mirror of https://github.com/immich-app/immich.git
refactor(server): guards, decorators, and utils (#3060)
parent
f55b3add80
commit
d69fa3ceae
@ -0,0 +1,118 @@
|
|||||||
|
import { AuthService, AuthUserDto, IMMICH_API_KEY_NAME, LoginDetails } from '@app/domain';
|
||||||
|
import {
|
||||||
|
applyDecorators,
|
||||||
|
CanActivate,
|
||||||
|
createParamDecorator,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
SetMetadata,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ApiBearerAuth, ApiCookieAuth, ApiQuery, ApiSecurity } from '@nestjs/swagger';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
|
export enum Metadata {
|
||||||
|
AUTH_ROUTE = 'auth_route',
|
||||||
|
ADMIN_ROUTE = 'admin_route',
|
||||||
|
SHARED_ROUTE = 'shared_route',
|
||||||
|
PUBLIC_SECURITY = 'public_security',
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminDecorator = SetMetadata(Metadata.ADMIN_ROUTE, true);
|
||||||
|
|
||||||
|
const sharedLinkDecorators = [
|
||||||
|
SetMetadata(Metadata.SHARED_ROUTE, true),
|
||||||
|
ApiQuery({ name: 'key', type: String, required: false }),
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface AuthenticatedOptions {
|
||||||
|
admin?: boolean;
|
||||||
|
isShared?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Authenticated = (options: AuthenticatedOptions = {}) => {
|
||||||
|
const decorators: MethodDecorator[] = [
|
||||||
|
ApiBearerAuth(),
|
||||||
|
ApiCookieAuth(),
|
||||||
|
ApiSecurity(IMMICH_API_KEY_NAME),
|
||||||
|
SetMetadata(Metadata.AUTH_ROUTE, true),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options.admin) {
|
||||||
|
decorators.push(adminDecorator);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.isShared) {
|
||||||
|
decorators.push(...sharedLinkDecorators);
|
||||||
|
}
|
||||||
|
|
||||||
|
return applyDecorators(...decorators);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PublicRoute = () =>
|
||||||
|
applyDecorators(SetMetadata(Metadata.AUTH_ROUTE, false), ApiSecurity(Metadata.PUBLIC_SECURITY));
|
||||||
|
export const SharedLinkRoute = () => applyDecorators(...sharedLinkDecorators);
|
||||||
|
export const AdminRoute = () => adminDecorator;
|
||||||
|
|
||||||
|
export const AuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => {
|
||||||
|
return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GetLoginDetails = createParamDecorator((data, ctx: ExecutionContext): LoginDetails => {
|
||||||
|
const req = ctx.switchToHttp().getRequest();
|
||||||
|
const userAgent = UAParser(req.headers['user-agent']);
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientIp: req.clientIp,
|
||||||
|
isSecure: req.secure,
|
||||||
|
deviceType: userAgent.browser.name || userAgent.device.type || req.headers.devicemodel || '',
|
||||||
|
deviceOS: userAgent.os.name || req.headers.devicetype || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
user?: AuthUserDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppGuard implements CanActivate {
|
||||||
|
private logger = new Logger(AppGuard.name);
|
||||||
|
|
||||||
|
constructor(private reflector: Reflector, private authService: AuthService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const targets = [context.getHandler(), context.getClass()];
|
||||||
|
|
||||||
|
const isAuthRoute = this.reflector.getAllAndOverride(Metadata.AUTH_ROUTE, targets);
|
||||||
|
const isAdminRoute = this.reflector.getAllAndOverride(Metadata.ADMIN_ROUTE, targets);
|
||||||
|
const isSharedRoute = this.reflector.getAllAndOverride(Metadata.SHARED_ROUTE, targets);
|
||||||
|
|
||||||
|
if (!isAuthRoute) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = context.switchToHttp().getRequest<AuthRequest>();
|
||||||
|
|
||||||
|
const authDto = await this.authService.validate(req.headers, req.query as Record<string, string>);
|
||||||
|
if (!authDto) {
|
||||||
|
this.logger.warn(`Denied access to authenticated route: ${req.path}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authDto.isPublicUser && !isSharedRoute) {
|
||||||
|
this.logger.warn(`Denied access to non-shared route: ${req.path}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdminRoute && !authDto.isAdmin) {
|
||||||
|
this.logger.warn(`Denied access to admin only route: ${req.path}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = authDto;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,25 +0,0 @@
|
|||||||
export { AuthUserDto } from '@app/domain';
|
|
||||||
import { AuthUserDto, LoginDetails } from '@app/domain';
|
|
||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
||||||
import { Request } from 'express';
|
|
||||||
import { UAParser } from 'ua-parser-js';
|
|
||||||
|
|
||||||
export interface AuthRequest extends Request {
|
|
||||||
user?: AuthUserDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => {
|
|
||||||
return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const GetLoginDetails = createParamDecorator((data, ctx: ExecutionContext): LoginDetails => {
|
|
||||||
const req = ctx.switchToHttp().getRequest();
|
|
||||||
const userAgent = UAParser(req.headers['user-agent']);
|
|
||||||
|
|
||||||
return {
|
|
||||||
clientIp: req.clientIp,
|
|
||||||
isSecure: req.secure,
|
|
||||||
deviceType: userAgent.browser.name || userAgent.device.type || req.headers.devicemodel || '',
|
|
||||||
deviceOS: userAgent.os.name || req.headers.devicetype || '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
import { IMMICH_API_KEY_NAME } from '@app/domain';
|
|
||||||
import { applyDecorators, SetMetadata } from '@nestjs/common';
|
|
||||||
import { ApiBearerAuth, ApiCookieAuth, ApiQuery, ApiSecurity } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
interface AuthenticatedOptions {
|
|
||||||
admin?: boolean;
|
|
||||||
isShared?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum Metadata {
|
|
||||||
AUTH_ROUTE = 'auth_route',
|
|
||||||
ADMIN_ROUTE = 'admin_route',
|
|
||||||
SHARED_ROUTE = 'shared_route',
|
|
||||||
PUBLIC_SECURITY = 'public_security',
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminDecorator = SetMetadata(Metadata.ADMIN_ROUTE, true);
|
|
||||||
|
|
||||||
const sharedLinkDecorators = [
|
|
||||||
SetMetadata(Metadata.SHARED_ROUTE, true),
|
|
||||||
ApiQuery({ name: 'key', type: String, required: false }),
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Authenticated = (options: AuthenticatedOptions = {}) => {
|
|
||||||
const decorators: MethodDecorator[] = [
|
|
||||||
ApiBearerAuth(),
|
|
||||||
ApiCookieAuth(),
|
|
||||||
ApiSecurity(IMMICH_API_KEY_NAME),
|
|
||||||
SetMetadata(Metadata.AUTH_ROUTE, true),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (options.admin) {
|
|
||||||
decorators.push(adminDecorator);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.isShared) {
|
|
||||||
decorators.push(...sharedLinkDecorators);
|
|
||||||
}
|
|
||||||
|
|
||||||
return applyDecorators(...decorators);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PublicRoute = () =>
|
|
||||||
applyDecorators(SetMetadata(Metadata.AUTH_ROUTE, false), ApiSecurity(Metadata.PUBLIC_SECURITY));
|
|
||||||
export const SharedLinkRoute = () => applyDecorators(...sharedLinkDecorators);
|
|
||||||
export const AdminRoute = () => adminDecorator;
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { applyDecorators, UsePipes, ValidationPipe } from '@nestjs/common';
|
|
||||||
|
|
||||||
export function UseValidation() {
|
|
||||||
return applyDecorators(
|
|
||||||
UsePipes(
|
|
||||||
new ValidationPipe({
|
|
||||||
transform: true,
|
|
||||||
whitelist: true,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import { applyDecorators } from '@nestjs/common';
|
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { IsArray, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
|
||||||
|
|
||||||
export type Options = {
|
|
||||||
optional?: boolean;
|
|
||||||
each?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ValidateUUID({ optional, each }: Options = { optional: false, each: false }) {
|
|
||||||
return applyDecorators(
|
|
||||||
IsUUID('4', { each }),
|
|
||||||
ApiProperty({ format: 'uuid' }),
|
|
||||||
optional ? IsOptional() : IsNotEmpty(),
|
|
||||||
each ? IsArray() : IsString(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
import { AuthService } from '@app/domain';
|
|
||||||
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { AuthRequest } from '../decorators/auth-user.decorator';
|
|
||||||
import { Metadata } from '../decorators/authenticated.decorator';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AuthGuard implements CanActivate {
|
|
||||||
private logger = new Logger(AuthGuard.name);
|
|
||||||
|
|
||||||
constructor(private reflector: Reflector, private authService: AuthService) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const targets = [context.getHandler(), context.getClass()];
|
|
||||||
|
|
||||||
const isAuthRoute = this.reflector.getAllAndOverride(Metadata.AUTH_ROUTE, targets);
|
|
||||||
const isAdminRoute = this.reflector.getAllAndOverride(Metadata.ADMIN_ROUTE, targets);
|
|
||||||
const isSharedRoute = this.reflector.getAllAndOverride(Metadata.SHARED_ROUTE, targets);
|
|
||||||
|
|
||||||
if (!isAuthRoute) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const req = context.switchToHttp().getRequest<AuthRequest>();
|
|
||||||
|
|
||||||
const authDto = await this.authService.validate(req.headers, req.query as Record<string, string>);
|
|
||||||
if (!authDto) {
|
|
||||||
this.logger.warn(`Denied access to authenticated route: ${req.path}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authDto.isPublicUser && !isSharedRoute) {
|
|
||||||
this.logger.warn(`Denied access to non-shared route: ${req.path}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAdminRoute && !authDto.isAdmin) {
|
|
||||||
this.logger.warn(`Denied access to admin only route: ${req.path}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
req.user = authDto;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
export function patchFormData(latin1: string) {
|
|
||||||
return Buffer.from(latin1, 'latin1').toString('utf8');
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import sanitize from 'sanitize-filename';
|
|
||||||
|
|
||||||
interface IValue {
|
|
||||||
value?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const toBoolean = ({ value }: IValue) => {
|
|
||||||
if (value == 'true') {
|
|
||||||
return true;
|
|
||||||
} else if (value == 'false') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const toEmail = ({ value }: IValue) => value?.toLowerCase();
|
|
||||||
|
|
||||||
export const toSanitized = ({ value }: IValue) => sanitize((value || '').replace(/\./g, ''));
|
|
||||||
Loading…
Reference in New Issue