Skip to content

Commit

Permalink
Merge pull request #256 from swisstopo/feature/assets-247-asset-viewer
Browse files Browse the repository at this point in the history
add viewer mode
  • Loading branch information
vej-ananas authored Aug 29, 2024
2 parents efdf9ed + 1d1426c commit 5132cb0
Show file tree
Hide file tree
Showing 20 changed files with 248 additions and 94 deletions.
6 changes: 5 additions & 1 deletion apps/client-asset-sg/src/app/app-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ 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) => {
const store = inject(Store<AppState>);
return store.select(fromAppShared.selectUser).pipe(filter(isNotNull), map(testUser));
};

export const notAnonymousGuard: CanActivateFn = () => {
const store = inject(Store<AppState>);
return store.select(fromAppShared.selectIsAnonymousMode).pipe(map((isAnonymousMode) => !isAnonymousMode));
};

export const adminGuard: CanActivateFn = () => roleGuard((user) => user.isAdmin);
25 changes: 9 additions & 16 deletions apps/client-asset-sg/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ 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';

const fullHdWidth = 1920;
Expand All @@ -24,25 +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<AppState>);

constructor() {
this._httpClient.get<Record<string, unknown>>('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());
this.store.dispatch(appSharedStateActions.loadWorkgroups());
});
this._httpClient
.get<Record<string, unknown>>('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);
Expand Down
3 changes: 2 additions & 1 deletion apps/client-asset-sg/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
</a>
</ng-template>
<a
*ngIf="userExists$ | async"
asset-sg-reset
[disabled]="true"
[routerLink]="[_translateService.currentLang, 'favourites']"
Expand Down Expand Up @@ -59,6 +60,7 @@
</div>
<div>
<a
*ngIf="userExists$ | async"
[routerLink]="[_translateService.currentLang, 'profile']"
asset-sg-reset
class="menu-bar-item"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, HostBinding, inject } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { appSharedStateActions } from '@asset-sg/client-shared';
import { appSharedStateActions, fromAppShared } from '@asset-sg/client-shared';
import { AssetEditPolicy } from '@asset-sg/shared/v2';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Store } from '@ngrx/store';
Expand All @@ -23,6 +23,7 @@ export class MenuBarComponent {
public _translateService = inject(TranslateService);
private _store = inject(Store<AppState>);

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');
Expand Down
8 changes: 8 additions & 0 deletions apps/client-asset-sg/src/app/state/app-shared.reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const initialState: AppSharedState = {
rdReferenceData: RD.initial,
workgroups: [],
lang: 'de',
isAnonymousMode: false,
};

export const appSharedStateReducer = createReducer(
Expand All @@ -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 })
Expand Down
1 change: 1 addition & 0 deletions apps/server-asset-sg/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
1 change: 1 addition & 0 deletions apps/server-asset-sg/src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
63 changes: 52 additions & 11 deletions apps/server-asset-sg/src/core/middleware/jwt.middleware.ts
Original file line number Diff line number Diff line change
@@ -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 { CACHE_MANAGER } from '@nestjs/cache-manager';
import { HttpException, Inject, Injectable, NestMiddleware } from '@nestjs/common';
import { Prisma } from '@prisma/client';
Expand All @@ -12,15 +13,26 @@ 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';
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 ')) {
Expand All @@ -36,14 +48,25 @@ 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()();
await this.cacheManager.set('jwk', E.isRight(jwk) ? jwk.right : [], 60 * 1000);

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),
Expand All @@ -56,14 +79,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<Error, JwksKey[]> {
Expand Down Expand Up @@ -186,6 +201,32 @@ export class JwtMiddleware implements NestMiddleware {
Object.assign(req, authenticatedFields);
}

private async handleAnonymousModeRequest(req: Request): Promise<void> {
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<JwtRequest, keyof Request> = {
user,
accessToken: 'anonymous-access-token',
jwtPayload: payload,
};
Object.assign(req, authenticatedFields);
}

private async createAnonymousUser(): Promise<User> {
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<WorkgroupId, Role>(workgroups.map((workgroup) => [workgroup.id, Role.Viewer]));
return {
id,
email: '',
lang: 'de',
isAdmin: false,
roles,
};
}

private async initializeDefaultUser(oidcId: string, payload: JwtPayload): Promise<User> {
if (!('username' in payload) || payload.username.length === 0) {
throw new HttpException('invalid JWT payload: missing username', 401);
Expand Down
3 changes: 1 addition & 2 deletions apps/server-asset-sg/src/features/users/users.controller.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
11 changes: 5 additions & 6 deletions apps/server-asset-sg/src/features/users/users.http
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,16 +16,18 @@ export class SimpleWorkgroupRepo implements ReadRepo<SimpleWorkgroup, WorkgroupI
}

async list({ limit, offset, ids }: RepoListOptions<WorkgroupId> = {}): Promise<SimpleWorkgroup[]> {
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));
}
}

Expand All @@ -46,8 +47,18 @@ export const simpleWorkgroupSelection = (userId: UserId) =>

type SelectedWorkgroup = Prisma.WorkgroupGetPayload<{ select: ReturnType<typeof simpleWorkgroupSelection> }>;

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,
};
};
16 changes: 13 additions & 3 deletions libs/auth/src/lib/services/auth.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
Expand All @@ -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()) {
Expand Down Expand Up @@ -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) => {
Expand Down
Loading

0 comments on commit 5132cb0

Please sign in to comment.