From 36edc6707cb49a71c8504d700a938f98fee31df4 Mon Sep 17 00:00:00 2001 From: Bastian Rihm Date: Fri, 5 Jun 2026 10:36:04 +0200 Subject: [PATCH 1/3] Keycloak authentication via x-user-id header --- .../auth-token.interceptor.spec.ts | 16 ------ .../interceptors/auth-token.interceptor.ts | 42 ---------------- .../interceptors/index.ts | 4 +- .../interceptors/user-header.interceptor.ts | 23 +++++++++ .../account-dialog.component.html | 22 ++------- .../account-dialog.component.ts | 49 ++++++------------- .../site/pages/login/login-routing.module.ts | 6 ++- .../site/services/auth-token.service.spec.ts | 8 +-- .../app/site/services/auth-token.service.ts | 24 +++++++++ client/src/app/site/services/auth.service.ts | 44 +++++++---------- .../src/app/site/services/operator.service.ts | 12 ++--- client/src/app/site/services/user.service.ts | 3 -- 12 files changed, 103 insertions(+), 150 deletions(-) delete mode 100644 client/src/app/openslides-main-module/interceptors/auth-token.interceptor.spec.ts delete mode 100644 client/src/app/openslides-main-module/interceptors/auth-token.interceptor.ts create mode 100644 client/src/app/openslides-main-module/interceptors/user-header.interceptor.ts diff --git a/client/src/app/openslides-main-module/interceptors/auth-token.interceptor.spec.ts b/client/src/app/openslides-main-module/interceptors/auth-token.interceptor.spec.ts deleted file mode 100644 index 085050987d..0000000000 --- a/client/src/app/openslides-main-module/interceptors/auth-token.interceptor.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { AuthTokenInterceptor } from './auth-token.interceptor'; - -xdescribe(`AuthTokenInterceptor`, () => { - beforeEach(() => - TestBed.configureTestingModule({ - providers: [AuthTokenInterceptor] - }) - ); - - it(`should be created`, () => { - const interceptor: AuthTokenInterceptor = TestBed.inject(AuthTokenInterceptor); - expect(interceptor).toBeTruthy(); - }); -}); diff --git a/client/src/app/openslides-main-module/interceptors/auth-token.interceptor.ts b/client/src/app/openslides-main-module/interceptors/auth-token.interceptor.ts deleted file mode 100644 index 74f80c99e7..0000000000 --- a/client/src/app/openslides-main-module/interceptors/auth-token.interceptor.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - HttpErrorResponse, - HttpEvent, - HttpHandler, - HttpInterceptor, - HttpRequest, - HttpResponse -} from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Observable, tap } from 'rxjs'; - -import { AuthTokenService } from '../../site/services/auth-token.service'; - -@Injectable() -export class AuthTokenInterceptor implements HttpInterceptor { - public constructor(private authTokenService: AuthTokenService) {} - - public intercept(request: HttpRequest, next: HttpHandler): Observable> { - if (this.authTokenService.rawAccessToken) { - request = request.clone({ - setHeaders: { - authentication: this.authTokenService.rawAccessToken - } - }); - } - return next.handle(request).pipe( - tap({ - next: httpEvent => { - if (httpEvent instanceof HttpResponse && httpEvent.headers.get(`authentication`)) { - // Successful request - this.authTokenService.setRawAccessToken(httpEvent.headers.get(`Authentication`)); - } - }, - error: (error: unknown) => { - if (error instanceof HttpErrorResponse) { - // Here you can cache failed responses and try again - } - } - }) - ); - } -} diff --git a/client/src/app/openslides-main-module/interceptors/index.ts b/client/src/app/openslides-main-module/interceptors/index.ts index 9670d14dd9..751a729f71 100644 --- a/client/src/app/openslides-main-module/interceptors/index.ts +++ b/client/src/app/openslides-main-module/interceptors/index.ts @@ -2,12 +2,12 @@ import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { Provider } from '@angular/core'; import { AuthTokenService } from '../../site/services/auth-token.service'; -import { AuthTokenInterceptor } from './auth-token.interceptor'; +import { UserHeaderInterceptor } from './user-header.interceptor'; export const httpInterceptorProviders: Provider[] = [ { provide: HTTP_INTERCEPTORS, - useClass: AuthTokenInterceptor, + useClass: UserHeaderInterceptor, deps: [AuthTokenService], multi: true } diff --git a/client/src/app/openslides-main-module/interceptors/user-header.interceptor.ts b/client/src/app/openslides-main-module/interceptors/user-header.interceptor.ts new file mode 100644 index 0000000000..feff418a25 --- /dev/null +++ b/client/src/app/openslides-main-module/interceptors/user-header.interceptor.ts @@ -0,0 +1,23 @@ +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, tap } from 'rxjs'; + +import { AuthTokenService } from '../../site/services/auth-token.service'; + +@Injectable() +export class UserHeaderInterceptor implements HttpInterceptor { + public constructor(private authTokenService: AuthTokenService) {} + + public intercept(request: HttpRequest, next: HttpHandler): Observable> { + return next.handle(request).pipe( + tap({ + next: httpEvent => { + if (httpEvent instanceof HttpResponse && httpEvent.headers.get(`x-user-id`) !== null) { + this.authTokenService.setUserId(+httpEvent.headers.get(`x-user-id`)); + } + }, + error: (_error: unknown) => {} + }) + ); + } +} diff --git a/client/src/app/site/modules/global-headbar/components/account-dialog/account-dialog.component.html b/client/src/app/site/modules/global-headbar/components/account-dialog/account-dialog.component.html index 64b3d5bab1..5804ac9b66 100644 --- a/client/src/app/site/modules/global-headbar/components/account-dialog/account-dialog.component.html +++ b/client/src/app/site/modules/global-headbar/components/account-dialog/account-dialog.component.html @@ -14,6 +14,11 @@

} + + + {{ "Change Password" | translate }} + + @@ -26,9 +31,6 @@

@case (menuItemsRef.SHOW_MEETINGS) { } - @case (menuItemsRef.CHANGE_PASSWORD) { - - } } @@ -79,17 +81,3 @@

{{ 'My meetings' | translate }}

- - -

{{ 'Change password' | translate }}

- -
- -
-
diff --git a/client/src/app/site/modules/global-headbar/components/account-dialog/account-dialog.component.ts b/client/src/app/site/modules/global-headbar/components/account-dialog/account-dialog.component.ts index 0a9021c897..5d0d67c33b 100644 --- a/client/src/app/site/modules/global-headbar/components/account-dialog/account-dialog.component.ts +++ b/client/src/app/site/modules/global-headbar/components/account-dialog/account-dialog.component.ts @@ -1,14 +1,11 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { TranslateService } from '@ngx-translate/core'; import { Permission } from 'src/app/domain/definitions/permission'; import { PasswordForm, PasswordFormComponent } from 'src/app/site/modules/user-components'; import { ViewGroup } from 'src/app/site/pages/meetings/pages/participants'; import { MeetingControllerService } from 'src/app/site/pages/meetings/services/meeting-controller.service'; import { ViewMeeting } from 'src/app/site/pages/meetings/view-models/view-meeting'; import { PERSONAL_FORM_CONTROLS, ViewUser } from 'src/app/site/pages/meetings/view-models/view-user'; -import { AuthService } from 'src/app/site/services/auth.service'; import { OperatorService } from 'src/app/site/services/operator.service'; import { UserService } from 'src/app/site/services/user.service'; import { UserControllerService } from 'src/app/site/services/user-controller.service'; @@ -40,9 +37,6 @@ export class AccountDialogComponent extends BaseUiComponent implements OnInit { }, { name: MenuItems.SHOW_MEETINGS - }, - { - name: MenuItems.CHANGE_PASSWORD } ]; @@ -86,6 +80,21 @@ export class AccountDialogComponent extends BaseUiComponent implements OnInit { return this._isEditing; } + public get keycloakPasswordResetLink(): string { + const instance_url = 'http://localhost:8080'; + const openslides_realm = 'openslides'; + const redirect_uri = 'https://localhost:8000/'; + + return ( + instance_url + + '/realms/' + + openslides_realm + + '/protocol/openid-connect/auth?client_id=proxy-client&redirect_uri=' + + redirect_uri + + '&response_type=code&scope=openid&kc_action=UPDATE_PASSWORD' + ); + } + public isUserFormValid = false; public isUserPasswordValid = false; public userPersonalForm: any; @@ -100,10 +109,7 @@ export class AccountDialogComponent extends BaseUiComponent implements OnInit { private operator: OperatorService, private repo: UserControllerService, private meetingRepo: MeetingControllerService, - private userService: UserService, - private snackbar: MatSnackBar, - private authService: AuthService, - private translate: TranslateService + private userService: UserService ) { super(); } @@ -124,8 +130,6 @@ export class AccountDialogComponent extends BaseUiComponent implements OnInit { if (event.key === `Enter` && event.shiftKey) { if (this.activeMenuItem === MenuItems.SHOW_PROFILE && this.isEditing && this.isUserFormValid) { this.saveUserChanges(); - } else if (this.activeMenuItem === MenuItems.CHANGE_PASSWORD && this.isUserPasswordValid) { - this.changePassword(); } } } @@ -151,27 +155,6 @@ export class AccountDialogComponent extends BaseUiComponent implements OnInit { return this.self!.groups(meeting.id); } - public async changePassword(): Promise { - const { oldPassword, newPassword }: PasswordForm = this.userPasswordForm; - - this.authService - .invalidateSessionAfter(() => this.repo.setPasswordSelf(this.self!, oldPassword, newPassword)) - .then(() => { - this.snackbar.open(this.translate.instant(`Password changed successfully!`), `Ok`); - this.changePasswordComponent.reset(); - this.dialogRef.close(); - }) - .catch(e => { - if (e?.message) { - this.snackbar.open(this.translate.instant(e.message), this.translate.instant(`OK`), { - duration: 0 - }); - } - - console.log(e); - }); - } - public async saveUserChanges(): Promise { if (this.self) { const payload = this.userPersonalForm; diff --git a/client/src/app/site/pages/login/login-routing.module.ts b/client/src/app/site/pages/login/login-routing.module.ts index 0f4acea979..8cdffcc985 100644 --- a/client/src/app/site/pages/login/login-routing.module.ts +++ b/client/src/app/site/pages/login/login-routing.module.ts @@ -10,7 +10,11 @@ const routes: Routes = [ children: [ { path: ``, - loadChildren: () => import(`./pages/login-mask/login-mask.module`).then(m => m.LoginMaskModule) + pathMatch: 'full', + redirectTo: (): null => { + window.location.href = '/system/login'; + return null; + } }, { path: `legalnotice`, diff --git a/client/src/app/site/services/auth-token.service.spec.ts b/client/src/app/site/services/auth-token.service.spec.ts index ba61822f3c..e9a9955907 100644 --- a/client/src/app/site/services/auth-token.service.spec.ts +++ b/client/src/app/site/services/auth-token.service.spec.ts @@ -11,13 +11,13 @@ describe(`AuthTokenService`, () => { service = TestBed.inject(AuthTokenService); }); - it(`should be created with empty token`, () => { + xit(`should be created with empty token`, () => { expect(service).toBeTruthy(); expect(service.rawAccessToken).toBe(null); expect(service.accessToken).toBe(null); }); - it(`check observable`, () => { + xit(`check observable`, () => { const token: AuthToken = { expiresAt: new Date(), userId: 1, sessionId: `test`, iat: 5, exp: 12 }; let test_token: AuthToken | null; const raw_token = `UNKNOWN.` + btoa(JSON.stringify(token)); @@ -26,7 +26,7 @@ describe(`AuthTokenService`, () => { expect(JSON.stringify(test_token)).toEqual(JSON.stringify(token)); }); - it(`set accessToken with setRawAccessToken`, () => { + xit(`set accessToken with setRawAccessToken`, () => { const token = { expiresAt: new Date(), userId: 1, sessionId: `test`, iat: 5, exp: 12 }; const raw_token = `UNKNOWN.` + btoa(JSON.stringify(token)); service.setRawAccessToken(raw_token); @@ -34,7 +34,7 @@ describe(`AuthTokenService`, () => { expect(JSON.stringify(service.accessToken)).toEqual(JSON.stringify(token)); }); - it(`set empty access token with setRawAccessToken`, () => { + xit(`set empty access token with setRawAccessToken`, () => { const token = { expiresAt: new Date(), userId: 1, sessionId: `test`, iat: 5, exp: 12 }; const raw_token = `UNKNOWN.` + btoa(JSON.stringify(token)); service.setRawAccessToken(raw_token); diff --git a/client/src/app/site/services/auth-token.service.ts b/client/src/app/site/services/auth-token.service.ts index b2385b40c6..a765ce605d 100644 --- a/client/src/app/site/services/auth-token.service.ts +++ b/client/src/app/site/services/auth-token.service.ts @@ -7,28 +7,52 @@ import { AuthToken } from '../../domain/interfaces/auth-token'; providedIn: `root` }) export class AuthTokenService { + /* @deprecated */ public get rawAccessToken(): string | null { return this._rawAccessToken; } + /* @deprecated */ public get accessTokenObservable(): Observable { return this._accessTokenSubject; } + /* @deprecated */ public get accessToken(): AuthToken | null { return this._accessTokenSubject.getValue(); } + public get userIdObservable(): Observable { + return this._userIdSubject; + } + + public get userId(): number | null { + return this._userIdSubject.getValue(); + } + // Use undefined as the state of not being initialized private _accessTokenSubject = new BehaviorSubject(null); private _rawAccessToken: string | null = null; + private _userIdSubject = new BehaviorSubject(null); + + /* @deprecated */ public setRawAccessToken(rawToken: string | null): void { this._rawAccessToken = rawToken; const token = this.parseToken(rawToken); this._accessTokenSubject.next(token); } + public setUserId(id: number): void { + if (!id) { + id = null; + } + + if (id !== this.userId) { + this._userIdSubject.next(id); + } + } + private parseToken(rawToken: string | null): AuthToken | null { if (!rawToken) { return null; diff --git a/client/src/app/site/services/auth.service.ts b/client/src/app/site/services/auth.service.ts index 89156d3fc4..4b14f96e74 100644 --- a/client/src/app/site/services/auth.service.ts +++ b/client/src/app/site/services/auth.service.ts @@ -23,6 +23,14 @@ export class AuthService { return this._authTokenSubject.getValue(); } + public get userIdObservable(): Observable { + return this._userIdSubject; + } + + public get userId(): number | null { + return this._userIdSubject.getValue(); + } + /** * "Pings" every time when a user logs out. */ @@ -44,6 +52,8 @@ export class AuthService { private readonly _logoutEvent = new EventEmitter(); private readonly _loginEvent = new EventEmitter(); + private readonly _userIdSubject = new BehaviorSubject(null); + public constructor( private lifecycleService: LifecycleService, private router: Router, @@ -53,19 +63,16 @@ export class AuthService { private cookie: CookieService, private DS: DataStoreService ) { - this.authTokenService.accessTokenObservable.subscribe(token => { - this._authTokenSubject.next(token); + this.authTokenService.userIdObservable.subscribe(userId => { + this._userIdSubject.next(userId); }); this.sharedWorker.listenTo(`auth`).subscribe(msg => { switch (msg?.action) { case `new-user`: - this.authTokenService.setRawAccessToken(msg.content?.token); + this.authTokenService.setUserId(msg.content?.user); this.updateUser(msg.content?.user); break; - case `new-token`: - this.authTokenService.setRawAccessToken(msg.content?.token); - break; } }); } @@ -109,7 +116,7 @@ export class AuthService { this.router.navigate([`/`]); } else { this.lifecycleService.shutdown(); - this.authTokenService.setRawAccessToken(null); + this.authTokenService.setUserId(null); this._logoutEvent.emit(); await this.DS.clear(); this.lifecycleService.bootup(); @@ -121,7 +128,7 @@ export class AuthService { const response = await callback(); if (response?.success) { this.lifecycleService.shutdown(); - this.authTokenService.setRawAccessToken(null); + this.authTokenService.setUserId(null); this._logoutEvent.emit(); this.sharedWorker.sendMessage(`auth`, { action: `update` }); this.DS.deleteCollections(...this.DS.getCollections()); @@ -135,22 +142,7 @@ export class AuthService { } public async logout(): Promise { - this.lifecycleService.shutdown(); - const response = await this.authAdapter.logout(); - if (response?.success) { - this.authTokenService.setRawAccessToken(null); - } - this._logoutEvent.emit(); - this.sharedWorker.sendMessage(`auth`, { action: `update` }); - this.DS.deleteCollections(...this.DS.getCollections()); - await this.DS.clear(); - this.lifecycleService.bootup(); - // In case SAML is enabled, we need to redirect the user to the IDP - // to complete the logout-flow. Maybe there is a better way to check - // for activated SAML than checking if the response is a URL. - if (response?.message && URL.parse(response.message)) { - location.replace(response.message); - } + location.replace(`/system/logout`); } public async logoutAnonymous(): Promise { @@ -158,7 +150,7 @@ export class AuthService { } public isAuthenticated(): boolean { - return !!this.authTokenService.accessToken || this.cookie.check(`anonymous-auth`); + return !!this.authTokenService.userId || this.cookie.check(`anonymous-auth`); } /** @@ -179,7 +171,7 @@ export class AuthService { online = false; } } - console.log(`auth: WhoAmI done, online:`, online, `authenticated:`, !!this.authTokenService.accessToken); + console.log(`auth: WhoAmI done, online:`, online, `authenticated:`, !!this.authTokenService.userId); return online; } } diff --git a/client/src/app/site/services/operator.service.ts b/client/src/app/site/services/operator.service.ts index 755a84d527..eeace17619 100644 --- a/client/src/app/site/services/operator.service.ts +++ b/client/src/app/site/services/operator.service.ts @@ -44,7 +44,7 @@ const UNKOWN_USER_ID = -1; // this is an invalid id **and** not equal to 0, null }) export class OperatorService { public get operatorId(): number | null { - return this.isAnonymous || !this.authService.authToken ? null : this.authService.authToken.userId; + return this.isAnonymous || !this.authService.userId ? null : this.authService.userId; } public get isAnonymousLoggedIn(): boolean { @@ -52,7 +52,7 @@ export class OperatorService { } public get isAnonymous(): boolean { - return !this.authService.authToken; + return !this.authService.userId; } public get isAuthenticated(): boolean { @@ -282,18 +282,18 @@ export class OperatorService { ) { this.setNotReady(); // General environment in which the operator moves - this.authService.authTokenObservable.subscribe(token => { - if (token === undefined) { + this.authService.userIdObservable.subscribe(id => { + if (id === undefined) { return; } - const id = token ? token.userId : null; + if (id !== this._lastUserId) { console.debug(`operator: user changed from `, this._lastUserId, `to`, id); this._lastUserId = id; this.resetOperatorData(); this.operatorStateChange(true); } - if (token) { + if (id) { this.checkReadyState(); } }); diff --git a/client/src/app/site/services/user.service.ts b/client/src/app/site/services/user.service.ts index e61e612574..9a6249df8c 100644 --- a/client/src/app/site/services/user.service.ts +++ b/client/src/app/site/services/user.service.ts @@ -6,8 +6,6 @@ import { GetUserEditablePresenterService, GetUserScopePresenterService } from 's import { ActiveMeetingService } from 'src/app/site/pages/meetings/services/active-meeting.service'; import { OperatorService } from 'src/app/site/services/operator.service'; -import { MeetingControllerService } from '../pages/meetings/services/meeting-controller.service'; - export enum UserScope { MEETING = `meeting`, COMMITTEE = `committee`, @@ -22,7 +20,6 @@ export class UserService { private activeMeetingService: ActiveMeetingService, private presenter: GetUserScopePresenterService, private operator: OperatorService, - private meetingRepo: MeetingControllerService, private getUserEditablePresenter: GetUserEditablePresenterService ) {} From ed535e9c9ab1366885bf0261fd63348a7f72a479 Mon Sep 17 00:00:00 2001 From: Bastian Rihm Date: Fri, 5 Jun 2026 12:07:02 +0200 Subject: [PATCH 2/3] Fix reset link --- .../components/account-dialog/account-dialog.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/app/site/modules/global-headbar/components/account-dialog/account-dialog.component.html b/client/src/app/site/modules/global-headbar/components/account-dialog/account-dialog.component.html index 5804ac9b66..62908a1411 100644 --- a/client/src/app/site/modules/global-headbar/components/account-dialog/account-dialog.component.html +++ b/client/src/app/site/modules/global-headbar/components/account-dialog/account-dialog.component.html @@ -15,7 +15,7 @@

} - + {{ "Change Password" | translate }} From 8e17cce477312b21826ede3816abc35a7dbd827a Mon Sep 17 00:00:00 2001 From: Jan Malte Behrens Date: Thu, 11 Jun 2026 15:38:02 +0200 Subject: [PATCH 3/3] feat: Adopt new meta --- meta | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meta b/meta index d3b883cb27..657f040586 160000 --- a/meta +++ b/meta @@ -1 +1 @@ -Subproject commit d3b883cb2777153dc9a99fc325d62b777be0f220 +Subproject commit 657f040586c764da674fce05bff58e13b93ad3a3