diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b55679e..4f2f44f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - CQP queries - CorpusListing - `$rootScope` + - Auth module ### Changed diff --git a/app/scripts/app.ts b/app/scripts/app.ts index d065495d8..072fef192 100644 --- a/app/scripts/app.ts +++ b/app/scripts/app.ts @@ -51,7 +51,7 @@ korpApp.filter( input === "" ? "–" : input ) -authenticationProxy.initAngular() +authenticationProxy.initAngular(korpApp) /** * angular-dynamic-locale updates translations in the builtin $locale service, which is used diff --git a/app/scripts/components/auth/auth.js b/app/scripts/components/auth/auth.js deleted file mode 100644 index ece21fba3..000000000 --- a/app/scripts/components/auth/auth.js +++ /dev/null @@ -1,50 +0,0 @@ -/** @format */ -import statemachine from "@/statemachine" -import settings from "@/settings" - -let authModule -let authModuleName = settings["auth_module"]?.["module"] || settings["auth_module"] -if (authModuleName == "federated_auth") { - authModule = require("./federatedauth/fed_auth.js") -} else if (authModuleName == "basic_auth" || authModuleName == undefined) { - // load the default athentication - authModule = require("./basic_auth") -} else { - // must be a custom auth module - try { - authModule = require("custom/" + authModuleName) - } catch (error) { - console.log("Auth module not available: ", authModule) - } -} - -const init = async () => { - const loggedIn = await authModule.init() - if (loggedIn) { - statemachine.send("USER_FOUND") - } else { - statemachine.send("USER_NOT_FOUND") - } - return loggedIn -} - -const initAngular = authModule.initAngular -const login = authModule.login -const logout = authModule.logout -const getCredentials = authModule.getCredentials -const isLoggedIn = authModule.isLoggedIn -const getUsername = authModule.getUsername -const getAuthorizationHeader = authModule.getAuthorizationHeader -const hasCredential = authModule.hasCredential - -export { - init, - initAngular, - login, - logout, - getAuthorizationHeader, - hasCredential, - getCredentials, - getUsername, - isLoggedIn, -} diff --git a/app/scripts/components/auth/auth.ts b/app/scripts/components/auth/auth.ts new file mode 100644 index 000000000..ea4d8637a --- /dev/null +++ b/app/scripts/components/auth/auth.ts @@ -0,0 +1,39 @@ +/** @format */ +import statemachine from "@/statemachine" +import settings from "@/settings" +import { AuthModule } from "./auth.types" + +function findAuthModule(): AuthModule | undefined { + const authModuleName = settings["auth_module"]?.["module"] || settings["auth_module"] + if (authModuleName == "federated_auth") { + return require("./federatedauth/fed_auth") + } + if (authModuleName == "basic_auth" || authModuleName == undefined) { + // load the default athentication + return require("./basic_auth") + } + + // must be a custom auth module + try { + return require("custom/" + authModuleName) + } catch (error) { + console.log("Auth module not available: ", authModule) + } +} + +const authModule = findAuthModule() + +export async function init(): Promise { + const loggedIn = await authModule.init() + statemachine.send(loggedIn ? "USER_FOUND" : "USER_NOT_FOUND") + return loggedIn +} + +export const initAngular = authModule.initAngular +export const login = authModule.login +export const logout = authModule.logout +export const getCredentials = authModule.getCredentials +export const isLoggedIn = authModule.isLoggedIn +export const getUsername = authModule.getUsername +export const getAuthorizationHeader = authModule.getAuthorizationHeader +export const hasCredential = authModule.hasCredential diff --git a/app/scripts/components/auth/auth.types.ts b/app/scripts/components/auth/auth.types.ts new file mode 100644 index 000000000..ab65ec21f --- /dev/null +++ b/app/scripts/components/auth/auth.types.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { IModule } from "angular" + +export type AuthModule = { + /** + * Check if logged in. + * This is called before Angular app is initialized. + */ + init: () => boolean | Promise + /** Initialize in Angular context (add login form components etc) */ + initAngular: (korpApp: IModule) => void + /** Trigger interactive authentication workflow */ + login: Function + /** Trigger logout */ + logout: () => void + /** Get headers to include in API requests */ + getAuthorizationHeader: () => Record + /** Check if user has access to a given corpus */ + hasCredential: (corpusId: string) => boolean + /** Get corpus ids the user has access to */ + getCredentials: () => string[] + getUsername: () => string + isLoggedIn: () => boolean +} diff --git a/app/scripts/components/auth/basic_auth.js b/app/scripts/components/auth/basic_auth.ts similarity index 58% rename from app/scripts/components/auth/basic_auth.js rename to app/scripts/components/auth/basic_auth.ts index f7784fb03..bb71a9e44 100644 --- a/app/scripts/components/auth/basic_auth.js +++ b/app/scripts/components/auth/basic_auth.ts @@ -1,13 +1,15 @@ /** @format */ import _ from "lodash" +import { IModule } from "angular" import settings from "@/settings" +import { localStorageGet, localStorageSet } from "@/local-storage" import { loginBoxComponent } from "./login_box" import { loginStatusComponent } from "./login_status" -import { localStorageGet, localStorageSet } from "@/local-storage" +import { AuthState, LoginResponseData } from "./basic_auth.types" -const state = {} +const state: AuthState = {} -const init = () => { +export const init = () => { const creds = localStorageGet("creds") if (creds) { state.loginObj = creds @@ -15,27 +17,20 @@ const init = () => { return !_.isEmpty(creds) } -const initAngular = () => { - const korpApp = angular.module("korpApp") - +export const initAngular = (korpApp: IModule) => { korpApp.component("loginStatus", loginStatusComponent) korpApp.component("loginBox", loginBoxComponent) } -const getAuthorizationHeader = () => { - if (!_.isEmpty(state.loginObj)) { - return { Authorization: `Basic ${state.loginObj.auth}` } - } else { - return {} - } -} +export const getAuthorizationHeader = () => + !_.isEmpty(state.loginObj) ? { Authorization: `Basic ${state.loginObj.auth}` } : {} -function toBase64(str) { +function toBase64(str: string) { // copied from https://stackoverflow.com/a/43271130 - function u_btoa(buffer) { - var binary = [] - var bytes = new Uint8Array(buffer) - for (var i = 0, il = bytes.byteLength; i < il; i++) { + function u_btoa(buffer: Uint8Array | Buffer) { + const binary = [] + const bytes = new Uint8Array(buffer) + for (let i = 0; i < bytes.byteLength; i++) { binary.push(String.fromCharCode(bytes[i])) } return window.btoa(binary.join("")) @@ -43,17 +38,20 @@ function toBase64(str) { return u_btoa(new TextEncoder().encode(str)) } -const login = (usr, pass, saveLogin) => { +export const login = (usr: string, pass: string, saveLogin: boolean): JQueryDeferred => { const auth = toBase64(usr + ":" + pass) const dfd = $.Deferred() - $.ajax({ + + const ajaxSettings: JQuery.AjaxSettings = { url: settings["korp_backend_url"] + "/authenticate", type: "GET", beforeSend(req) { return req.setRequestHeader("Authorization", `Basic ${auth}`) }, - }) + } + + ;($.ajax(ajaxSettings) as JQuery.jqXHR) .done(function (data, status, xhr) { if (!data.corpora) { dfd.reject() @@ -77,32 +75,15 @@ const login = (usr, pass, saveLogin) => { return dfd } -const hasCredential = (corpusId) => { - if (!state.loginObj?.credentials) { - return false - } - return state.loginObj.credentials.includes(corpusId.toUpperCase()) -} +export const hasCredential = (corpusId: string): boolean => state.loginObj.credentials?.includes(corpusId.toUpperCase()) -const logout = () => { - state.loginObj = {} +export const logout = (): void => { + state.loginObj = undefined localStorage.removeItem("creds") } -const getCredentials = () => state.loginObj?.credentials || [] - -const getUsername = () => state.loginObj.name +export const getCredentials = (): string[] => state.loginObj?.credentials || [] -const isLoggedIn = () => !_.isEmpty(state.loginObj) +export const getUsername = () => state.loginObj.name -export { - init, - initAngular, - login, - logout, - getAuthorizationHeader, - hasCredential, - getCredentials, - getUsername, - isLoggedIn, -} +export const isLoggedIn = () => !_.isEmpty(state.loginObj) diff --git a/app/scripts/components/auth/basic_auth.types.ts b/app/scripts/components/auth/basic_auth.types.ts new file mode 100644 index 000000000..2559ec2d3 --- /dev/null +++ b/app/scripts/components/auth/basic_auth.types.ts @@ -0,0 +1,15 @@ +/** @format */ +import { Creds } from "@/local-storage" + +export type AuthState = { + loginObj?: Creds +} + +export type LoginResponseData = { + corpora: string[] +} + +export type AuthModuleOptions = { + show_remember?: boolean + default_value_remember?: boolean +} diff --git a/app/scripts/components/auth/federatedauth/fed_auth.js b/app/scripts/components/auth/federatedauth/fed_auth.js deleted file mode 100644 index 1d70e2be8..000000000 --- a/app/scripts/components/auth/federatedauth/fed_auth.js +++ /dev/null @@ -1,116 +0,0 @@ -/** - * @format - * @file This is a login module that fetches a JWT from a source to see if the user is already logged in. - * If the JWT call fails, it will redirect the user to a login service, a service that will redirect - * the user back to Korp. After that the JWT call is expected to return a JWT. - */ -import _ from "lodash" -import { loginStatusComponent } from "./login_status" -import settings from "@/settings" - -const state = { - jwt: null, - username: null, -} - -const init = async () => { - const response = await fetch(settings["auth_module"]["options"]["jwt_url"], { - headers: { accept: "text/plain" }, - credentials: "include", - }) - - if (!response.ok) { - if (response.status == 401) { - console.log("User not logged in") - } else { - console.warn(`An error has occured: ${response.status}`) - } - return false - } - - const jwt = await response.text() - state.jwt = jwt - - const jwtPayload = JSON.parse(atob(jwt.split(".")[1])) - state.username = jwtPayload.name || jwtPayload.email - state.credentials = [] - - state.credentials = _.reduce( - jwtPayload.scope.corpora, - (acc, val, key) => { - if (val >= jwtPayload.levels["READ"]) { - acc.push(key.toUpperCase()) - } - return acc - }, - [] - ) - - state.otherCredentials = _.reduce( - jwtPayload.scope.other, - (acc, val, key) => { - if (val >= jwtPayload.levels["READ"]) { - acc.push(key.toUpperCase()) - } - return acc - }, - [] - ) - - return true -} - -const initAngular = () => { - const korpApp = angular.module("korpApp") - korpApp.component("loginStatus", loginStatusComponent) -} - -const login = () => { - // TODO try to implement this again - // if we already tried to login, don't redirect again, to avoid infinite loops - // if (document.referrer == "") { - // } - window.location.href = `${settings["auth_module"]["options"]["login_service"]}?redirect=${window.location.href}` -} - -const getAuthorizationHeader = () => { - if (isLoggedIn()) { - return { Authorization: `Bearer ${state.jwt}` } - } - return {} -} - -const hasCredential = (corpusId) => { - return getCredentials().includes(corpusId) -} - -const logout = () => { - window.location.href = settings["auth_module"]["options"]["logout_service"] -} - -const getCredentials = () => { - return state.credentials || [] -} - -const getUsername = () => { - return state.username -} - -const isLoggedIn = () => { - return !_.isEmpty(state.jwt) -} - -const getOtherCredentials = () => state.otherCredentials || [] - -export { - init, - initAngular, - login, - logout, - getAuthorizationHeader, - hasCredential, - getCredentials, - getUsername, - isLoggedIn, - getOtherCredentials, -} diff --git a/app/scripts/components/auth/federatedauth/fed_auth.ts b/app/scripts/components/auth/federatedauth/fed_auth.ts new file mode 100644 index 000000000..91ed4d6ab --- /dev/null +++ b/app/scripts/components/auth/federatedauth/fed_auth.ts @@ -0,0 +1,91 @@ +/** + * @format + * @file This is a login module that fetches a JWT from a source to see if the user is already logged in. + * If the JWT call fails, it will redirect the user to a login service, a service that will redirect + * the user back to Korp. After that the JWT call is expected to return a JWT. + */ +import _ from "lodash" +import { loginStatusComponent } from "./login_status" +import settings from "@/settings" +import { IModule } from "angular" + +type State = { + credentials?: string[] + otherCredentials?: string[] + jwt?: string + username?: string +} + +type JwtPayload = { + name?: string + email: string + scope: { + corpora?: Record + other?: Record + } + levels: Record<"READ" | "WRITE" | "ADMIN", number> +} + +const state: State = { + jwt: null, + username: null, +} + +export const init = async () => { + const response = await fetch(settings.auth_module["options"]?.jwt_url, { + headers: { accept: "text/plain" }, + credentials: "include", + }) + + if (!response.ok) { + if (response.status == 401) { + console.log("User not logged in") + } else { + console.warn(`An error has occured: ${response.status}`) + } + return false + } + + const jwt = await response.text() + state.jwt = jwt + + const jwtPayload: JwtPayload = JSON.parse(atob(jwt.split(".")[1])) + const { name, email, scope, levels } = jwtPayload + state.username = name || email + + state.credentials = Object.keys(scope.corpora || {}) + .filter((id) => scope.corpora[id] > levels["READ"]) + .map((id) => id.toUpperCase()) + + state.otherCredentials = Object.keys(scope.other || {}) + .filter((id) => scope.other[id] > levels["READ"]) + .map((id) => id.toUpperCase()) + + return true +} + +export const initAngular = (korpApp: IModule) => { + korpApp.component("loginStatus", loginStatusComponent) +} + +export const login = () => { + // TODO try to implement this again + // if we already tried to login, don't redirect again, to avoid infinite loops + // if (document.referrer == "") { + // } + window.location.href = `${settings["auth_module"]["options"]["login_service"]}?redirect=${window.location.href}` +} + +export const getAuthorizationHeader = () => (isLoggedIn() ? { Authorization: `Bearer ${state.jwt}` } : {}) + +export const hasCredential = (corpusId) => getCredentials().includes(corpusId) + +export const logout = () => (window.location.href = settings["auth_module"]["options"]["logout_service"]) + +export const getCredentials = () => state.credentials || [] + +export const getUsername = () => state.username + +export const isLoggedIn = () => !_.isEmpty(state.jwt) + +export const getOtherCredentials = () => state.otherCredentials || [] diff --git a/app/scripts/components/auth/federatedauth/login_status.js b/app/scripts/components/auth/federatedauth/login_status.ts similarity index 62% rename from app/scripts/components/auth/federatedauth/login_status.js rename to app/scripts/components/auth/federatedauth/login_status.ts index 7ee0576f8..0390fa558 100644 --- a/app/scripts/components/auth/federatedauth/login_status.js +++ b/app/scripts/components/auth/federatedauth/login_status.ts @@ -1,9 +1,11 @@ /** @format */ +import { IComponentOptions, IController, ITimeoutService } from "angular" import statemachine from "@/statemachine" -import * as authenticationProxy from "../auth" import { html } from "@/util" +import { CorpusTransformed } from "@/settings/config-transformed.types" +import { getUsername, isLoggedIn, login } from "@/components/auth/auth" -export const loginStatusComponent = { +export const loginStatusComponent: IComponentOptions = { template: html`