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 @@
+
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 @@
/>
+
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,
};