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