From 19a0dffe6cc8757f9a27a7c81cb512f912a6547d Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 26 Aug 2022 16:56:20 +0200 Subject: [PATCH] feat: added support for authentication via Keycloak (#1381) closes #1340 Co-authored-by: Sebastian --- package-lock.json | 29 ++++ package.json | 1 + src/app/core/session/auth/auth-provider.ts | 18 +++ src/app/core/session/auth/auth.service.ts | 37 +++++ .../auth/couchdb/couchdb-auth.service.spec.ts | 111 +++++++++++++ .../auth/couchdb/couchdb-auth.service.ts | 112 +++++++++++++ .../password-form.component.html | 94 +++++++++++ .../password-form.component.spec.ts | 88 +++++++++++ .../password-form/password-form.component.ts | 102 ++++++++++++ .../keycloak/keycloak-auth.service.spec.ts | 149 ++++++++++++++++++ .../auth/keycloak/keycloak-auth.service.ts | 120 ++++++++++++++ .../password-button.component.html | 12 ++ .../password-button.component.spec.ts | 26 +++ .../password-button.component.ts | 18 +++ .../session-service/remote-session.spec.ts | 76 ++------- .../session/session-service/remote-session.ts | 72 ++++----- .../synced-session.service.spec.ts | 87 ++++------ .../session-service/synced-session.service.ts | 24 ++- src/app/core/session/session.module.ts | 34 +++- .../user-account/user-account.component.html | 121 ++------------ .../user-account.component.spec.ts | 77 +-------- .../user-account/user-account.component.ts | 88 ++--------- .../user-account/user-account.service.spec.ts | 57 ------- .../user/user-account/user-account.service.ts | 60 ------- src/app/core/user/user.module.ts | 17 +- src/app/utils/utils.ts | 24 +++ src/assets/keycloak.json | 10 ++ src/environments/environment.prod.ts | 2 + src/environments/environment.ts | 2 + 29 files changed, 1106 insertions(+), 562 deletions(-) create mode 100644 src/app/core/session/auth/auth-provider.ts create mode 100644 src/app/core/session/auth/auth.service.ts create mode 100644 src/app/core/session/auth/couchdb/couchdb-auth.service.spec.ts create mode 100644 src/app/core/session/auth/couchdb/couchdb-auth.service.ts create mode 100644 src/app/core/session/auth/couchdb/password-form/password-form.component.html create mode 100644 src/app/core/session/auth/couchdb/password-form/password-form.component.spec.ts create mode 100644 src/app/core/session/auth/couchdb/password-form/password-form.component.ts create mode 100644 src/app/core/session/auth/keycloak/keycloak-auth.service.spec.ts create mode 100644 src/app/core/session/auth/keycloak/keycloak-auth.service.ts create mode 100644 src/app/core/session/auth/keycloak/password-button/password-button.component.html create mode 100644 src/app/core/session/auth/keycloak/password-button/password-button.component.spec.ts create mode 100644 src/app/core/session/auth/keycloak/password-button/password-button.component.ts delete mode 100644 src/app/core/user/user-account/user-account.service.spec.ts delete mode 100644 src/app/core/user/user-account/user-account.service.ts create mode 100644 src/assets/keycloak.json diff --git a/package-lock.json b/package-lock.json index 48065ab122..7adecfcc49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "flag-icons": "^6.6.4", "hammerjs": "^2.0.8", "json-query": "^2.2.2", + "keycloak-js": "^18.0.1", "lodash-es": "^4.17.21", "md5": "^2.3.0", "moment": "^2.29.4", @@ -21327,6 +21328,11 @@ "node": ">=8" } }, + "node_modules/js-sha256": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", + "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==" + }, "node_modules/js-string-escape": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", @@ -21741,6 +21747,15 @@ "node": ">= 12" } }, + "node_modules/keycloak-js": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-18.0.1.tgz", + "integrity": "sha512-IRXToYpbIrkyfLeNNJly2OjUCf11ncx2Sdsg257NVDwjOYE923osu47w8pfxEVWpTaS14/Y2QjbTHciuBK0RBQ==", + "dependencies": { + "base64-js": "^1.5.1", + "js-sha256": "^0.9.0" + } + }, "node_modules/khroma": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.0.0.tgz", @@ -48735,6 +48750,11 @@ } } }, + "js-sha256": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", + "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==" + }, "js-string-escape": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", @@ -49063,6 +49083,15 @@ } } }, + "keycloak-js": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-18.0.1.tgz", + "integrity": "sha512-IRXToYpbIrkyfLeNNJly2OjUCf11ncx2Sdsg257NVDwjOYE923osu47w8pfxEVWpTaS14/Y2QjbTHciuBK0RBQ==", + "requires": { + "base64-js": "^1.5.1", + "js-sha256": "^0.9.0" + } + }, "khroma": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.0.0.tgz", diff --git a/package.json b/package.json index bd6e645747..585775d46e 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "flag-icons": "^6.6.4", "hammerjs": "^2.0.8", "json-query": "^2.2.2", + "keycloak-js": "^18.0.1", "lodash-es": "^4.17.21", "md5": "^2.3.0", "moment": "^2.29.4", diff --git a/src/app/core/session/auth/auth-provider.ts b/src/app/core/session/auth/auth-provider.ts new file mode 100644 index 0000000000..1931b02834 --- /dev/null +++ b/src/app/core/session/auth/auth-provider.ts @@ -0,0 +1,18 @@ +/** + * Available authentication providers. + */ +export enum AuthProvider { + /** + * Default auth provider using CouchDB's `_users` database and permission settings. + * This is the simplest setup as no other service besides the CouchDB is required. + * However, this provider comes with limited functionality. + */ + CouchDB = "couchdb", + + /** + * Keycloak is used to authenticate and manage users. + * This requires keycloak and potentially other services to be running. + * Also, the client configuration has to be placed in a file called `keycloak.json` in the `assets` folder. + */ + Keycloak = "keycloak", +} diff --git a/src/app/core/session/auth/auth.service.ts b/src/app/core/session/auth/auth.service.ts new file mode 100644 index 0000000000..0167879840 --- /dev/null +++ b/src/app/core/session/auth/auth.service.ts @@ -0,0 +1,37 @@ +import { HttpHeaders } from "@angular/common/http"; +import { DatabaseUser } from "../session-service/local-user"; + +/** + * Abstract class that handles user authentication and password change. + * Implement this for different authentication providers. + * See {@link AuthProvider} for available options. + */ +export abstract class AuthService { + /** + * Authenticate a user with credentials. + * @param username The username of the user + * @param password The password of the user + * @returns Promise that resolves with the user if the login was successful, rejects otherwise. + */ + abstract authenticate( + username: string, + password: string + ): Promise; + + /** + * Authenticate a user without credentials based on a still valid session. + * @returns Promise that resolves with the user if the session is still valid, rejects otherwise. + */ + abstract autoLogin(): Promise; + + /** + * Add headers to requests send by PouchDB if required for authentication. + * @param headers the object where further headers can be added + */ + abstract addAuthHeader(headers: HttpHeaders); + + /** + * Clear the local session of the currently logged-in user. + */ + abstract logout(): Promise; +} diff --git a/src/app/core/session/auth/couchdb/couchdb-auth.service.spec.ts b/src/app/core/session/auth/couchdb/couchdb-auth.service.spec.ts new file mode 100644 index 0000000000..8b89ce0734 --- /dev/null +++ b/src/app/core/session/auth/couchdb/couchdb-auth.service.spec.ts @@ -0,0 +1,111 @@ +import { TestBed } from "@angular/core/testing"; + +import { CouchdbAuthService } from "./couchdb-auth.service"; +import { + HttpClient, + HttpErrorResponse, + HttpStatusCode, +} from "@angular/common/http"; +import { of, throwError } from "rxjs"; +import { + TEST_PASSWORD, + TEST_USER, +} from "../../../../utils/mocked-testing.module"; + +describe("CouchdbAuthService", () => { + let service: CouchdbAuthService; + let mockHttpClient: jasmine.SpyObj; + let dbUser = { name: TEST_USER, roles: ["user_app"] }; + + beforeEach(() => { + mockHttpClient = jasmine.createSpyObj(["get", "post", "put"]); + mockHttpClient.get.and.returnValue(throwError(() => new Error())); + mockHttpClient.post.and.callFake((_url, body) => { + if (body.name === TEST_USER && body.password === TEST_PASSWORD) { + return of(dbUser as any); + } else { + return throwError( + () => + new HttpErrorResponse({ + status: HttpStatusCode.Unauthorized, + }) + ); + } + }); + + TestBed.configureTestingModule({ + providers: [ + CouchdbAuthService, + { provide: HttpClient, useValue: mockHttpClient }, + ], + }); + service = TestBed.inject(CouchdbAuthService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("should return the current user after successful login", async () => { + const user = await service.authenticate(TEST_USER, TEST_PASSWORD); + expect(user).toEqual(dbUser); + }); + + it("should login, given that CouchDB cookie is still valid", async () => { + const responseObject = { + ok: true, + userCtx: dbUser, + info: { + authentication_handlers: ["cookie", "default"], + authenticated: "default", + }, + }; + mockHttpClient.get.and.returnValue(of(responseObject)); + const user = await service.autoLogin(); + + expect(user).toEqual(responseObject.userCtx); + }); + + it("should not login, given that there is no valid CouchDB cookie", () => { + const responseObject = { + ok: true, + userCtx: { + name: null, + roles: [], + }, + info: { + authentication_handlers: ["cookie", "default"], + }, + }; + mockHttpClient.get.and.returnValue(of(responseObject)); + return expectAsync(service.autoLogin()).toBeRejected(); + }); + + it("should reject if current user cant be fetched", () => { + mockHttpClient.get.and.returnValue(throwError(() => new Error())); + + return expectAsync( + service.changePassword("username", "wrongPW", "") + ).toBeRejected(); + }); + + it("should report error when new Password cannot be saved", async () => { + mockHttpClient.get.and.returnValues(of({})); + mockHttpClient.put.and.returnValue(throwError(() => new Error())); + + await expectAsync( + service.changePassword("username", "testPW", "") + ).toBeRejected(); + expect(mockHttpClient.get).toHaveBeenCalled(); + expect(mockHttpClient.put).toHaveBeenCalled(); + }); + + it("should not fail if get and put requests are successful", () => { + mockHttpClient.get.and.returnValues(of({})); + mockHttpClient.put.and.returnValues(of({})); + + return expectAsync( + service.changePassword("username", "testPW", "newPW") + ).not.toBeRejected(); + }); +}); diff --git a/src/app/core/session/auth/couchdb/couchdb-auth.service.ts b/src/app/core/session/auth/couchdb/couchdb-auth.service.ts new file mode 100644 index 0000000000..9f47624254 --- /dev/null +++ b/src/app/core/session/auth/couchdb/couchdb-auth.service.ts @@ -0,0 +1,112 @@ +import { Injectable } from "@angular/core"; +import { AuthService } from "../auth.service"; +import { + HttpClient, + HttpErrorResponse, + HttpHeaders, + HttpStatusCode, +} from "@angular/common/http"; +import { firstValueFrom } from "rxjs"; +import { DatabaseUser } from "../../session-service/local-user"; +import { AppSettings } from "../../../app-config/app-settings"; + +@Injectable() +export class CouchdbAuthService extends AuthService { + private static readonly COUCHDB_USER_ENDPOINT = `${AppSettings.DB_PROXY_PREFIX}/_users/org.couchdb.user`; + + constructor(private http: HttpClient) { + super(); + } + + addAuthHeader() { + // auth happens through cookie + return; + } + + authenticate(username: string, password: string): Promise { + return firstValueFrom( + this.http.post( + `${AppSettings.DB_PROXY_PREFIX}/_session`, + { name: username, password: password }, + { withCredentials: true } + ) + ); + } + + autoLogin(): Promise { + return firstValueFrom( + this.http.get<{ userCtx: DatabaseUser }>( + `${AppSettings.DB_PROXY_PREFIX}/_session`, + { withCredentials: true } + ) + ).then((res: any) => { + if (res.userCtx.name) { + return res.userCtx; + } else { + throw new HttpErrorResponse({ + status: HttpStatusCode.Unauthorized, + }); + } + }); + } + + /** + * Function to change the password of a user + * @param username The username for which the password should be changed + * @param oldPassword The current plaintext password of the user + * @param newPassword The new plaintext password of the user + * @return Promise that resolves once the password is changed in _user and the database + */ + public async changePassword( + username?: string, + oldPassword?: string, + newPassword?: string + ): Promise { + let userResponse; + try { + // TODO due to cookie-auth, the old password is actually not checked + userResponse = await this.getCouchDBUser(username, oldPassword); + } catch (e) { + throw new Error("Current password incorrect or server not available"); + } + + userResponse.password = newPassword; + try { + await this.saveNewPasswordToCouchDB(username, oldPassword, userResponse); + } catch (e) { + throw new Error( + "Could not save new password, please contact your system administrator" + ); + } + } + + private getCouchDBUser(username: string, password: string): Promise { + const userUrl = CouchdbAuthService.COUCHDB_USER_ENDPOINT + ":" + username; + const headers: HttpHeaders = new HttpHeaders({ + Authorization: "Basic " + btoa(username + ":" + password), + }); + return firstValueFrom(this.http.get(userUrl, { headers: headers })); + } + + private saveNewPasswordToCouchDB( + username: string, + oldPassword: string, + userObj: any + ): Promise { + const userUrl = CouchdbAuthService.COUCHDB_USER_ENDPOINT + ":" + username; + const headers: HttpHeaders = new HttpHeaders({ + Authorization: "Basic " + btoa(username + ":" + oldPassword), + }); + return firstValueFrom( + this.http.put(userUrl, userObj, { headers: headers }) + ); + } + + logout(): Promise { + return firstValueFrom( + this.http.delete(`${AppSettings.DB_PROXY_PREFIX}/_session`, { + withCredentials: true, + }) + ); + } +} diff --git a/src/app/core/session/auth/couchdb/password-form/password-form.component.html b/src/app/core/session/auth/couchdb/password-form/password-form.component.html new file mode 100644 index 0000000000..02b8a40904 --- /dev/null +++ b/src/app/core/session/auth/couchdb/password-form/password-form.component.html @@ -0,0 +1,94 @@ +
+ + + + Please provide your correct current password for confirmation. + + +
+ + + + +
+ Please enter a new password. +
+
+ Must be at least 8 characters long. +
+
+ Must contain lower case letters, upper case letters, symbols and + numbers to be secure. +
+
+
+
+ + + + + Passwords don't match. + + +
+ +
+
+ Password changed successfully. +
+
+ Failed to change password: {{ passwordChangeResult.error }}
+ Please try again. If the problem persists contact Aam Digital + support. +
+
+ + +
diff --git a/src/app/core/session/auth/couchdb/password-form/password-form.component.spec.ts b/src/app/core/session/auth/couchdb/password-form/password-form.component.spec.ts new file mode 100644 index 0000000000..48701890b1 --- /dev/null +++ b/src/app/core/session/auth/couchdb/password-form/password-form.component.spec.ts @@ -0,0 +1,88 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; + +import { PasswordFormComponent } from "./password-form.component"; +import { UserModule } from "../../../../user/user.module"; +import { MockedTestingModule } from "../../../../../utils/mocked-testing.module"; +import { SessionService } from "../../../session-service/session.service"; +import { CouchdbAuthService } from "../couchdb-auth.service"; + +describe("PasswordFormComponent", () => { + let component: PasswordFormComponent; + let fixture: ComponentFixture; + let mockSessionService: jasmine.SpyObj; + let mockCouchDBAuth: jasmine.SpyObj; + + beforeEach(async () => { + mockSessionService = jasmine.createSpyObj(["login", "checkPassword"]); + mockCouchDBAuth = jasmine.createSpyObj(["changePassword"]); + + await TestBed.configureTestingModule({ + imports: [UserModule, MockedTestingModule.withState()], + providers: [{ provide: SessionService, useValue: mockSessionService }], + }).compileComponents(); + + fixture = TestBed.createComponent(PasswordFormComponent); + component = fixture.componentInstance; + component.couchdbAuthService = mockCouchDBAuth; + component.username = "testUser"; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should disable the form when disabled is passed to component", () => { + component.disabled = true; + component.ngOnInit(); + expect(component.passwordForm.disabled).toBeTrue(); + }); + + it("should set error when password is incorrect", () => { + component.passwordForm.get("currentPassword").setValue("wrongPW"); + mockSessionService.checkPassword.and.returnValue(false); + + expect(component.passwordForm.get("currentPassword")).toBeValidForm(); + + component.changePassword(); + + expect(component.passwordForm.get("currentPassword")).not.toBeValidForm(); + }); + + it("should set error when password change fails", fakeAsync(() => { + component.passwordForm.get("currentPassword").setValue("testPW"); + component.passwordForm.get("newPassword").setValue("Password1-"); + component.passwordForm.get("confirmPassword").setValue("Password1-"); + mockSessionService.checkPassword.and.returnValue(true); + mockCouchDBAuth.changePassword.and.rejectWith(new Error("pw change error")); + + expectAsync(component.changePassword()).toBeRejected(); + tick(); + + expect(mockCouchDBAuth.changePassword).toHaveBeenCalled(); + expect(component.passwordChangeResult.success).toBeFalse(); + expect(component.passwordChangeResult.error).toBe("pw change error"); + })); + + it("should set success and re-login when password change worked", fakeAsync(() => { + component.passwordForm.get("currentPassword").setValue("testPW"); + component.passwordForm.get("newPassword").setValue("Password1-"); + component.passwordForm.get("confirmPassword").setValue("Password1-"); + mockSessionService.checkPassword.and.returnValue(true); + mockCouchDBAuth.changePassword.and.resolveTo(); + mockSessionService.login.and.resolveTo(null); + + component.changePassword(); + tick(); + expect(component.passwordChangeResult.success).toBeTrue(); + expect(mockSessionService.login).toHaveBeenCalledWith( + "testUser", + "Password1-" + ); + })); +}); diff --git a/src/app/core/session/auth/couchdb/password-form/password-form.component.ts b/src/app/core/session/auth/couchdb/password-form/password-form.component.ts new file mode 100644 index 0000000000..59f3ee7912 --- /dev/null +++ b/src/app/core/session/auth/couchdb/password-form/password-form.component.ts @@ -0,0 +1,102 @@ +import { Component, Input, OnInit } from "@angular/core"; +import { FormBuilder, ValidationErrors, Validators } from "@angular/forms"; +import { SessionService } from "../../../session-service/session.service"; +import { LoggingService } from "../../../../logging/logging.service"; +import { AuthService } from "../../auth.service"; +import { CouchdbAuthService } from "../couchdb-auth.service"; + +/** + * A simple password form that enforces secure password. + */ +@Component({ + selector: "app-password-form", + templateUrl: "./password-form.component.html", +}) +export class PasswordFormComponent implements OnInit { + @Input() username: string; + @Input() disabled = false; + + couchdbAuthService: CouchdbAuthService; + + passwordChangeResult: { success: boolean; error?: any }; + + passwordForm = this.fb.group( + { + currentPassword: ["", Validators.required], + newPassword: [ + "", + [ + Validators.required, + Validators.minLength(8), + Validators.pattern(/[A-Z]/), + Validators.pattern(/[a-z]/), + Validators.pattern(/\d/), + Validators.pattern(/[^A-Za-z0-9]/), + ], + ], + confirmPassword: ["", [Validators.required]], + }, + { validators: () => this.passwordMatchValidator() } + ); + + constructor( + private fb: FormBuilder, + private sessionService: SessionService, + private loggingService: LoggingService, + authService: AuthService + ) { + if (authService instanceof CouchdbAuthService) { + this.couchdbAuthService = authService; + } + } + + ngOnInit() { + if (this.disabled) { + this.passwordForm.disable(); + } + } + + changePassword(): Promise { + this.passwordChangeResult = undefined; + + const currentPassword = this.passwordForm.get("currentPassword").value; + + if (!this.sessionService.checkPassword(this.username, currentPassword)) { + this.passwordForm + .get("currentPassword") + .setErrors({ incorrectPassword: true }); + return; + } + + if (this.passwordForm.invalid) { + return; + } + + const newPassword = this.passwordForm.get("newPassword").value; + return this.couchdbAuthService + .changePassword(this.username, currentPassword, newPassword) + .then(() => this.sessionService.login(this.username, newPassword)) + .then(() => (this.passwordChangeResult = { success: true })) + .catch((err: Error) => { + this.passwordChangeResult = { success: false, error: err.message }; + this.loggingService.error({ + error: "password change failed", + details: err.message, + }); + // rethrow to properly report to sentry.io; this exception is not expected, only caught to display in UI + throw err; + }); + } + + private passwordMatchValidator(): ValidationErrors | null { + const newPassword = this.passwordForm?.get("newPassword").value; + const confirmPassword = this.passwordForm?.get("confirmPassword").value; + if (newPassword !== confirmPassword) { + this.passwordForm + .get("confirmPassword") + .setErrors({ passwordConfirmationMismatch: true }); + return { passwordConfirmationMismatch: true }; + } + return null; + } +} diff --git a/src/app/core/session/auth/keycloak/keycloak-auth.service.spec.ts b/src/app/core/session/auth/keycloak/keycloak-auth.service.spec.ts new file mode 100644 index 0000000000..54cba568cb --- /dev/null +++ b/src/app/core/session/auth/keycloak/keycloak-auth.service.spec.ts @@ -0,0 +1,149 @@ +import { fakeAsync, TestBed, tick } from "@angular/core/testing"; + +import { + OIDCTokenResponse, + KeycloakAuthService, +} from "./keycloak-auth.service"; +import { + TEST_PASSWORD, + TEST_USER, +} from "../../../../utils/mocked-testing.module"; +import { of, throwError } from "rxjs"; +import { + HttpClient, + HttpErrorResponse, + HttpStatusCode, +} from "@angular/common/http"; +import { DatabaseUser } from "../../session-service/local-user"; + +function keycloakAuthHttpFake(_url, body) { + const params = new URLSearchParams(body); + const isValidPassword = + params.get("username") === TEST_USER && + params.get("password") === TEST_PASSWORD; + const isValidToken = params.get("refresh_token") === "test-refresh-token"; + if (isValidPassword || isValidToken) { + return of(jwtTokenResponse as any); + } else { + return throwError( + () => + new HttpErrorResponse({ + status: HttpStatusCode.Unauthorized, + }) + ); + } +} + +/** + * Check {@link https://jwt.io} to decode the access_token. + * Extract: + * ```json + * { + * "sub": "881ba191-0d27-4dff-9bc4-2c9e561ac900", + * "username": "test", + * "exp": 1658138259, + * "_couchdb.roles": [ + * "user_app" + * ], + * ... + * } + * ``` + */ +const jwtTokenResponse: OIDCTokenResponse = { + access_token: + "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJOTzU3NEpPTmoxWUM0V3VLbEtMN0R0dUdHemJTdEQ3WUFaX3FONUk0WDB3In0.eyJleHAiOjE2NTkxMTI1NjUsImlhdCI6MTY1OTExMjI2NSwianRpIjoiODYwMmJiMDQtZDA2Mi00MjcxLWFlYmMtN2I0MjY3YmY0MDNlIiwiaXNzIjoiaHR0cHM6Ly9rZXljbG9hay10ZXN0LmFhbS1kaWdpdGFsLmNvbTo0NDMvYXV0aC9yZWFsbXMva2V5Y2xvYWstdGVzdCIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiI4ODFiYTE5MS0wZDI3LTRkZmYtOWJjNC0yYzllNTYxYWM5MDAiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJhcHAiLCJzZXNzaW9uX3N0YXRlIjoiNTYwMmNhZDgtMjgxNS00YTY5LWFlN2YtZWY2MjVmZjE1ZGUyIiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyIqIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLWtleWNsb2FrLXRlc3QiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYXBwIjp7InJvbGVzIjpbInVzZXJfYXBwIl19LCJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6InByb2ZpbGUgZW1haWwiLCJzaWQiOiI1NjAyY2FkOC0yODE1LTRhNjktYWU3Zi1lZjYyNWZmMTVkZTIiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIl9jb3VjaGRiLnJvbGVzIjpbInVzZXJfYXBwIl0sInByZWZlcnJlZF91c2VybmFtZSI6InRlc3QiLCJnaXZlbl9uYW1lIjoiIiwiZmFtaWx5X25hbWUiOiIiLCJ1c2VybmFtZSI6InRlc3QifQ.g0Lq8tPN9fdni-tro7xcT4g4Ju-pyFTlYY8hjy-H34jxjkFDh6eTSjmnkof8w6r5TDg7V18k3WMz5Bf4XXt9kJtrVM0nOFq7wY-BSRdvl1TtMpRRkGlEUg5CMxCoyhkpkL1dcYslKlxNw4qwavvcjqYdtL7LU7ezZfs9wcAUV0VB9frxIzhq3WW6eHPBWYdFJFY1H5kl7jI6gtrLEc25tC-8Hpsz12Ey8O1DnsTqS7cXa1gNSGY10xYO9zNhxNfYy_x4uaaVJviT-gq9Bz-LM55H9s7Nz_FT9ETHNBm479jetBwURWLR-QRTwEdgajQWUUBw3l4Ld15q1YUSVSn1Ww", + refresh_token: "test-refresh-token", + expires_in: 120, + session_state: "test-session-state", +}; + +describe("KeycloakAuthService", () => { + let service: KeycloakAuthService; + let mockHttpClient: jasmine.SpyObj; + let dbUser: DatabaseUser; + + beforeEach(() => { + mockHttpClient = jasmine.createSpyObj(["post"]); + mockHttpClient.post.and.callFake(keycloakAuthHttpFake); + TestBed.configureTestingModule({ + providers: [ + { provide: HttpClient, useValue: mockHttpClient }, + KeycloakAuthService, + ], + }); + dbUser = { name: TEST_USER, roles: ["user_app"] }; + service = TestBed.inject(KeycloakAuthService); + // Mock initialization of keycloak + service["keycloakReady"] = Promise.resolve() as any; + }); + + afterEach(() => + window.localStorage.removeItem(KeycloakAuthService.REFRESH_TOKEN_KEY) + ); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("should take username and roles from jwtToken", async () => { + const user = await service.authenticate(TEST_USER, TEST_PASSWORD); + + expect(user).toEqual(dbUser); + }); + + it("should store access token in memory and refresh token in local storage", async () => { + await service.authenticate(TEST_USER, TEST_PASSWORD); + + expect(service.accessToken).toBe(jwtTokenResponse.access_token); + expect( + window.localStorage.getItem(KeycloakAuthService.REFRESH_TOKEN_KEY) + ).toBe("test-refresh-token"); + }); + + it("should update token before it expires", fakeAsync(() => { + // token has 2 minutes expiration time + service.authenticate(TEST_USER, TEST_PASSWORD); + tick(); + + mockHttpClient.post.calls.reset(); + const newToken = { ...jwtTokenResponse, access_token: "new.token" }; + // mock token cannot be parsed as JwtToken + spyOn(window, "atob").and.returnValue('{"decoded": "token"}'); + mockHttpClient.post.and.returnValue(of(newToken)); + // should refresh token one minute before it expires + tick(60 * 1000); + + expect(mockHttpClient.post).toHaveBeenCalled(); + expect(service.accessToken).toBe("new.token"); + + // clear timeouts + service.logout(); + })); + + it("should call keycloak for a password reset", () => { + const loginSpy = spyOn(service["keycloak"], "login"); + + service.changePassword(); + + expect(loginSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ action: "UPDATE_PASSWORD" }) + ); + }); + + it("should login, if there is a valid refresh token", async () => { + localStorage.setItem( + KeycloakAuthService.REFRESH_TOKEN_KEY, + "some-refresh-token" + ); + mockHttpClient.post.and.returnValue(of(jwtTokenResponse)); + const user = await service.autoLogin(); + expect(user).toEqual(dbUser); + }); + + it("should not login, given that there is no valid refresh token", () => { + mockHttpClient.post.and.returnValue( + throwError(() => new HttpErrorResponse({})) + ); + return expectAsync(service.autoLogin()).toBeRejected(); + }); +}); diff --git a/src/app/core/session/auth/keycloak/keycloak-auth.service.ts b/src/app/core/session/auth/keycloak/keycloak-auth.service.ts new file mode 100644 index 0000000000..20eb176bbd --- /dev/null +++ b/src/app/core/session/auth/keycloak/keycloak-auth.service.ts @@ -0,0 +1,120 @@ +import { AuthService } from "../auth.service"; +import { Injectable } from "@angular/core"; +import Keycloak from "keycloak-js"; +import { HttpClient, HttpHeaders } from "@angular/common/http"; +import { firstValueFrom } from "rxjs"; +import { DatabaseUser } from "../../session-service/local-user"; +import { parseJwt } from "../../../../utils/utils"; + +@Injectable() +export class KeycloakAuthService extends AuthService { + static readonly REFRESH_TOKEN_KEY = "REFRESH_TOKEN"; + + public accessToken: string; + + private keycloak = new Keycloak("assets/keycloak.json"); + private keycloakReady = this.keycloak.init({}); + private refreshTokenTimeout; + + constructor(private httpClient: HttpClient) { + super(); + } + + authenticate(username: string, password: string): Promise { + return this.keycloakReady + .then(() => this.credentialAuth(username, password)) + .then((token) => this.processToken(token)); + } + + autoLogin(): Promise { + return this.keycloakReady + .then(() => this.refreshTokenAuth()) + .then((token) => this.processToken(token)); + } + + private credentialAuth( + username: string, + password: string + ): Promise { + const body = new URLSearchParams(); + body.set("username", username); + body.set("password", password); + body.set("grant_type", "password"); + return this.getToken(body); + } + + private refreshTokenAuth(): Promise { + const body = new URLSearchParams(); + const token = localStorage.getItem(KeycloakAuthService.REFRESH_TOKEN_KEY); + body.set("refresh_token", token); + body.set("grant_type", "refresh_token"); + return this.getToken(body); + } + + private getToken(body: URLSearchParams): Promise { + body.set("client_id", "app"); + const options = { + headers: new HttpHeaders().set( + "Content-Type", + "application/x-www-form-urlencoded" + ), + }; + return firstValueFrom( + this.httpClient.post( + `${this.keycloak.authServerUrl}realms/${this.keycloak.realm}/protocol/openid-connect/token`, + body.toString(), + options + ) + ); + } + + private processToken(token: OIDCTokenResponse): DatabaseUser { + this.accessToken = token.access_token; + localStorage.setItem( + KeycloakAuthService.REFRESH_TOKEN_KEY, + token.refresh_token + ); + this.refreshTokenBeforeExpiry(token.expires_in); + const parsedToken = parseJwt(this.accessToken); + return { + name: parsedToken.username, + roles: parsedToken["_couchdb.roles"], + }; + } + + private refreshTokenBeforeExpiry(secondsTillExpiration: number) { + // Refresh token one minute before it expires or after ten seconds + const refreshTimeout = Math.max(10, secondsTillExpiration - 60); + this.refreshTokenTimeout = setTimeout( + () => this.refreshTokenAuth().then((token) => this.processToken(token)), + refreshTimeout * 1000 + ); + } + + addAuthHeader(headers: HttpHeaders) { + headers.set("Authorization", "Bearer " + this.accessToken); + } + + async logout() { + clearTimeout(this.refreshTokenTimeout); + window.localStorage.removeItem(KeycloakAuthService.REFRESH_TOKEN_KEY); + } + + /** + * Open password reset page in browser. + * Only works with internet connection. + */ + changePassword(): Promise { + return this.keycloak.login({ + action: "UPDATE_PASSWORD", + redirectUri: location.href, + }); + } +} + +export interface OIDCTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + session_state: string; +} diff --git a/src/app/core/session/auth/keycloak/password-button/password-button.component.html b/src/app/core/session/auth/keycloak/password-button/password-button.component.html new file mode 100644 index 0000000000..4c10783908 --- /dev/null +++ b/src/app/core/session/auth/keycloak/password-button/password-button.component.html @@ -0,0 +1,12 @@ + diff --git a/src/app/core/session/auth/keycloak/password-button/password-button.component.spec.ts b/src/app/core/session/auth/keycloak/password-button/password-button.component.spec.ts new file mode 100644 index 0000000000..6a6d9972f2 --- /dev/null +++ b/src/app/core/session/auth/keycloak/password-button/password-button.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { PasswordButtonComponent } from "./password-button.component"; +import { AuthService } from "../../auth.service"; + +describe("PasswordButtonComponent", () => { + let component: PasswordButtonComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [PasswordButtonComponent], + providers: [ + { provide: AuthService, useValue: { changePassword: () => undefined } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PasswordButtonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/core/session/auth/keycloak/password-button/password-button.component.ts b/src/app/core/session/auth/keycloak/password-button/password-button.component.ts new file mode 100644 index 0000000000..2984fd1a7a --- /dev/null +++ b/src/app/core/session/auth/keycloak/password-button/password-button.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from "@angular/core"; +import { AuthService } from "../../auth.service"; +import { KeycloakAuthService } from "../keycloak-auth.service"; + +@Component({ + selector: "app-password-button", + templateUrl: "./password-button.component.html", +}) +export class PasswordButtonComponent { + @Input() disabled: boolean; + keycloakAuthService: KeycloakAuthService; + + constructor(authService: AuthService) { + if (authService instanceof KeycloakAuthService) { + this.keycloakAuthService = authService; + } + } +} diff --git a/src/app/core/session/session-service/remote-session.spec.ts b/src/app/core/session/session-service/remote-session.spec.ts index 18c34721d3..f263036ef2 100644 --- a/src/app/core/session/session-service/remote-session.spec.ts +++ b/src/app/core/session/session-service/remote-session.spec.ts @@ -1,60 +1,46 @@ import { TestBed } from "@angular/core/testing"; import { RemoteSession } from "./remote-session"; -import { HttpClient, HttpErrorResponse } from "@angular/common/http"; -import { of, throwError } from "rxjs"; +import { HttpErrorResponse, HttpStatusCode } from "@angular/common/http"; import { SessionType } from "../session-type"; import { LoggingService } from "../../logging/logging.service"; import { testSessionServiceImplementation } from "./session.service.spec"; -import { DatabaseUser } from "./local-user"; import { LoginState } from "../session-states/login-state.enum"; import { TEST_PASSWORD, TEST_USER } from "../../../utils/mocked-testing.module"; import { environment } from "../../../../environments/environment"; +import { AuthService } from "../auth/auth.service"; describe("RemoteSessionService", () => { let service: RemoteSession; - let mockHttpClient: jasmine.SpyObj; - let dbUser: DatabaseUser; + let mockAuthService: jasmine.SpyObj; beforeEach(() => { environment.session_type = SessionType.mock; - mockHttpClient = jasmine.createSpyObj(["post", "delete"]); - mockHttpClient.delete.and.returnValue(of()); + mockAuthService = jasmine.createSpyObj(["authenticate", "logout"]); + // Remote session allows TEST_USER and TEST_PASSWORD as valid credentials + mockAuthService.authenticate.and.callFake(async (u, p) => { + if (u === TEST_USER && p === TEST_PASSWORD) { + return { name: TEST_USER, roles: ["user_app"] }; + } else { + throw new HttpErrorResponse({ + status: HttpStatusCode.Unauthorized, + }); + } + }); TestBed.configureTestingModule({ providers: [ RemoteSession, LoggingService, - { provide: HttpClient, useValue: mockHttpClient }, + { provide: AuthService, useValue: mockAuthService }, ], }); - // Remote session allows TEST_USER and TEST_PASSWORD as valid credentials - dbUser = { name: TEST_USER, roles: ["user_app"] }; service = TestBed.inject(RemoteSession); - - mockHttpClient.post.and.callFake((url, body) => { - if (body.name === TEST_USER && body.password === TEST_PASSWORD) { - return of(dbUser as any); - } else { - return throwError( - new HttpErrorResponse({ status: service.UNAUTHORIZED_STATUS_CODE }) - ); - } - }); - }); - - it("should be connected after successful login", async () => { - expect(service.loginState.value).toBe(LoginState.LOGGED_OUT); - - await service.login(TEST_USER, TEST_PASSWORD); - - expect(mockHttpClient.post).toHaveBeenCalled(); - expect(service.loginState.value).toBe(LoginState.LOGGED_IN); }); it("should be unavailable if requests fails with error other than 401", async () => { - mockHttpClient.post.and.returnValue( - throwError(new HttpErrorResponse({ status: 501 })) + mockAuthService.authenticate.and.rejectWith( + new HttpErrorResponse({ status: 501 }) ); await service.login(TEST_USER, TEST_PASSWORD); @@ -62,33 +48,5 @@ describe("RemoteSessionService", () => { expect(service.loginState.value).toBe(LoginState.UNAVAILABLE); }); - it("should be rejected if login is unauthorized", async () => { - await service.login(TEST_USER, "wrongPassword"); - - expect(service.loginState.value).toBe(LoginState.LOGIN_FAILED); - }); - - it("should disconnect after logout", async () => { - await service.login(TEST_USER, TEST_PASSWORD); - - await service.logout(); - - expect(service.loginState.value).toBe(LoginState.LOGGED_OUT); - }); - - it("should assign the current user after successful login", async () => { - await service.login(TEST_USER, TEST_PASSWORD); - - expect(service.getCurrentUser()).toEqual({ - name: dbUser.name, - roles: dbUser.roles, - }); - }); - - it("should not throw error when remote logout is not possible", () => { - mockHttpClient.delete.and.returnValue(throwError(new Error())); - return expectAsync(service.logout()).not.toBeRejected(); - }); - testSessionServiceImplementation(() => Promise.resolve(service)); }); diff --git a/src/app/core/session/session-service/remote-session.ts b/src/app/core/session/session-service/remote-session.ts index bcc199077e..135084a5b1 100644 --- a/src/app/core/session/session-service/remote-session.ts +++ b/src/app/core/session/session-service/remote-session.ts @@ -15,7 +15,7 @@ * along with ndb-core. If not, see . */ import { Injectable } from "@angular/core"; -import { HttpClient, HttpErrorResponse } from "@angular/common/http"; +import { HttpErrorResponse, HttpStatusCode } from "@angular/common/http"; import { DatabaseUser } from "./local-user"; import { SessionService } from "./session.service"; import { LoginState } from "../session-states/login-state.enum"; @@ -23,6 +23,7 @@ import { PouchDatabase } from "../../database/pouch-database"; import { LoggingService } from "../../logging/logging.service"; import PouchDB from "pouchdb-browser"; import { AppSettings } from "app/core/app-config/app-settings"; +import { AuthService } from "../auth/auth.service"; /** * Responsibilities: @@ -33,36 +34,19 @@ import { AppSettings } from "app/core/app-config/app-settings"; @Injectable() export class RemoteSession extends SessionService { static readonly LAST_LOGIN_KEY = "LAST_REMOTE_LOGIN"; - // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401 - readonly UNAUTHORIZED_STATUS_CODE = 401; /** remote (!) PouchDB */ private readonly database: PouchDatabase; private currentDBUser: DatabaseUser; /** - * Create a RemoteSession and set up connection to the remote CouchDB server configured in AppConfig. + * Create a RemoteSession and set up connection to the remote CouchDB server with valid authentication. */ constructor( - private httpClient: HttpClient, - private loggingService: LoggingService + private loggingService: LoggingService, + private authService: AuthService ) { super(); - this.database = new PouchDatabase(this.loggingService).initIndexedDB( - `${AppSettings.DB_PROXY_PREFIX}/${AppSettings.DB_NAME}`, - { - adapter: "http", - skip_setup: true, - fetch: (url, opts) => { - if (typeof url === "string") { - return PouchDB.fetch( - AppSettings.DB_PROXY_PREFIX + - url.split(AppSettings.DB_PROXY_PREFIX)[1], - opts - ); - } - }, - } - ); + this.database = new PouchDatabase(this.loggingService); } /** @@ -72,15 +56,8 @@ export class RemoteSession extends SessionService { */ public async login(username: string, password: string): Promise { try { - const response = await this.httpClient - .post( - `${AppSettings.DB_PROXY_PREFIX}/_session`, - { name: username, password: password }, - { withCredentials: true } - ) - .toPromise(); - await this.handleSuccessfulLogin(response); - this.assignDatabaseUser(response); + const user = await this.authService.authenticate(username, password); + await this.handleSuccessfulLogin(user); localStorage.setItem( RemoteSession.LAST_LOGIN_KEY, new Date().toISOString() @@ -88,7 +65,7 @@ export class RemoteSession extends SessionService { this.loginState.next(LoginState.LOGGED_IN); } catch (error) { const httpError = error as HttpErrorResponse; - if (httpError?.status === this.UNAUTHORIZED_STATUS_CODE) { + if (httpError?.status === HttpStatusCode.Unauthorized) { this.loginState.next(LoginState.LOGIN_FAILED); } else { this.loginState.next(LoginState.UNAVAILABLE); @@ -97,14 +74,24 @@ export class RemoteSession extends SessionService { return this.loginState.value; } - private assignDatabaseUser(couchDBResponse: any) { - this.currentDBUser = { - name: couchDBResponse.name, - roles: couchDBResponse.roles, - }; - } - public async handleSuccessfulLogin(userObject: DatabaseUser) { + this.database.initIndexedDB( + `${AppSettings.DB_PROXY_PREFIX}/${AppSettings.DB_NAME}`, + { + adapter: "http", + skip_setup: true, + fetch: (url, opts: any) => { + if (typeof url === "string") { + this.authService.addAuthHeader(opts.headers); + return PouchDB.fetch( + AppSettings.DB_PROXY_PREFIX + + url.split(AppSettings.DB_PROXY_PREFIX)[1], + opts + ); + } + }, + } + ); this.currentDBUser = userObject; this.loginState.next(LoginState.LOGGED_IN); } @@ -113,12 +100,7 @@ export class RemoteSession extends SessionService { * Logout at the remote database. */ public async logout(): Promise { - await this.httpClient - .delete(`${AppSettings.DB_PROXY_PREFIX}/_session`, { - withCredentials: true, - }) - .toPromise() - .catch(() => undefined); + await this.authService.logout(); this.currentDBUser = undefined; this.loginState.next(LoginState.LOGGED_OUT); } diff --git a/src/app/core/session/session-service/synced-session.service.spec.ts b/src/app/core/session/session-service/synced-session.service.spec.ts index c91611f488..f16e217510 100644 --- a/src/app/core/session/session-service/synced-session.service.spec.ts +++ b/src/app/core/session/session-service/synced-session.service.spec.ts @@ -21,8 +21,7 @@ import { LocalSession } from "./local-session"; import { RemoteSession } from "./remote-session"; import { SessionType } from "../session-type"; import { fakeAsync, flush, TestBed, tick } from "@angular/core/testing"; -import { HttpClient, HttpErrorResponse } from "@angular/common/http"; -import { of, throwError } from "rxjs"; +import { HttpErrorResponse, HttpStatusCode } from "@angular/common/http"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { DatabaseUser } from "./local-user"; import { TEST_PASSWORD, TEST_USER } from "../../../utils/mocked-testing.module"; @@ -32,33 +31,42 @@ import { PouchDatabase } from "../../database/pouch-database"; import { SessionModule } from "../session.module"; import { LOCATION_TOKEN } from "../../../utils/di-tokens"; import { environment } from "../../../../environments/environment"; +import { AuthService } from "../auth/auth.service"; describe("SyncedSessionService", () => { let sessionService: SyncedSessionService; let localSession: LocalSession; let remoteSession: RemoteSession; - let localLoginSpy: jasmine.Spy< - (username: string, password: string) => Promise - >; - let remoteLoginSpy: jasmine.Spy< - (username: string, password: string) => Promise - >; + let localLoginSpy: jasmine.Spy<(username: string, password: string) => Promise>; + let remoteLoginSpy: jasmine.Spy<(username: string, password: string) => Promise>; let dbUser: DatabaseUser; let syncSpy: jasmine.Spy<() => Promise>; let liveSyncSpy: jasmine.Spy<() => void>; - let mockHttpClient: jasmine.SpyObj; + let mockAuthService: jasmine.SpyObj; let mockLocation: jasmine.SpyObj; beforeEach(() => { - mockHttpClient = jasmine.createSpyObj(["post", "delete", "get"]); - mockHttpClient.delete.and.returnValue(of()); - mockHttpClient.get.and.returnValue(of()); mockLocation = jasmine.createSpyObj(["reload"]); + mockAuthService = jasmine.createSpyObj([ + "authenticate", + "autoLogin", + "logout", + ]); + mockAuthService.autoLogin.and.rejectWith(); + mockAuthService.authenticate.and.callFake(async (u, p) => { + if (u === TEST_USER && p === TEST_PASSWORD) { + return dbUser; + } else { + throw new HttpErrorResponse({ + status: HttpStatusCode.Unauthorized, + }); + } + }); TestBed.configureTestingModule({ imports: [SessionModule, NoopAnimationsModule, FontAwesomeTestingModule], providers: [ PouchDatabase, - { provide: HttpClient, useValue: mockHttpClient }, + { provide: AuthService, useValue: mockAuthService }, { provide: LOCATION_TOKEN, useValue: mockLocation }, ], }); @@ -71,17 +79,6 @@ describe("SyncedSessionService", () => { // Setting up local and remote session to accept TEST_USER and TEST_PASSWORD as valid credentials dbUser = { name: TEST_USER, roles: ["user_app"] }; localSession.saveUser({ name: TEST_USER, roles: [] }, TEST_PASSWORD); - mockHttpClient.post.and.callFake((url, body) => { - if (body.name === TEST_USER && body.password === TEST_PASSWORD) { - return of(dbUser as any); - } else { - return throwError( - new HttpErrorResponse({ - status: remoteSession.UNAUTHORIZED_STATUS_CODE, - }) - ); - } - }); localLoginSpy = spyOn(localSession, "login").and.callThrough(); remoteLoginSpy = spyOn(remoteSession, "login").and.callThrough(); @@ -188,6 +185,9 @@ describe("SyncedSessionService", () => { expect(syncSpy).toHaveBeenCalled(); expect(liveSyncSpy).toHaveBeenCalled(); expectAsync(login).toBeResolvedTo(LoginState.LOGGED_IN); + + // clear timeouts and intervals + sessionService.logout(); flush(); })); @@ -249,54 +249,27 @@ describe("SyncedSessionService", () => { tick(); })); - it("should login, given that CouchDB cookie is still valid", fakeAsync(() => { - const responseObject = { - ok: true, - userCtx: { - name: "demo", - roles: ["user_app"], - }, - info: { - authentication_handlers: ["cookie", "default"], - authenticated: "default", - }, - }; - mockHttpClient.get.and.returnValue(of(responseObject)); - sessionService.checkForValidSession(); - tick(); - expect(sessionService.loginState.value).toEqual(LoginState.LOGGED_IN); - })); + it("should login, if the session is still valid", fakeAsync(() => { + mockAuthService.autoLogin.and.resolveTo(dbUser); - it("should not login, given that there is no valid CouchDB cookie", fakeAsync(() => { - const responseObject = { - ok: true, - userCtx: { - name: null, - roles: [], - }, - info: { - authentication_handlers: ["cookie", "default"], - }, - }; - mockHttpClient.get.and.returnValue(of(responseObject)); sessionService.checkForValidSession(); tick(); - expect(sessionService.loginState.value).toEqual(LoginState.LOGGED_OUT); + expect(sessionService.loginState.value).toEqual(LoginState.LOGGED_IN); })); testSessionServiceImplementation(() => Promise.resolve(sessionService)); function passRemoteLogin(response: DatabaseUser = { name: "", roles: [] }) { - mockHttpClient.post.and.returnValue(of(response)); + mockAuthService.authenticate.and.resolveTo(response); } function failRemoteLogin(offline = false) { let rejectError; if (!offline) { rejectError = new HttpErrorResponse({ - status: remoteSession.UNAUTHORIZED_STATUS_CODE, + status: HttpStatusCode.Unauthorized, }); } - mockHttpClient.post.and.returnValue(throwError(rejectError)); + mockAuthService.authenticate.and.rejectWith(rejectError); } }); diff --git a/src/app/core/session/session-service/synced-session.service.ts b/src/app/core/session/session-service/synced-session.service.ts index 68f6de62bf..74c9fb1d51 100644 --- a/src/app/core/session/session-service/synced-session.service.ts +++ b/src/app/core/session/session-service/synced-session.service.ts @@ -29,9 +29,9 @@ import { HttpClient } from "@angular/common/http"; import { DatabaseUser } from "./local-user"; import { waitForChangeTo } from "../session-states/session-utils"; import { zip } from "rxjs"; -import { AppSettings } from "app/core/app-config/app-settings"; import { filter } from "rxjs/operators"; import { LOCATION_TOKEN } from "../../../utils/di-tokens"; +import { AuthService } from "../auth/auth.service"; /** * A synced session creates and manages a LocalSession and a RemoteSession @@ -56,6 +56,7 @@ export class SyncedSessionService extends SessionService { private httpClient: HttpClient, private localSession: LocalSession, private remoteSession: RemoteSession, + private authService: AuthService, @Inject(LOCATION_TOKEN) private location: Location ) { super(); @@ -71,18 +72,13 @@ export class SyncedSessionService extends SessionService { } /** - * Do login automatically if there is still a valid CouchDB cookie from last login with username and password + * Do log in automatically if there is still a valid CouchDB cookie from last login with username and password */ checkForValidSession() { - this.httpClient - .get(`${AppSettings.DB_PROXY_PREFIX}/_session`, { - withCredentials: true, - }) - .subscribe((res: any) => { - if (res.userCtx.name) { - this.handleSuccessfulLogin(res.userCtx); - } - }); + this.authService + .autoLogin() + .then((user) => this.handleSuccessfulLogin(user)) + .catch(() => undefined); } async handleSuccessfulLogin(userObject: DatabaseUser) { @@ -225,10 +221,11 @@ export class SyncedSessionService extends SessionService { this.syncState.next(SyncState.STARTED); const localPouchDB = this.localSession.getDatabase().getPouchDB(); const remotePouchDB = this.remoteSession.getDatabase().getPouchDB(); - this._liveSyncHandle = (localPouchDB.sync(remotePouchDB, { + this._liveSyncHandle = localPouchDB.sync(remotePouchDB, { live: true, retry: true, - }) as any) + }); + this._liveSyncHandle .on("paused", (info) => { // replication was paused: either because sync is finished or because of a failed sync (mostly due to lost connection). info is empty. if (this.remoteSession.loginState.value === LoginState.LOGGED_IN) { @@ -250,7 +247,6 @@ export class SyncedSessionService extends SessionService { // replication was canceled! this._liveSyncHandle = null; }); - return this._liveSyncHandle; } /** diff --git a/src/app/core/session/session.module.ts b/src/app/core/session/session.module.ts index e6197070a7..ec3033e1aa 100644 --- a/src/app/core/session/session.module.ts +++ b/src/app/core/session/session.module.ts @@ -18,10 +18,9 @@ import { Injector, NgModule } from "@angular/core"; import { CommonModule } from "@angular/common"; import { LoginComponent } from "./login/login.component"; -import { FormsModule } from "@angular/forms"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { EntityModule } from "../entity/entity.module"; import { AlertsModule } from "../alerts/alerts.module"; -import { UserModule } from "../user/user.module"; import { MatButtonModule } from "@angular/material/button"; import { MatCardModule } from "@angular/material/card"; import { MatFormFieldModule } from "@angular/material/form-field"; @@ -36,6 +35,13 @@ import { RemoteSession } from "./session-service/remote-session"; import { SessionService } from "./session-service/session.service"; import { SessionType } from "./session-type"; import { environment } from "../../../environments/environment"; +import { AuthService } from "./auth/auth.service"; +import { KeycloakAuthService } from "./auth/keycloak/keycloak-auth.service"; +import { CouchdbAuthService } from "./auth/couchdb/couchdb-auth.service"; +import { AuthProvider } from "./auth/auth-provider"; +import { PasswordFormComponent } from "./auth/couchdb/password-form/password-form.component"; +import { PasswordButtonComponent } from "./auth/keycloak/password-button/password-button.component"; +import { Angulartics2OnModule } from "angulartics2"; /** * The core session logic handling user login as well as connection and synchronization with the remote database. @@ -56,13 +62,18 @@ import { environment } from "../../../environments/environment"; MatInputModule, MatButtonModule, RouterModule, - UserModule, HttpClientModule, MatDialogModule, MatProgressBarModule, + Angulartics2OnModule, + ReactiveFormsModule, ], - declarations: [LoginComponent], - exports: [LoginComponent], + declarations: [ + LoginComponent, + PasswordFormComponent, + PasswordButtonComponent, + ], + exports: [LoginComponent, PasswordButtonComponent, PasswordFormComponent], providers: [ SyncedSessionService, LocalSession, @@ -78,6 +89,19 @@ import { environment } from "../../../environments/environment"; }, deps: [Injector], }, + KeycloakAuthService, + CouchdbAuthService, + { + provide: AuthService, + useFactory: (injector: Injector) => { + if (environment.authenticator === AuthProvider.Keycloak) { + return injector.get(KeycloakAuthService); + } else { + return injector.get(CouchdbAuthService); + } + }, + deps: [Injector], + }, ], }) export class SessionModule {} diff --git a/src/app/core/user/user-account/user-account.component.html b/src/app/core/user/user-account/user-account.component.html index 44a3a26009..969b1f2dd1 100644 --- a/src/app/core/user/user-account/user-account.component.html +++ b/src/app/core/user/user-account/user-account.component.html @@ -32,115 +32,20 @@ />

+
+ -
-

- - - Password change is not allowed in demo mode. - -

-

- - - Password change is not possible while being offline. - - -

- - - - - Please provide your correct current password for confirmation. - - -
- - - - -
- Must be at least 8 characters long. -
-
- Must contain lower case letters, upper case letters, symbols and - numbers to be secure. -
-
-
-
- - - - - Confirmation does not match your new password. - - -
- -
-
- Password changed successfully. -
-
- Failed to change password: {{ passwordChangeResult.error }}
- Please try again. If the problem persists contact Aam Digital - support. -
-
- - -
+ +
diff --git a/src/app/core/user/user-account/user-account.component.spec.ts b/src/app/core/user/user-account/user-account.component.spec.ts index 767a5192e1..58494a0bb2 100644 --- a/src/app/core/user/user-account/user-account.component.spec.ts +++ b/src/app/core/user/user-account/user-account.component.spec.ts @@ -15,59 +15,46 @@ * along with ndb-core. If not, see . */ -import { - ComponentFixture, - fakeAsync, - TestBed, - tick, - waitForAsync, -} from "@angular/core/testing"; +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; import { UserAccountComponent } from "./user-account.component"; import { SessionService } from "../../session/session-service/session.service"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; -import { UserAccountService } from "./user-account.service"; import { UserModule } from "../user.module"; -import { SessionType } from "../../session/session-type"; import { LoggingService } from "../../logging/logging.service"; import { TabStateModule } from "../../../utils/tab-state/tab-state.module"; import { RouterTestingModule } from "@angular/router/testing"; -import { environment } from "../../../../environments/environment"; +import { Angulartics2Module } from "angulartics2"; +import { AuthService } from "../../session/auth/auth.service"; describe("UserAccountComponent", () => { let component: UserAccountComponent; let fixture: ComponentFixture; let mockSessionService: jasmine.SpyObj; - let mockUserAccountService: jasmine.SpyObj; let mockLoggingService: jasmine.SpyObj; beforeEach(waitForAsync(() => { - environment.session_type = SessionType.synced; // password change only available in synced mode mockSessionService = jasmine.createSpyObj("sessionService", [ "getCurrentUser", - "login", - "checkPassword", ]); mockSessionService.getCurrentUser.and.returnValue({ name: "TestUser", roles: [], }); - mockUserAccountService = jasmine.createSpyObj("mockUserAccount", [ - "changePassword", - ]); mockLoggingService = jasmine.createSpyObj(["error"]); TestBed.configureTestingModule({ imports: [ + RouterTestingModule, UserModule, + Angulartics2Module.forRoot(), NoopAnimationsModule, TabStateModule, - RouterTestingModule, ], providers: [ { provide: SessionService, useValue: mockSessionService }, - { provide: UserAccountService, useValue: mockUserAccountService }, + { provide: AuthService, useValue: { changePassword: () => undefined } }, { provide: LoggingService, useValue: mockLoggingService }, ], }); @@ -82,56 +69,4 @@ describe("UserAccountComponent", () => { it("should be created", () => { expect(component).toBeTruthy(); }); - - it("should enable password form", () => { - expect(component.passwordForm).toBeEnabled(); - }); - - it("should set error when password is incorrect", () => { - component.passwordForm.get("currentPassword").setValue("wrongPW"); - mockSessionService.checkPassword.and.returnValue(false); - - expect(component.passwordForm.get("currentPassword")).toBeValidForm(); - - component.changePassword(); - - expect(component.passwordForm.get("currentPassword")).not.toBeValidForm(); - }); - - it("should set error when password change fails", fakeAsync(() => { - component.username = "testUser"; - component.passwordForm.get("currentPassword").setValue("testPW"); - mockSessionService.checkPassword.and.returnValue(true); - mockUserAccountService.changePassword.and.rejectWith( - new Error("pw change error") - ); - - try { - component.changePassword(); - tick(); - } catch (e) { - // expected to re-throw the error for upstream reporting - } - - expect(mockUserAccountService.changePassword).toHaveBeenCalled(); - expect(component.passwordChangeResult.success).toBeFalse(); - expect(component.passwordChangeResult.error).toBe("pw change error"); - })); - - it("should set success and re-login when password change worked", fakeAsync(() => { - component.username = "testUser"; - component.passwordForm.get("currentPassword").setValue("testPW"); - component.passwordForm.get("newPassword").setValue("changedPassword"); - mockSessionService.checkPassword.and.returnValue(true); - mockUserAccountService.changePassword.and.resolveTo(); - mockSessionService.login.and.resolveTo(null); - - component.changePassword(); - tick(); - expect(component.passwordChangeResult.success).toBeTrue(); - expect(mockSessionService.login).toHaveBeenCalledWith( - "testUser", - "changedPassword" - ); - })); }); diff --git a/src/app/core/user/user-account/user-account.component.ts b/src/app/core/user/user-account/user-account.component.ts index a088adea07..90fdbe4166 100644 --- a/src/app/core/user/user-account/user-account.component.ts +++ b/src/app/core/user/user-account/user-account.component.ts @@ -17,11 +17,9 @@ import { Component, OnInit } from "@angular/core"; import { SessionService } from "../../session/session-service/session.service"; -import { UserAccountService } from "./user-account.service"; -import { FormBuilder, ValidationErrors, Validators } from "@angular/forms"; -import { LoggingService } from "../../logging/logging.service"; -import { SessionType } from "../../session/session-type"; import { environment } from "../../../../environments/environment"; +import { SessionType } from "../../session/session-type"; +import { AuthService } from "../../session/auth/auth.service"; /** * User account form to allow the user to view and edit information. @@ -35,36 +33,12 @@ export class UserAccountComponent implements OnInit { /** user to be edited */ username: string; - /** whether password change is disallowed because of demo mode */ - disabledForDemoMode: boolean; - disabledForOfflineMode: boolean; - - passwordChangeResult: { success: boolean; error?: any }; - - passwordForm = this.fb.group( - { - currentPassword: ["", Validators.required], - newPassword: [ - "", - [ - Validators.required, - Validators.minLength(8), - Validators.pattern(/[A-Z]/), - Validators.pattern(/[a-z]/), - Validators.pattern(/[0-9]/), - Validators.pattern(/[^A-Za-z0-9]/), - ], - ], - confirmPassword: ["", [Validators.required]], - }, - { validators: () => this.passwordMatchValidator() } - ); + passwordChangeDisabled = false; + tooltipText; constructor( private sessionService: SessionService, - private userAccountService: UserAccountService, - private fb: FormBuilder, - private loggingService: LoggingService + public authService: AuthService ) {} ngOnInit() { @@ -73,56 +47,14 @@ export class UserAccountComponent implements OnInit { } checkIfPasswordChangeAllowed() { - this.disabledForDemoMode = false; - this.disabledForOfflineMode = false; - this.passwordForm.enable(); + this.passwordChangeDisabled = false; + this.tooltipText = ""; if (environment.session_type !== SessionType.synced) { - this.disabledForDemoMode = true; - this.passwordForm.disable(); + this.passwordChangeDisabled = true; + this.tooltipText = $localize`:Password reset disabled tooltip:Password change is not allowed in demo mode.`; } else if (!navigator.onLine) { - this.disabledForOfflineMode = true; - this.passwordForm.disable(); - } - } - - changePassword() { - this.passwordChangeResult = undefined; - - const currentPassword = this.passwordForm.get("currentPassword").value; - - if (!this.sessionService.checkPassword(this.username, currentPassword)) { - this.passwordForm - .get("currentPassword") - .setErrors({ incorrectPassword: true }); - return; - } - - const newPassword = this.passwordForm.get("newPassword").value; - this.userAccountService - .changePassword(this.username, currentPassword, newPassword) - .then(() => this.sessionService.login(this.username, newPassword)) - .then(() => (this.passwordChangeResult = { success: true })) - .catch((err: Error) => { - this.passwordChangeResult = { success: false, error: err.message }; - this.loggingService.error({ - error: "password change failed", - details: err.message, - }); - // rethrow to properly report to sentry.io; this exception is not expected, only caught to display in UI - throw err; - }); - } - - private passwordMatchValidator(): ValidationErrors | null { - const newPassword = this.passwordForm?.get("newPassword").value; - const confirmPassword = this.passwordForm?.get("confirmPassword").value; - if (newPassword !== confirmPassword) { - this.passwordForm - .get("confirmPassword") - .setErrors({ passwordConfirmationMismatch: true }); - return { passwordConfirmationMismatch: true }; + this.tooltipText = $localize`:Password reset disabled tooltip:Password change is not possible while being offline.`; } - return null; } } diff --git a/src/app/core/user/user-account/user-account.service.spec.ts b/src/app/core/user/user-account/user-account.service.spec.ts deleted file mode 100644 index 52c17c35fc..0000000000 --- a/src/app/core/user/user-account/user-account.service.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { TestBed } from "@angular/core/testing"; -import { UserAccountService } from "./user-account.service"; -import { HttpClient } from "@angular/common/http"; -import { of, throwError } from "rxjs"; - -describe("UserAccountService", () => { - let service: UserAccountService; - let mockHttpClient: jasmine.SpyObj; - - beforeEach(() => { - mockHttpClient = jasmine.createSpyObj("mockHttpClient", ["get", "put"]); - TestBed.configureTestingModule({ - providers: [{ provide: HttpClient, useValue: mockHttpClient }], - }); - service = TestBed.inject(UserAccountService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); - - it("should reject if current user cant be fetched", (done) => { - mockHttpClient.get.and.returnValue(throwError(new Error())); - - service - .changePassword("username", "wrongPW", "") - .then(() => fail()) - .catch((err) => { - expect(err).toBeDefined(); - done(); - }); - }); - - it("should report error when new Password cannot be saved", (done) => { - mockHttpClient.get.and.returnValues(of({})); - mockHttpClient.put.and.returnValue(throwError(new Error())); - - service - .changePassword("username", "testPW", "") - .then(() => fail()) - .catch((err) => { - expect(mockHttpClient.get).toHaveBeenCalled(); - expect(mockHttpClient.put).toHaveBeenCalled(); - expect(err).toBeDefined(); - done(); - }); - }); - - it("should not fail if get and put requests are successful", () => { - mockHttpClient.get.and.returnValues(of({})); - mockHttpClient.put.and.returnValues(of({})); - - return expectAsync( - service.changePassword("username", "testPW", "newPW") - ).not.toBeRejected(); - }); -}); diff --git a/src/app/core/user/user-account/user-account.service.ts b/src/app/core/user/user-account/user-account.service.ts deleted file mode 100644 index 4a40920867..0000000000 --- a/src/app/core/user/user-account/user-account.service.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Injectable } from "@angular/core"; -import { HttpClient, HttpHeaders } from "@angular/common/http"; - -@Injectable({ - providedIn: "root", -}) -export class UserAccountService { - private static readonly COUCHDB_USER_ENDPOINT = "/db/_users/org.couchdb.user"; - constructor(private http: HttpClient) {} - - /** - * Function to change the password of a user - * @param username The username for which the password should be changed - * @param oldPassword The current plaintext password of the user - * @param newPassword The new plaintext password of the user - * @return Promise that resolves once the password is changed in _user and the database - */ - public async changePassword( - username: string, - oldPassword: string, - newPassword: string - ): Promise { - let userResponse; - try { - // TODO due to cookie-auth, the old password is actually not checked - userResponse = await this.getCouchDBUser(username, oldPassword); - } catch (e) { - throw new Error("Current password incorrect or server not available"); - } - - userResponse["password"] = newPassword; - try { - await this.saveNewPasswordToCouchDB(username, oldPassword, userResponse); - } catch (e) { - throw new Error( - "Could not save new password, please contact your system administrator" - ); - } - } - - private getCouchDBUser(username: string, password: string): Promise { - const userUrl = UserAccountService.COUCHDB_USER_ENDPOINT + ":" + username; - const headers: HttpHeaders = new HttpHeaders({ - Authorization: "Basic " + btoa(username + ":" + password), - }); - return this.http.get(userUrl, { headers: headers }).toPromise(); - } - - private saveNewPasswordToCouchDB( - username: string, - oldPassword: string, - userObj: any - ): Promise { - const userUrl = UserAccountService.COUCHDB_USER_ENDPOINT + ":" + username; - const headers: HttpHeaders = new HttpHeaders({ - Authorization: "Basic " + btoa(username + ":" + oldPassword), - }); - return this.http.put(userUrl, userObj, { headers: headers }).toPromise(); - } -} diff --git a/src/app/core/user/user.module.ts b/src/app/core/user/user.module.ts index 8b6c6890e7..58cb619671 100644 --- a/src/app/core/user/user.module.ts +++ b/src/app/core/user/user.module.ts @@ -22,11 +22,12 @@ import { MatFormFieldModule } from "@angular/material/form-field"; import { MatInputModule } from "@angular/material/input"; import { CommonModule } from "@angular/common"; import { MatTabsModule } from "@angular/material/tabs"; -import { FormsModule, ReactiveFormsModule } from "@angular/forms"; -import { MatListModule } from "@angular/material/list"; -import { MatAutocompleteModule } from "@angular/material/autocomplete"; -import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { TabStateModule } from "../../utils/tab-state/tab-state.module"; +import { MatTooltipModule } from "@angular/material/tooltip"; +import { Angulartics2Module } from "angulartics2"; +import { ReactiveFormsModule } from "@angular/forms"; +import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +import { SessionModule } from "../session/session.module"; /** * Provides a User functionality including user account forms. @@ -38,12 +39,12 @@ import { TabStateModule } from "../../utils/tab-state/tab-state.module"; MatInputModule, MatButtonModule, MatTabsModule, + TabStateModule, + MatTooltipModule, + Angulartics2Module, ReactiveFormsModule, - MatListModule, - MatAutocompleteModule, - FormsModule, FontAwesomeModule, - TabStateModule, + SessionModule, ], declarations: [UserAccountComponent], }) diff --git a/src/app/utils/utils.ts b/src/app/utils/utils.ts index 999ce3fcca..310d77290a 100644 --- a/src/app/utils/utils.ts +++ b/src/app/utils/utils.ts @@ -97,3 +97,27 @@ export function compareEnums( ): boolean { return a?.id === b?.id; } + +/** + * Parses and returns the payload of a JWT into a JSON object. + * For me info see {@link https://jwt.io}. + * @param token a valid JWT + */ +export function parseJwt(token): { + sub: string; + username: string; + sid: string; + jti: string; +} { + const base64Url = token.split(".")[1]; + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const jsonPayload = decodeURIComponent( + window + .atob(base64) + .split("") + .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) + .join("") + ); + + return JSON.parse(jsonPayload); +} diff --git a/src/assets/keycloak.json b/src/assets/keycloak.json new file mode 100644 index 0000000000..9a463971c8 --- /dev/null +++ b/src/assets/keycloak.json @@ -0,0 +1,10 @@ +{ + "realm": "ndb-dev", + "auth-server-url": "https://keycloak.aam-digital.com/", + "ssl-required": "external", + "resource": "app", + "public-client": true, + "verify-token-audience": true, + "use-resource-role-mappings": true, + "confidential-port": 0 +} diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index c6992a02f7..15e0070287 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -16,6 +16,7 @@ */ import { SessionType } from "../app/core/session/session-type"; +import { AuthProvider } from "../app/core/session/auth/auth-provider"; /** * Central environment that allows to configure differences between a "dev" and a "prod" build. @@ -34,4 +35,5 @@ export const environment = { /** The following settings can be overridden by the `config.json` if present, see {@link AppSettings} */ demo_mode: true, session_type: SessionType.mock, + authenticator: AuthProvider.CouchDB, }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index dc88332220..820ef1ff38 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -16,6 +16,7 @@ */ import { SessionType } from "../app/core/session/session-type"; +import { AuthProvider } from "../app/core/session/auth/auth-provider"; /** * Central environment that allows to configure differences between a "dev" and a "prod" build. @@ -33,4 +34,5 @@ export const environment = { /** The following settings can be overridden by the `config.json` if present, see {@link AppSettings} */ demo_mode: true, session_type: SessionType.mock, + authenticator: AuthProvider.CouchDB, };