mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
* Refactor impersonation mode into guard * Update changelogbugfix/user-settings-and-calculations-in-impersonation-mode
committed by
GitHub
42 changed files with 431 additions and 288 deletions
@ -0,0 +1,12 @@ |
|||||
|
import { SetMetadata } from '@nestjs/common'; |
||||
|
|
||||
|
export const ALLOW_DURING_IMPERSONATION_KEY = 'allow_during_impersonation'; |
||||
|
|
||||
|
/** |
||||
|
* Marks a controller or a route which modifies data of the authenticated user |
||||
|
* instead of data of the impersonated user, hence it stays available while an |
||||
|
* impersonation is active |
||||
|
*/ |
||||
|
export function AllowDuringImpersonation() { |
||||
|
return SetMetadata(ALLOW_DURING_IMPERSONATION_KEY, true); |
||||
|
} |
||||
@ -0,0 +1,26 @@ |
|||||
|
import type { |
||||
|
ImpersonationContext, |
||||
|
RequestWithUser |
||||
|
} from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; |
||||
|
|
||||
|
/** |
||||
|
* Provides the impersonation context of the request, which requires the |
||||
|
* ImpersonationGuard to be applied to the route |
||||
|
*/ |
||||
|
export const Impersonation = createParamDecorator( |
||||
|
(_data: unknown, context: ExecutionContext): ImpersonationContext => { |
||||
|
const { impersonation, user } = context |
||||
|
.switchToHttp() |
||||
|
.getRequest<RequestWithUser>(); |
||||
|
|
||||
|
return ( |
||||
|
impersonation ?? { |
||||
|
isActive: false, |
||||
|
userId: user?.id, |
||||
|
userSettings: user?.settings?.settings ?? {} |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
); |
||||
@ -0,0 +1,53 @@ |
|||||
|
import { ALLOW_DURING_IMPERSONATION_KEY } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; |
||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; |
||||
|
|
||||
|
import { |
||||
|
CanActivate, |
||||
|
ExecutionContext, |
||||
|
HttpException, |
||||
|
Injectable |
||||
|
} from '@nestjs/common'; |
||||
|
import { Reflector } from '@nestjs/core'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
/** |
||||
|
* Blocks write requests while an impersonation is active, so that data of the |
||||
|
* authenticated user cannot be changed from a view presenting data of the |
||||
|
* impersonated user. The header is evaluated instead of the resolved context to |
||||
|
* fail closed, also for an identifier which cannot be resolved. |
||||
|
*/ |
||||
|
@Injectable() |
||||
|
export class ImpersonationWriteGuard implements CanActivate { |
||||
|
public constructor(private readonly reflector: Reflector) {} |
||||
|
|
||||
|
public canActivate(context: ExecutionContext): boolean { |
||||
|
if (context.getType() !== 'http') { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
const request = context.switchToHttp().getRequest(); |
||||
|
|
||||
|
if (request.method === 'GET') { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
if (!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()]) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
const isAllowedDuringImpersonation = |
||||
|
this.reflector.getAllAndOverride<boolean>( |
||||
|
ALLOW_DURING_IMPERSONATION_KEY, |
||||
|
[context.getHandler(), context.getClass()] |
||||
|
); |
||||
|
|
||||
|
if (isAllowedDuringImpersonation) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; |
||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class ImpersonationGuard implements CanActivate { |
||||
|
public constructor( |
||||
|
private readonly impersonationService: ImpersonationService |
||||
|
) {} |
||||
|
|
||||
|
public async canActivate(context: ExecutionContext) { |
||||
|
const request = context.switchToHttp().getRequest<RequestWithUser>(); |
||||
|
|
||||
|
request.impersonation = await this.impersonationService.resolve({ |
||||
|
impersonationId: request.headers?.[ |
||||
|
HEADER_KEY_IMPERSONATION.toLowerCase() |
||||
|
] as string, |
||||
|
user: request.user |
||||
|
}); |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
import { UserSettings } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
/** |
||||
|
* Describes whose data a request presents. The user id and the settings belong |
||||
|
* to the impersonated user while an impersonation is active and to the |
||||
|
* authenticated user otherwise, so a handler can use them unconditionally. |
||||
|
*/ |
||||
|
export interface ImpersonationContext { |
||||
|
accessId?: string; |
||||
|
isActive: boolean; |
||||
|
userId: string; |
||||
|
userSettings: UserSettings; |
||||
|
} |
||||
@ -1,3 +1,9 @@ |
|||||
import { UserWithSettings } from '@ghostfolio/common/types'; |
import { |
||||
|
ImpersonationContext, |
||||
|
UserWithSettings |
||||
|
} from '@ghostfolio/common/types'; |
||||
|
|
||||
export type RequestWithUser = Request & { user: UserWithSettings }; |
export type RequestWithUser = Request & { |
||||
|
impersonation?: ImpersonationContext; |
||||
|
user: UserWithSettings; |
||||
|
}; |
||||
|
|||||
Loading…
Reference in new issue