From c9d215d9cfaa6930d906bd79bbbdbb3b2991eeb3 Mon Sep 17 00:00:00 2001 From: till_schuetze Date: Wed, 14 Aug 2024 13:14:22 +0200 Subject: [PATCH 1/3] add viewer mode by bypassing the token validation --- apps/client-asset-sg/src/app/app-guards.ts | 6 ++- apps/client-asset-sg/src/app/app.component.ts | 26 +++++++----- apps/client-asset-sg/src/app/app.module.ts | 3 +- .../menu-bar/menu-bar.component.html | 2 + .../components/menu-bar/menu-bar.component.ts | 3 +- .../src/app/state/app-shared.reducer.ts | 8 ++++ apps/server-asset-sg/.env | 1 + apps/server-asset-sg/src/app.controller.ts | 1 + .../core/decorators/current-user.decorator.ts | 2 +- .../src/core/middleware/jwt.middleware.ts | 40 ++++++++++++++++++- .../src/features/users/users.controller.ts | 3 +- .../src/features/users/users.http | 11 +++-- .../workgroups/workgroup-simple.repo.ts | 29 +++++++++----- .../auth/src/lib/services/auth.interceptor.ts | 16 ++++++-- .../src/lib/state/app-shared-state.actions.ts | 3 ++ .../lib/state/app-shared-state.selectors.ts | 2 + .../src/lib/state/app-shared-state.ts | 1 + package-lock.json | 38 +++++++++++++++--- 18 files changed, 153 insertions(+), 42 deletions(-) diff --git a/apps/client-asset-sg/src/app/app-guards.ts b/apps/client-asset-sg/src/app/app-guards.ts index bdfb4628..c307e6a5 100644 --- a/apps/client-asset-sg/src/app/app-guards.ts +++ b/apps/client-asset-sg/src/app/app-guards.ts @@ -5,7 +5,6 @@ import { isNotNull } from '@asset-sg/core'; import { User } from '@asset-sg/shared/v2'; import { Store } from '@ngrx/store'; import { filter, map } from 'rxjs'; - import { AppState } from './state/app-state'; export const roleGuard = (testUser: (u: User) => boolean) => { @@ -13,4 +12,9 @@ export const roleGuard = (testUser: (u: User) => boolean) => { return store.select(fromAppShared.selectUser).pipe(filter(isNotNull), map(testUser)); }; +export const notAnonymousGuard: CanActivateFn = () => { + const store = inject(Store); + return store.select(fromAppShared.selectIsAnonymousMode).pipe(map((isAnonymousMode) => !isAnonymousMode)); +}; + export const adminGuard: CanActivateFn = () => roleGuard((user) => user.isAdmin); diff --git a/apps/client-asset-sg/src/app/app.component.ts b/apps/client-asset-sg/src/app/app.component.ts index f76cef43..231c0e4e 100644 --- a/apps/client-asset-sg/src/app/app.component.ts +++ b/apps/client-asset-sg/src/app/app.component.ts @@ -8,7 +8,6 @@ import { Store } from '@ngrx/store'; import { WINDOW } from 'ngx-window-token'; import { debounceTime, fromEvent, startWith } from 'rxjs'; import { assert } from 'tsafe'; - import { AppState } from './state/app-state'; const fullHdWidth = 1920; @@ -31,17 +30,22 @@ export class AppComponent { constructor() { this._httpClient.get>('api/oauth-config/config').subscribe(async (oAuthConfig) => { - this.authService.configureOAuth( - oAuthConfig['oauth_issuer'] as string, - oAuthConfig['oauth_clientId'] as string, - oAuthConfig['oauth_scope'] as string, - oAuthConfig['oauth_showDebugInformation'] as boolean, - oAuthConfig['oauth_tokenEndpoint'] as string - ); - await this.authService.signIn(); - this.store.dispatch(appSharedStateActions.loadUserProfile()); - this.store.dispatch(appSharedStateActions.loadReferenceData()); + if (oAuthConfig['anonymous_mode']) { + this.authService.setState(AuthState.Success); + this.store.dispatch(appSharedStateActions.setAnonymousMode()); + } else { + this.authService.configureOAuth( + oAuthConfig['oauth_issuer'] as string, + oAuthConfig['oauth_clientId'] as string, + oAuthConfig['oauth_scope'] as string, + oAuthConfig['oauth_showDebugInformation'] as boolean, + oAuthConfig['oauth_tokenEndpoint'] as string + ); + await this.authService.signIn(); + this.store.dispatch(appSharedStateActions.loadUserProfile()); + } this.store.dispatch(appSharedStateActions.loadWorkgroups()); + this.store.dispatch(appSharedStateActions.loadReferenceData()); }); const wndw = this._wndw; diff --git a/apps/client-asset-sg/src/app/app.module.ts b/apps/client-asset-sg/src/app/app.module.ts index 0deebf38..d0c49edc 100644 --- a/apps/client-asset-sg/src/app/app.module.ts +++ b/apps/client-asset-sg/src/app/app.module.ts @@ -33,7 +33,7 @@ import { PushModule } from '@rx-angular/template/push'; import { environment } from '../environments/environment'; -import { adminGuard } from './app-guards'; +import { adminGuard, notAnonymousGuard } from './app-guards'; import { assetsPageMatcher } from './app-matchers'; import { AppComponent } from './app.component'; import { AppBarComponent, MenuBarComponent, NotFoundComponent, RedirectToLangComponent } from './components'; @@ -66,6 +66,7 @@ registerLocaleData(locale_deCH, 'de-CH'); { path: ':lang/profile', loadChildren: () => import('@asset-sg/profile').then((m) => m.ProfileModule), + canActivate: [notAnonymousGuard], }, { path: ':lang/admin', diff --git a/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html b/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html index 53befdd1..cca96889 100644 --- a/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html +++ b/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html @@ -15,6 +15,7 @@
); + public isAnonymousMode$ = this._store.select(fromAppShared.selectIsAnonymousMode); public isAssetsActive$ = this.createIsRouteActive$((url) => Boolean(url.match(/^\/\w\w$/))); public isEditActive$ = this.isSegmentActive('asset-admin'); public isFavouritesActive$ = this.isSegmentActive('favourites'); diff --git a/apps/client-asset-sg/src/app/state/app-shared.reducer.ts b/apps/client-asset-sg/src/app/state/app-shared.reducer.ts index c1433bd9..992cd05c 100644 --- a/apps/client-asset-sg/src/app/state/app-shared.reducer.ts +++ b/apps/client-asset-sg/src/app/state/app-shared.reducer.ts @@ -10,6 +10,7 @@ const initialState: AppSharedState = { rdReferenceData: RD.initial, workgroups: [], lang: 'de', + isAnonymousMode: false, }; export const appSharedStateReducer = createReducer( @@ -18,6 +19,13 @@ export const appSharedStateReducer = createReducer( appSharedStateActions.loadUserProfileResult, (state, rdUserProfile): AppSharedState => ({ ...state, rdUserProfile }) ), + on( + appSharedStateActions.setAnonymousMode, + (state): AppSharedState => ({ + ...state, + isAnonymousMode: true, + }) + ), on( appSharedStateActions.loadReferenceDataResult, (state, rdReferenceData): AppSharedState => ({ ...state, rdReferenceData }) diff --git a/apps/server-asset-sg/.env b/apps/server-asset-sg/.env index edee6ed9..b4ffa40b 100644 --- a/apps/server-asset-sg/.env +++ b/apps/server-asset-sg/.env @@ -12,6 +12,7 @@ OAUTH_SCOPE=openid profile email cognito OAUTH_SHOW_DEBUG_INFO=true OAUTH_TOKEN_ENDPOINT=http://localhost:4011/connect/token OAUTH_AUTHORIZED_GROUPS=assets.swissgeol +ANONYMOUS_MODE=false OCR_URL= OCR_CALLBACK_URL= diff --git a/apps/server-asset-sg/src/app.controller.ts b/apps/server-asset-sg/src/app.controller.ts index a02b1366..1be81232 100644 --- a/apps/server-asset-sg/src/app.controller.ts +++ b/apps/server-asset-sg/src/app.controller.ts @@ -27,6 +27,7 @@ export class AppController { oauth_responseType: process.env.OAUTH_RESPONSE_TYPE, oauth_showDebugInformation: !!process.env.OAUTH_SHOW_DEBUG_INFORMATION, oauth_tokenEndpoint: process.env.OAUTH_TOKEN_ENDPOINT, + anonymous_mode: process.env.ANONYMOUS_MODE === 'true', }; } diff --git a/apps/server-asset-sg/src/core/decorators/current-user.decorator.ts b/apps/server-asset-sg/src/core/decorators/current-user.decorator.ts index 38538239..af5bc90f 100644 --- a/apps/server-asset-sg/src/core/decorators/current-user.decorator.ts +++ b/apps/server-asset-sg/src/core/decorators/current-user.decorator.ts @@ -1,5 +1,5 @@ import { User } from '@asset-sg/shared/v2'; -import { ExecutionContext, createParamDecorator } from '@nestjs/common'; +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { JwtRequest } from '@/models/jwt-request'; diff --git a/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts b/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts index 89582cc3..f5e48e50 100644 --- a/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts +++ b/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts @@ -1,5 +1,6 @@ -import { User } from '@asset-sg/shared/v2'; +import { Role, User, WorkgroupId } from '@asset-sg/shared/v2'; import { environment } from '@environment'; +import { faker } from '@faker-js/faker'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { HttpException, Inject, Injectable, NestMiddleware } from '@nestjs/common'; import { Prisma } from '@prisma/client'; @@ -14,13 +15,23 @@ import { Jwt, JwtPayload } from 'jsonwebtoken'; import jwkToPem from 'jwk-to-pem'; import { UserRepo } from '@/features/users/user.repo'; +import { WorkgroupRepo } from '@/features/workgroups/workgroup.repo'; import { JwtRequest } from '@/models/jwt-request'; @Injectable() export class JwtMiddleware implements NestMiddleware { - constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache, private readonly userRepo: UserRepo) {} + constructor( + @Inject(CACHE_MANAGER) private cacheManager: Cache, + private readonly userRepo: UserRepo, + private readonly workgroupRepo: WorkgroupRepo + ) {} async use(req: Request, res: Response, next: NextFunction) { + if (process.env.ANONYMOUS_MODE === 'true') { + await this.handleAnonymousModeRequest(req); + return next(); + } + if (process.env.NODE_ENV === 'development') { const authentication = req.header('Authorization'); if (authentication != null && authentication.startsWith('Impersonate ')) { @@ -186,6 +197,31 @@ export class JwtMiddleware implements NestMiddleware { Object.assign(req, authenticatedFields); } + private async handleAnonymousModeRequest(req: Request): Promise { + const user = await this.createAnonymousUser(); + const payload: JwtPayload = { sub: user.id, username: user.email }; + // Extend the request with the fields required to make it an `AuthenticatedRequest`. + const authenticatedFields: Omit = { + user, + accessToken: 'anonymous-access-token', + jwtPayload: payload, + }; + Object.assign(req, authenticatedFields); + } + + private async createAnonymousUser(): Promise { + const id = faker.string.uuid(); + const workgroups = await this.workgroupRepo.list(); + const roles = new Map(workgroups.map((workgroup) => [workgroup.id, Role.Viewer])); + return { + id, + email: '', + lang: 'de', + isAdmin: false, + roles, + }; + } + private async initializeDefaultUser(oidcId: string, payload: JwtPayload): Promise { if (!('username' in payload) || payload.username.length === 0) { throw new HttpException('invalid JWT payload: missing username', 401); diff --git a/apps/server-asset-sg/src/features/users/users.controller.ts b/apps/server-asset-sg/src/features/users/users.controller.ts index b9e6190b..54c33a2a 100644 --- a/apps/server-asset-sg/src/features/users/users.controller.ts +++ b/apps/server-asset-sg/src/features/users/users.controller.ts @@ -1,5 +1,4 @@ -import { convert, User, UserData, UserId, UserSchema } from '@asset-sg/shared/v2'; -import { UserDataSchema } from '@asset-sg/shared/v2'; +import { convert, User, UserData, UserDataSchema, UserId, UserSchema } from '@asset-sg/shared/v2'; import { Controller, Delete, Get, HttpCode, HttpException, HttpStatus, Param, Put } from '@nestjs/common'; import { Authorize } from '@/core/decorators/authorize.decorator'; import { CurrentUser } from '@/core/decorators/current-user.decorator'; diff --git a/apps/server-asset-sg/src/features/users/users.http b/apps/server-asset-sg/src/features/users/users.http index 9ddb7c62..3157614a 100644 --- a/apps/server-asset-sg/src/features/users/users.http +++ b/apps/server-asset-sg/src/features/users/users.http @@ -22,13 +22,12 @@ Authorization: Impersonate {{user}} Content-Type: application/json { - "role": "admin", "lang": "de", - "workgroups": [ - { - "workgroupId": 4, - "role": "MasterEditor" - } + "roles": [ + [ + 1, + "MasterEditor" + ] ], "isAdmin": false } diff --git a/apps/server-asset-sg/src/features/workgroups/workgroup-simple.repo.ts b/apps/server-asset-sg/src/features/workgroups/workgroup-simple.repo.ts index 4b1ba2bb..c5a5481d 100644 --- a/apps/server-asset-sg/src/features/workgroups/workgroup-simple.repo.ts +++ b/apps/server-asset-sg/src/features/workgroups/workgroup-simple.repo.ts @@ -1,5 +1,4 @@ -import { User, UserId } from '@asset-sg/shared/v2'; -import { Role, SimpleWorkgroup, WorkgroupId } from '@asset-sg/shared/v2'; +import { Role, SimpleWorkgroup, User, UserId, WorkgroupId } from '@asset-sg/shared/v2'; import { Prisma } from '@prisma/client'; import { PrismaService } from '@/core/prisma.service'; import { ReadRepo, RepoListOptions } from '@/core/repo'; @@ -17,16 +16,18 @@ export class SimpleWorkgroupRepo implements ReadRepo = {}): Promise { + const isAnonymousMode = process.env.ANONYMOUS_MODE === 'true'; + const unrestricted = isAnonymousMode || this.user.isAdmin; const entries = await this.prisma.workgroup.findMany({ where: { id: ids == null ? undefined : { in: ids }, - users: this.user.isAdmin ? undefined : { some: { userId: this.user.id } }, + users: unrestricted ? undefined : { some: { userId: this.user.id } }, }, take: limit, skip: offset, select: simpleWorkgroupSelection(this.user.id), }); - return entries.map((it) => parse(it, this.user.isAdmin)); + return entries.map((it) => parse(it, this.user.isAdmin, isAnonymousMode)); } } @@ -46,8 +47,18 @@ export const simpleWorkgroupSelection = (userId: UserId) => type SelectedWorkgroup = Prisma.WorkgroupGetPayload<{ select: ReturnType }>; -const parse = (data: SelectedWorkgroup, isAdmin: boolean): SimpleWorkgroup => ({ - id: data.id, - name: data.name, - role: isAdmin ? Role.MasterEditor : data.users[0].role, -}); +const parse = (data: SelectedWorkgroup, isAdmin: boolean, isAnonymousMode = false): SimpleWorkgroup => { + let role: Role; + if (isAdmin) { + role = Role.MasterEditor; + } else if (isAnonymousMode) { + role = Role.Viewer; + } else { + role = data.users[0].role; + } + return { + id: data.id, + name: data.name, + role, + }; +}; diff --git a/libs/auth/src/lib/services/auth.interceptor.ts b/libs/auth/src/lib/services/auth.interceptor.ts index fc8c6c3d..7de10370 100644 --- a/libs/auth/src/lib/services/auth.interceptor.ts +++ b/libs/auth/src/lib/services/auth.interceptor.ts @@ -1,12 +1,11 @@ import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; import { inject, Injectable, OnDestroy } from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Router } from '@angular/router'; -import { AlertType, showAlert } from '@asset-sg/client-shared'; +import { AlertType, fromAppShared, showAlert } from '@asset-sg/client-shared'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { OAuthService } from 'angular-oauth2-oidc'; import { catchError, EMPTY, from, Observable, Subscription, switchMap } from 'rxjs'; - import { AuthService, AuthState } from './auth.service'; @Injectable() @@ -25,9 +24,11 @@ export class AuthInterceptor implements HttpInterceptor, OnDestroy { * @private */ private isNavigating = false; + private isAnonymousMode = false; constructor() { this.initializeRouterSubscription(); + this.initializeStoreSubscription(); } intercept(req: HttpRequest, next: HttpHandler): Observable> { @@ -36,7 +37,8 @@ export class AuthInterceptor implements HttpInterceptor, OnDestroy { if ( (this._oauthService.issuer && req.url.includes(this._oauthService.issuer)) || (this._oauthService.tokenEndpoint && req.url.includes(this._oauthService.tokenEndpoint)) || - req.url.includes('oauth-config/config') + req.url.includes('oauth-config/config') || + this.isAnonymousMode ) { return next.handle(req); } else if (token && !this._oauthService.hasValidAccessToken()) { @@ -122,6 +124,14 @@ export class AuthInterceptor implements HttpInterceptor, OnDestroy { this.subscription.unsubscribe(); } + private initializeStoreSubscription(): void { + this.subscription.add( + this.store.select(fromAppShared.selectIsAnonymousMode).subscribe((isAnonymousMode) => { + this.isAnonymousMode = isAnonymousMode; + }) + ); + } + private initializeRouterSubscription(): void { this.subscription.add( this.router.events.subscribe((event) => { diff --git a/libs/client-shared/src/lib/state/app-shared-state.actions.ts b/libs/client-shared/src/lib/state/app-shared-state.actions.ts index 33fd9242..10f9581d 100644 --- a/libs/client-shared/src/lib/state/app-shared-state.actions.ts +++ b/libs/client-shared/src/lib/state/app-shared-state.actions.ts @@ -21,6 +21,9 @@ export const editContactResult = createAction( ); export const loadUserProfile = createAction('[App Shared State] Load User Profile'); + +export const setAnonymousMode = createAction('[App Shared State] Set Viewer Mode'); + export const loadUserProfileResult = createAction( '[App Shared State] Load User Profile Result', props>() diff --git a/libs/client-shared/src/lib/state/app-shared-state.selectors.ts b/libs/client-shared/src/lib/state/app-shared-state.selectors.ts index a39a1f43..1b64f7ee 100644 --- a/libs/client-shared/src/lib/state/app-shared-state.selectors.ts +++ b/libs/client-shared/src/lib/state/app-shared-state.selectors.ts @@ -13,6 +13,8 @@ const appSharedFeature = (state: AppState) => state.shared; export const selectRDReferenceData = createSelector(appSharedFeature, (state) => state.rdReferenceData); +export const selectIsAnonymousMode = createSelector(appSharedFeature, (state) => state.isAnonymousMode); + export const selectRDUserProfile = createSelector(appSharedFeature, (state) => state.rdUserProfile); export const selectUser = createSelector(selectRDUserProfile, RD.toNullable); diff --git a/libs/client-shared/src/lib/state/app-shared-state.ts b/libs/client-shared/src/lib/state/app-shared-state.ts index fc8afb4c..1f6acb06 100644 --- a/libs/client-shared/src/lib/state/app-shared-state.ts +++ b/libs/client-shared/src/lib/state/app-shared-state.ts @@ -9,6 +9,7 @@ export interface AppSharedState { rdReferenceData: RD.RemoteData; workgroups: SimpleWorkgroup[]; lang: Lang; + isAnonymousMode: boolean; } export interface AppState { diff --git a/package-lock.json b/package-lock.json index a7d6d6b1..0a26d428 100644 --- a/package-lock.json +++ b/package-lock.json @@ -147,8 +147,10 @@ "@nx/nx-darwin-arm64": "19.2.1", "@nx/nx-darwin-x64": "19.2.1", "@nx/nx-linux-x64-gnu": "19.2.1", + "@nx/nx-linux-x64-musl": "19.2.1", "@nx/nx-win32-x64-msvc": "19.2.1", - "@rollup/rollup-linux-x64-gnu": "4.16.0" + "@rollup/rollup-linux-x64-gnu": "4.16.0", + "@rollup/rollup-linux-x64-musl": "4.16.0" } }, "node_modules/@adobe/css-tools": { @@ -7885,6 +7887,21 @@ "node": ">= 10" } }, + "node_modules/@nx/nx-linux-x64-musl": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-19.2.1.tgz", + "integrity": "sha512-J8IwFAfhZ16n1lKb1B7MSDQ7IScF+Y0IFKl48ze3ixD3sMr48KTDKetqqxjel1pNeUchtV5xFsAlWuHpp5s58Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nx/nx-win32-x64-msvc": { "version": "19.2.1", "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-19.2.1.tgz", @@ -8815,13 +8832,12 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz", - "integrity": "sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg==", + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.16.0.tgz", + "integrity": "sha512-YKCs7ghJZ5po6/qgfONiXyFKOKcTK4Kerzk/Kc89QK0JT94Qg4NurL+3Y3rZh5am2tu1OlvHPpBHQNBE8cFgJQ==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -26450,6 +26466,18 @@ "linux" ] }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz", + "integrity": "sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/rrweb-cssom": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", From 56058bdf2e46b56026412f24a49d51df9f03e145 Mon Sep 17 00:00:00 2001 From: Jannic Veith Date: Thu, 29 Aug 2024 13:21:26 +0200 Subject: [PATCH 2/3] Clean up app component --- apps/client-asset-sg/src/app/app.component.ts | 29 ++----- .../menu-bar/menu-bar.component.html | 4 +- .../components/menu-bar/menu-bar.component.ts | 2 +- .../src/core/middleware/jwt.middleware.ts | 21 +++-- libs/auth/src/lib/services/auth.service.ts | 83 +++++++++++-------- 5 files changed, 73 insertions(+), 66 deletions(-) diff --git a/apps/client-asset-sg/src/app/app.component.ts b/apps/client-asset-sg/src/app/app.component.ts index 231c0e4e..03620109 100644 --- a/apps/client-asset-sg/src/app/app.component.ts +++ b/apps/client-asset-sg/src/app/app.component.ts @@ -6,7 +6,7 @@ import { AppPortalService, appSharedStateActions, setCssCustomProperties } from import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { Store } from '@ngrx/store'; import { WINDOW } from 'ngx-window-token'; -import { debounceTime, fromEvent, startWith } from 'rxjs'; +import { debounceTime, fromEvent, startWith, tap } from 'rxjs'; import { assert } from 'tsafe'; import { AppState } from './state/app-state'; @@ -23,30 +23,19 @@ export class AppComponent { private _httpClient = inject(HttpClient); public appPortalService = inject(AppPortalService); - readonly router: Router = inject(Router); + private readonly router: Router = inject(Router); readonly errorService = inject(ErrorService); readonly authService = inject(AuthService); private readonly store = inject(Store); constructor() { - this._httpClient.get>('api/oauth-config/config').subscribe(async (oAuthConfig) => { - if (oAuthConfig['anonymous_mode']) { - this.authService.setState(AuthState.Success); - this.store.dispatch(appSharedStateActions.setAnonymousMode()); - } else { - this.authService.configureOAuth( - oAuthConfig['oauth_issuer'] as string, - oAuthConfig['oauth_clientId'] as string, - oAuthConfig['oauth_scope'] as string, - oAuthConfig['oauth_showDebugInformation'] as boolean, - oAuthConfig['oauth_tokenEndpoint'] as string - ); - await this.authService.signIn(); - this.store.dispatch(appSharedStateActions.loadUserProfile()); - } - this.store.dispatch(appSharedStateActions.loadWorkgroups()); - this.store.dispatch(appSharedStateActions.loadReferenceData()); - }); + this._httpClient + .get>('api/oauth-config/config') + .pipe(tap(async (config) => await this.authService.initialize(config))) + .subscribe(async (oAuthConfig) => { + this.store.dispatch(appSharedStateActions.loadWorkgroups()); + this.store.dispatch(appSharedStateActions.loadReferenceData()); + }); const wndw = this._wndw; assert(wndw != null); diff --git a/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html b/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html index cca96889..a0eed6b5 100644 --- a/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html +++ b/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html @@ -15,7 +15,7 @@
); - public isAnonymousMode$ = this._store.select(fromAppShared.selectIsAnonymousMode); + public userExists$ = this._store.select(fromAppShared.selectIsAnonymousMode).pipe(map((anonymous) => !anonymous)); public isAssetsActive$ = this.createIsRouteActive$((url) => Boolean(url.match(/^\/\w\w$/))); public isEditActive$ = this.isSegmentActive('asset-admin'); public isFavouritesActive$ = this.isSegmentActive('favourites'); diff --git a/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts b/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts index f5e48e50..cc6bef4a 100644 --- a/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts +++ b/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts @@ -47,6 +47,17 @@ export class JwtMiddleware implements NestMiddleware { } } + const token = await this.getToken(req); + // Set accessToken and jwtPayload to request if verification is successful + if (E.isRight(token)) { + await this.initializeRequest(req, token.right.accessToken, token.right.jwtPayload as JwtPayload); + next(); + } else { + res.status(403).json({ error: 'not authorized by eIAM' }); + } + } + + private async getToken(req: Request) { // Get JWK from cache if exists, otherwise fetch from issuer and set to cache for 1 minute const cachedJwk = await this.getJwkFromCache()(); const jwk = E.isRight(cachedJwk) ? cachedJwk : await this.getJwkTE()(); @@ -54,7 +65,7 @@ export class JwtMiddleware implements NestMiddleware { const token = this.extractTokenFromHeaderE(req); // Decode token, check groups permission, get JWK, convert JWK to PEM, and verify token - const result = pipe( + return pipe( token, E.chain(this.decodeTokenE), E.chain(this.isAuthorizedByGroupE), @@ -67,14 +78,6 @@ export class JwtMiddleware implements NestMiddleware { this.jwkToPemE, E.chain((pem) => this.verifyToken(token, pem)) ); - - // Set accessToken and jwtPayload to request if verification is successful - if (E.isRight(result)) { - await this.initializeRequest(req, result.right.accessToken, result.right.jwtPayload as JwtPayload); - next(); - } else { - res.status(403).json({ error: 'not authorized by eIAM' }); - } } private getJwkFromCache(): TE.TaskEither { diff --git a/libs/auth/src/lib/services/auth.service.ts b/libs/auth/src/lib/services/auth.service.ts index 549b6927..17a6b7ed 100644 --- a/libs/auth/src/lib/services/auth.service.ts +++ b/libs/auth/src/lib/services/auth.service.ts @@ -1,73 +1,70 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { ApiError } from '@asset-sg/client-shared'; +import { ApiError, appSharedStateActions, AppState } from '@asset-sg/client-shared'; import { ORD } from '@asset-sg/core'; import { User, UserSchema } from '@asset-sg/shared/v2'; import * as RD from '@devexperts/remote-data-ts'; +import { Store } from '@ngrx/store'; import { OAuthService } from 'angular-oauth2-oidc'; import { plainToInstance } from 'class-transformer'; import { BehaviorSubject, map, Observable, startWith } from 'rxjs'; -import urlJoin from 'url-join'; @Injectable({ providedIn: 'root' }) export class AuthService { - private _httpClient = inject(HttpClient); + private readonly httpClient = inject(HttpClient); + private readonly oauthService = inject(OAuthService); + private readonly store = inject(Store); - private oauthService = inject(OAuthService); + private readonly _state$ = new BehaviorSubject(AuthState.Ongoing); - private _state = new BehaviorSubject(AuthState.Ongoing); - - public configureOAuth( - issuer: string, - clientId: string, - scope: string, - showDebugInformation: boolean, - tokenEndpoint: string - ): void { - this.oauthService.configure({ - issuer, - redirectUri: window.location.origin, - postLogoutRedirectUri: window.location.origin, - clientId, - scope, - responseType: 'code', - showDebugInformation, - strictDiscoveryDocumentValidation: false, - tokenEndpoint, - }); + async initialize(oAuthConfig: Record) { + if (oAuthConfig['anonymous_mode']) { + this.setState(AuthState.Success); + this.store.dispatch(appSharedStateActions.setAnonymousMode()); + } else { + this.configureOAuth( + oAuthConfig['oauth_issuer'] as string, + oAuthConfig['oauth_clientId'] as string, + oAuthConfig['oauth_scope'] as string, + oAuthConfig['oauth_showDebugInformation'] as boolean, + oAuthConfig['oauth_tokenEndpoint'] as string + ); + await this.signIn(); + this.store.dispatch(appSharedStateActions.loadUserProfile()); + } } async signIn(): Promise { try { - if (this._state.value === AuthState.Ongoing) { + if (this._state$.value === AuthState.Ongoing) { const success = await this.oauthService.loadDiscoveryDocumentAndLogin(); if (success) { this.oauthService.setupAutomaticSilentRefresh(); // If something else has interrupted the auth process, then we don't want to signal a success. - if (this._state.value === AuthState.Ongoing) { - this._state.next(AuthState.Success); + if (this._state$.value === AuthState.Ongoing) { + this._state$.next(AuthState.Success); } } } else { - this._state.next(AuthState.Ongoing); + this._state$.next(AuthState.Ongoing); this.oauthService.initLoginFlow(); } } catch (e) { - this._state.next(AuthState.Aborted); + this._state$.next(AuthState.Aborted); } } get state(): AuthState { - return this._state.value; + return this._state$.value; } get state$(): Observable { - return this._state.asObservable(); + return this._state$.asObservable(); } setState(state: AuthState): void { - this._state.next(state); + this._state$.next(state); } getUserProfile(): ORD.ObservableRemoteData { @@ -90,10 +87,28 @@ export class AuthService { } private _getUserProfile(): Observable { - return this._httpClient.get('/api/users/current').pipe(map((it) => plainToInstance(UserSchema, it))); + return this.httpClient.get('/api/users/current').pipe(map((it) => plainToInstance(UserSchema, it))); } - buildAuthUrl = (path: string) => urlJoin(`/auth`, path); + private configureOAuth( + issuer: string, + clientId: string, + scope: string, + showDebugInformation: boolean, + tokenEndpoint: string + ): void { + this.oauthService.configure({ + issuer, + redirectUri: window.location.origin, + postLogoutRedirectUri: window.location.origin, + clientId, + scope, + responseType: 'code', + showDebugInformation, + strictDiscoveryDocumentValidation: false, + tokenEndpoint, + }); + } } export enum AuthState { From 1d1426c0eabc92877a5d6c2e5ea99dc7cca99d7d Mon Sep 17 00:00:00 2001 From: Jannic Veith Date: Thu, 29 Aug 2024 14:09:33 +0200 Subject: [PATCH 3/3] Use proper uuid package --- .../src/core/middleware/jwt.middleware.ts | 6 ++- package-lock.json | 38 +++++++++++++++++-- package.json | 4 +- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts b/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts index cc6bef4a..400ed59d 100644 --- a/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts +++ b/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts @@ -1,6 +1,6 @@ import { Role, User, WorkgroupId } from '@asset-sg/shared/v2'; import { environment } from '@environment'; -import { faker } from '@faker-js/faker'; + import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { HttpException, Inject, Injectable, NestMiddleware } from '@nestjs/common'; import { Prisma } from '@prisma/client'; @@ -13,6 +13,7 @@ import * as TE from 'fp-ts/TaskEither'; import * as jwt from 'jsonwebtoken'; import { Jwt, JwtPayload } from 'jsonwebtoken'; import jwkToPem from 'jwk-to-pem'; +import { v5 as uuidv5 } from 'uuid'; import { UserRepo } from '@/features/users/user.repo'; import { WorkgroupRepo } from '@/features/workgroups/workgroup.repo'; @@ -213,7 +214,8 @@ export class JwtMiddleware implements NestMiddleware { } private async createAnonymousUser(): Promise { - const id = faker.string.uuid(); + const swisstopoAssetsNamespace = '29248768-a9ac-4ef8-9dcb-d9847753208b'; + const id = uuidv5('anonymous', swisstopoAssetsNamespace); // a743fc8a-afec-5eab-8b9b-e4002c2a01be const workgroups = await this.workgroupRepo.list(); const roles = new Map(workgroups.map((workgroup) => [workgroup.id, Role.Viewer])); return { diff --git a/package-lock.json b/package-lock.json index 0a26d428..1470d055 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,7 @@ "tslib": "^2.3.0", "type-fest": "^4.21.0", "url-join": "^5.0.0", + "uuid": "^10.0.0", "vite-plugin-solid": "^2.5.0", "zone.js": "0.14.6" }, @@ -106,6 +107,7 @@ "@types/multer": "^1.4.11", "@types/node": "^18.16.9", "@types/proj4": "^2.5.5", + "@types/uuid": "^10.0.0", "@types/validator": "^13.7.10", "@typescript-eslint/eslint-plugin": "^7.10.0", "@typescript-eslint/parser": "7.3.0", @@ -6195,6 +6197,18 @@ "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, + "node_modules/@nestjs/schedule/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nestjs/schematics": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.0.1.tgz", @@ -9497,6 +9511,18 @@ "node": ">=16.0.0" } }, + "node_modules/@smithy/middleware-retry/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@smithy/middleware-serde": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-3.0.0.tgz", @@ -10970,6 +10996,12 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "devOptional": true }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, "node_modules/@types/validator": { "version": "13.11.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.10.tgz", @@ -28955,9 +28987,9 @@ } }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/package.json b/package.json index 3a28c6cb..10b30679 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "tslib": "^2.3.0", "type-fest": "^4.21.0", "url-join": "^5.0.0", + "uuid": "^10.0.0", "vite-plugin-solid": "^2.5.0", "zone.js": "0.14.6" }, @@ -117,6 +118,7 @@ "@types/multer": "^1.4.11", "@types/node": "^18.16.9", "@types/proj4": "^2.5.5", + "@types/uuid": "^10.0.0", "@types/validator": "^13.7.10", "@typescript-eslint/eslint-plugin": "^7.10.0", "@typescript-eslint/parser": "7.3.0", @@ -158,8 +160,8 @@ "@nx/nx-darwin-arm64": "19.2.1", "@nx/nx-darwin-x64": "19.2.1", "@nx/nx-linux-x64-gnu": "19.2.1", - "@nx/nx-win32-x64-msvc": "19.2.1", "@nx/nx-linux-x64-musl": "19.2.1", + "@nx/nx-win32-x64-msvc": "19.2.1", "@rollup/rollup-linux-x64-gnu": "4.16.0", "@rollup/rollup-linux-x64-musl": "4.16.0" },