immich/server/src/middleware/auth.guard.ts
bo0tzz a316ba35ca
fix: shared check for server setup availability (#30311)
* fix: shared check for server setup availability

* chore: add medium test

* feat: require @Authenticated decorator everywhere

* fix: lints
2026-07-30 15:12:25 -04:00

124 lines
4.2 KiB
TypeScript

import {
CanActivate,
ExecutionContext,
Injectable,
SetMetadata,
applyDecorators,
createParamDecorator,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ApiBearerAuth, ApiCookieAuth, ApiExtension, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger';
import { Request } from 'express';
import { AuthDto } from 'src/dtos/auth.dto';
import { ApiCustomExtension, ImmichQuery, MetadataKey, Permission } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { getUserAgentDetails } from 'src/utils/request';
type AdminRoute = { admin?: true };
type SharedLinkRoute = { sharedLink?: true };
type AuthorizedRoute = { permission?: Permission | false; public?: never; setup?: never } & (
AdminRoute | SharedLinkRoute
);
type PublicRoute = { public: true; setup?: true; permission?: never; admin?: never; sharedLink?: never };
export type AuthenticatedOptions = AuthorizedRoute | PublicRoute;
type ReflectorTarget = Parameters<Reflector['get']>[1];
/** Resolves the `@Authenticated()` options of a route handler, with the defaults applied. */
export const getAuthenticatedOptions = (reflector: Reflector, target: ReflectorTarget) => {
const options = reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AuthRoute, [target]);
return options && { sharedLink: false, admin: false, public: false, setup: false, ...options };
};
export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => {
const decorators: MethodDecorator[] = [SetMetadata(MetadataKey.AuthRoute, options)];
if (!options.public) {
decorators.push(ApiBearerAuth(), ApiCookieAuth(), ApiSecurity(MetadataKey.ApiKeySecurity));
}
if ((options as AdminRoute).admin) {
decorators.push(ApiExtension(ApiCustomExtension.AdminOnly, true));
}
if (options?.permission) {
decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission));
}
if ((options as SharedLinkRoute)?.sharedLink) {
decorators.push(
ApiQuery({ name: ImmichQuery.SharedLinkKey, type: String, required: false }),
ApiQuery({ name: ImmichQuery.SharedLinkSlug, type: String, required: false }),
);
}
return applyDecorators(...decorators);
};
export const Auth = createParamDecorator((data, context: ExecutionContext): AuthDto => {
return context.switchToHttp().getRequest<AuthenticatedRequest>().user;
});
export const FileResponse = () =>
ApiOkResponse({
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
});
export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => {
const request = context.switchToHttp().getRequest<Request>();
const { deviceType, deviceOS, appVersion } = getUserAgentDetails(request.headers);
return {
clientIp: request.ip ?? '',
isSecure: request.secure,
deviceType,
deviceOS,
appVersion,
};
});
export interface AuthRequest extends Request {
user?: AuthDto;
}
export interface AuthenticatedRequest extends Request {
user: AuthDto;
}
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private logger: LoggingRepository,
private reflector: Reflector,
private authService: AuthService,
) {
this.logger.setContext(AuthGuard.name);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const options = getAuthenticatedOptions(this.reflector, context.getHandler());
if (!options) {
throw new Error(`Route ${context.getHandler().name} does not declare @Authenticated()`);
}
if (options.setup) {
await this.authService.requireSetupAvailable();
}
if (options.public) {
return true;
}
const { admin: adminRoute, sharedLink: sharedLinkRoute, permission } = options;
const request = context.switchToHttp().getRequest<AuthRequest>();
request.user = await this.authService.authenticate({
headers: request.headers,
queryParams: request.query as Record<string, string>,
metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path },
});
return true;
}
}