1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 07:01:59 +00:00

refactor(server): cookies (#8920)

This commit is contained in:
Jason Rasmussen 2024-04-19 11:19:23 -04:00 committed by GitHub
parent eaf9e5e477
commit 74c921148b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 158 additions and 155 deletions

View file

@ -112,9 +112,29 @@ describe('/auth/*', () => {
const cookies = headers['set-cookie']; const cookies = headers['set-cookie'];
expect(cookies).toHaveLength(3); expect(cookies).toHaveLength(3);
expect(cookies[0]).toEqual(`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`); expect(cookies[0].split(';').map((item) => item.trim())).toEqual([
expect(cookies[1]).toEqual('immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;'); `immich_access_token=${token}`,
expect(cookies[2]).toEqual('immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;'); 'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[1].split(';').map((item) => item.trim())).toEqual([
'immich_auth_type=password',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[2].split(';').map((item) => item.trim())).toEqual([
'immich_is_authenticated=true',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'SameSite=Lax',
]);
}); });
}); });

View file

@ -26,12 +26,7 @@ export const geodataCities500Path = join(GEODATA_ROOT_PATH, citiesFile);
export const MOBILE_REDIRECT = 'app.immich:/'; export const MOBILE_REDIRECT = 'app.immich:/';
export const LOGIN_URL = '/auth/login?autoLaunch=0'; export const LOGIN_URL = '/auth/login?autoLaunch=0';
export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
export const IMMICH_IS_AUTHENTICATED = 'immich_is_authenticated';
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
export const IMMICH_API_KEY_NAME = 'api_key';
export const IMMICH_API_KEY_HEADER = 'x-api-key';
export const IMMICH_SHARED_LINK_ACCESS_COOKIE = 'immich_shared_link_token';
export enum AuthType { export enum AuthType {
PASSWORD = 'password', PASSWORD = 'password',
OAUTH = 'oauth', OAUTH = 'oauth',

View file

@ -1,10 +1,11 @@
import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common'; import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE, IMMICH_IS_AUTHENTICATED } from 'src/constants'; import { AuthType } from 'src/constants';
import { import {
AuthDto, AuthDto,
ChangePasswordDto, ChangePasswordDto,
ImmichCookie,
LoginCredentialDto, LoginCredentialDto,
LoginResponseDto, LoginResponseDto,
LogoutResponseDto, LogoutResponseDto,
@ -14,6 +15,7 @@ import {
import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard'; import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service'; import { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie, respondWithoutCookie } from 'src/utils/response';
@ApiTags('Authentication') @ApiTags('Authentication')
@Controller('auth') @Controller('auth')
@ -28,9 +30,15 @@ export class AuthController {
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails, @GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> { ): Promise<LoginResponseDto> {
const { response, cookie } = await this.service.login(loginCredential, loginDetails); const body = await this.service.login(loginCredential, loginDetails);
res.header('Set-Cookie', cookie); return respondWithCookie(res, body, {
return response; isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.ACCESS_TOKEN, value: body.accessToken },
{ key: ImmichCookie.AUTH_TYPE, value: AuthType.PASSWORD },
{ key: ImmichCookie.IS_AUTHENTICATED, value: 'true' },
],
});
} }
@PublicRoute() @PublicRoute()
@ -53,15 +61,18 @@ export class AuthController {
@Post('logout') @Post('logout')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
logout( async logout(
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
): Promise<LogoutResponseDto> { ): Promise<LogoutResponseDto> {
res.clearCookie(IMMICH_ACCESS_COOKIE); const authType = (request.cookies || {})[ImmichCookie.AUTH_TYPE];
res.clearCookie(IMMICH_AUTH_TYPE_COOKIE);
res.clearCookie(IMMICH_IS_AUTHENTICATED);
return this.service.logout(auth, (request.cookies || {})[IMMICH_AUTH_TYPE_COOKIE]); const body = await this.service.logout(auth, authType);
return respondWithoutCookie(res, body, [
ImmichCookie.ACCESS_TOKEN,
ImmichCookie.AUTH_TYPE,
ImmichCookie.IS_AUTHENTICATED,
]);
} }
} }

View file

@ -1,8 +1,10 @@
import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { AuthType } from 'src/constants';
import { import {
AuthDto, AuthDto,
ImmichCookie,
LoginResponseDto, LoginResponseDto,
OAuthAuthorizeResponseDto, OAuthAuthorizeResponseDto,
OAuthCallbackDto, OAuthCallbackDto,
@ -11,6 +13,7 @@ import {
import { UserResponseDto } from 'src/dtos/user.dto'; import { UserResponseDto } from 'src/dtos/user.dto';
import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard'; import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service'; import { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie } from 'src/utils/response';
@ApiTags('OAuth') @ApiTags('OAuth')
@Controller('oauth') @Controller('oauth')
@ -41,9 +44,15 @@ export class OAuthController {
@Body() dto: OAuthCallbackDto, @Body() dto: OAuthCallbackDto,
@GetLoginDetails() loginDetails: LoginDetails, @GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> { ): Promise<LoginResponseDto> {
const { response, cookie } = await this.service.callback(dto, loginDetails); const body = await this.service.callback(dto, loginDetails);
res.header('Set-Cookie', cookie); return respondWithCookie(res, body, {
return response; isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.ACCESS_TOKEN, value: body.accessToken },
{ key: ImmichCookie.AUTH_TYPE, value: AuthType.OAUTH },
{ key: ImmichCookie.IS_AUTHENTICATED, value: 'true' },
],
});
} }
@Post('link') @Post('link')

View file

@ -1,18 +1,19 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, Res } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { IMMICH_SHARED_LINK_ACCESS_COOKIE } from 'src/constants';
import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto, ImmichCookie } from 'src/dtos/auth.dto';
import { import {
SharedLinkCreateDto, SharedLinkCreateDto,
SharedLinkEditDto, SharedLinkEditDto,
SharedLinkPasswordDto, SharedLinkPasswordDto,
SharedLinkResponseDto, SharedLinkResponseDto,
} from 'src/dtos/shared-link.dto'; } from 'src/dtos/shared-link.dto';
import { Auth, Authenticated, SharedLinkRoute } from 'src/middleware/auth.guard'; import { Auth, Authenticated, GetLoginDetails, SharedLinkRoute } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { SharedLinkService } from 'src/services/shared-link.service'; import { SharedLinkService } from 'src/services/shared-link.service';
import { respondWithCookie } from 'src/utils/response';
import { UUIDParamDto } from 'src/validation'; import { UUIDParamDto } from 'src/validation';
@ApiTags('Shared Link') @ApiTags('Shared Link')
@ -33,20 +34,17 @@ export class SharedLinkController {
@Query() dto: SharedLinkPasswordDto, @Query() dto: SharedLinkPasswordDto,
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<SharedLinkResponseDto> { ): Promise<SharedLinkResponseDto> {
const sharedLinkToken = request.cookies?.[IMMICH_SHARED_LINK_ACCESS_COOKIE]; const sharedLinkToken = request.cookies?.[ImmichCookie.SHARED_LINK_TOKEN];
if (sharedLinkToken) { if (sharedLinkToken) {
dto.token = sharedLinkToken; dto.token = sharedLinkToken;
} }
const response = await this.service.getMine(auth, dto); const body = await this.service.getMine(auth, dto);
if (response.token) { return respondWithCookie(res, body, {
res.cookie(IMMICH_SHARED_LINK_ACCESS_COOKIE, response.token, { isSecure: loginDetails.isSecure,
expires: new Date(Date.now() + 1000 * 60 * 60 * 24), values: body.token ? [{ key: ImmichCookie.SHARED_LINK_TOKEN, value: body.token }] : [],
httpOnly: true, });
sameSite: 'lax',
});
}
return response;
} }
@Get(':id') @Get(':id')

View file

@ -6,6 +6,25 @@ import { SessionEntity } from 'src/entities/session.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
export enum ImmichCookie {
ACCESS_TOKEN = 'immich_access_token',
AUTH_TYPE = 'immich_auth_type',
IS_AUTHENTICATED = 'immich_is_authenticated',
SHARED_LINK_TOKEN = 'immich_shared_link_token',
}
export enum ImmichHeader {
API_KEY = 'x-api-key',
USER_TOKEN = 'x-immich-user-token',
SESSION_TOKEN = 'x-immich-session-token',
SHARED_LINK_TOKEN = 'x-immich-share-key',
}
export type CookieResponse = {
isSecure: boolean;
values: Array<{ key: ImmichCookie; value: string }>;
};
export class AuthDto { export class AuthDto {
user!: UserEntity; user!: UserEntity;
@ -39,7 +58,7 @@ export class LoginResponseDto {
export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto { export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto {
return { return {
accessToken: accessToken, accessToken,
userId: entity.id, userId: entity.id,
userEmail: entity.email, userEmail: entity.email,
name: entity.name, name: entity.name,

View file

@ -10,7 +10,6 @@ import {
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger'; import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger';
import { Request } from 'express'; import { Request } from 'express';
import { IMMICH_API_KEY_NAME } from 'src/constants';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AuthService, LoginDetails } from 'src/services/auth.service'; import { AuthService, LoginDetails } from 'src/services/auth.service';
@ -21,6 +20,7 @@ export enum Metadata {
ADMIN_ROUTE = 'admin_route', ADMIN_ROUTE = 'admin_route',
SHARED_ROUTE = 'shared_route', SHARED_ROUTE = 'shared_route',
PUBLIC_SECURITY = 'public_security', PUBLIC_SECURITY = 'public_security',
API_KEY_SECURITY = 'api_key',
} }
export interface AuthenticatedOptions { export interface AuthenticatedOptions {
@ -32,7 +32,7 @@ export const Authenticated = (options: AuthenticatedOptions = {}) => {
const decorators: MethodDecorator[] = [ const decorators: MethodDecorator[] = [
ApiBearerAuth(), ApiBearerAuth(),
ApiCookieAuth(), ApiCookieAuth(),
ApiSecurity(IMMICH_API_KEY_NAME), ApiSecurity(Metadata.API_KEY_SECURITY),
SetMetadata(Metadata.AUTH_ROUTE, true), SetMetadata(Metadata.AUTH_ROUTE, true),
]; ];

View file

@ -143,20 +143,6 @@ describe('AuthService', () => {
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password); await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1); expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
}); });
it('should generate the cookie headers (insecure)', async () => {
userMock.getByEmail.mockResolvedValue(userStub.user1);
sessionMock.create.mockResolvedValue(sessionStub.valid);
await expect(
sut.login(fixtures.login, {
clientIp: '127.0.0.1',
isSecure: false,
deviceOS: '',
deviceType: '',
}),
).resolves.toEqual(loginResponseStub.user1insecure);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
});
}); });
describe('changePassword', () => { describe('changePassword', () => {

View file

@ -10,23 +10,16 @@ import cookieParser from 'cookie';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http'; import { IncomingHttpHeaders } from 'node:http';
import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client'; import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client';
import { import { AuthType, LOGIN_URL, MOBILE_REDIRECT } from 'src/constants';
AuthType,
IMMICH_ACCESS_COOKIE,
IMMICH_API_KEY_HEADER,
IMMICH_AUTH_TYPE_COOKIE,
IMMICH_IS_AUTHENTICATED,
LOGIN_URL,
MOBILE_REDIRECT,
} from 'src/constants';
import { AccessCore } from 'src/cores/access.core'; import { AccessCore } from 'src/cores/access.core';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { UserCore } from 'src/cores/user.core'; import { UserCore } from 'src/cores/user.core';
import { import {
AuthDto, AuthDto,
ChangePasswordDto, ChangePasswordDto,
ImmichCookie,
ImmichHeader,
LoginCredentialDto, LoginCredentialDto,
LoginResponseDto,
LogoutResponseDto, LogoutResponseDto,
OAuthAuthorizeResponseDto, OAuthAuthorizeResponseDto,
OAuthCallbackDto, OAuthCallbackDto,
@ -55,11 +48,6 @@ export interface LoginDetails {
deviceOS: string; deviceOS: string;
} }
interface LoginResponse {
response: LoginResponseDto;
cookie: string[];
}
interface OAuthProfile extends UserinfoResponse { interface OAuthProfile extends UserinfoResponse {
email: string; email: string;
} }
@ -95,7 +83,7 @@ export class AuthService {
custom.setHttpOptionsDefaults({ timeout: 30_000 }); custom.setHttpOptionsDefaults({ timeout: 30_000 });
} }
async login(dto: LoginCredentialDto, details: LoginDetails): Promise<LoginResponse> { async login(dto: LoginCredentialDto, details: LoginDetails) {
const config = await this.configCore.getConfig(); const config = await this.configCore.getConfig();
if (!config.passwordLogin.enabled) { if (!config.passwordLogin.enabled) {
throw new UnauthorizedException('Password login has been disabled'); throw new UnauthorizedException('Password login has been disabled');
@ -114,7 +102,7 @@ export class AuthService {
throw new UnauthorizedException('Incorrect email or password'); throw new UnauthorizedException('Incorrect email or password');
} }
return this.createLoginResponse(user, AuthType.PASSWORD, details); return this.createLoginResponse(user, details);
} }
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> { async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
@ -161,13 +149,13 @@ export class AuthService {
} }
async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> { async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> {
const shareKey = (headers['x-immich-share-key'] || params.key) as string; const shareKey = (headers[ImmichHeader.SHARED_LINK_TOKEN] || params.key) as string;
const session = (headers['x-immich-user-token'] || const session = (headers[ImmichHeader.USER_TOKEN] ||
headers['x-immich-session-token'] || headers[ImmichHeader.SESSION_TOKEN] ||
params.sessionKey || params.sessionKey ||
this.getBearerToken(headers) || this.getBearerToken(headers) ||
this.getCookieToken(headers)) as string; this.getCookieToken(headers)) as string;
const apiKey = (headers[IMMICH_API_KEY_HEADER] || params.apiKey) as string; const apiKey = (headers[ImmichHeader.API_KEY] || params.apiKey) as string;
if (shareKey) { if (shareKey) {
return this.validateSharedLink(shareKey); return this.validateSharedLink(shareKey);
@ -204,10 +192,7 @@ export class AuthService {
return { url }; return { url };
} }
async callback( async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) {
dto: OAuthCallbackDto,
loginDetails: LoginDetails,
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
const config = await this.configCore.getConfig(); const config = await this.configCore.getConfig();
const profile = await this.getOAuthProfile(config, dto.url); const profile = await this.getOAuthProfile(config, dto.url);
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
@ -256,7 +241,7 @@ export class AuthService {
}); });
} }
return this.createLoginResponse(user, AuthType.OAUTH, loginDetails); return this.createLoginResponse(user, loginDetails);
} }
async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserResponseDto> { async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
@ -353,7 +338,7 @@ export class AuthService {
private getCookieToken(headers: IncomingHttpHeaders): string | null { private getCookieToken(headers: IncomingHttpHeaders): string | null {
const cookies = cookieParser.parse(headers.cookie || ''); const cookies = cookieParser.parse(headers.cookie || '');
return cookies[IMMICH_ACCESS_COOKIE] || null; return cookies[ImmichCookie.ACCESS_TOKEN] || null;
} }
async validateSharedLink(key: string | string[]): Promise<AuthDto> { async validateSharedLink(key: string | string[]): Promise<AuthDto> {
@ -405,7 +390,7 @@ export class AuthService {
throw new UnauthorizedException('Invalid user token'); throw new UnauthorizedException('Invalid user token');
} }
private async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) { private async createLoginResponse(user: UserEntity, loginDetails: LoginDetails) {
const key = this.cryptoRepository.newPassword(32); const key = this.cryptoRepository.newPassword(32);
const token = this.cryptoRepository.hashSha256(key); const token = this.cryptoRepository.hashSha256(key);
@ -416,28 +401,7 @@ export class AuthService {
deviceType: loginDetails.deviceType, deviceType: loginDetails.deviceType,
}); });
const response = mapLoginResponse(user, key); return mapLoginResponse(user, key);
const cookie = this.getCookies(response, authType, loginDetails);
return { response, cookie };
}
private getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) {
const maxAge = 400 * 24 * 3600; // 400 days
let authTypeCookie = '';
let accessTokenCookie = '';
let isAuthenticatedCookie = '';
if (isSecure) {
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
isAuthenticatedCookie = `${IMMICH_IS_AUTHENTICATED}=true; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
} else {
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
isAuthenticatedCookie = `${IMMICH_IS_AUTHENTICATED}=true; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
}
return [accessTokenCookie, authTypeCookie, isAuthenticatedCookie];
} }
private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T { private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T {

View file

@ -10,13 +10,8 @@ import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.inte
import _ from 'lodash'; import _ from 'lodash';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { import { CLIP_MODEL_INFO, serverVersion } from 'src/constants';
CLIP_MODEL_INFO, import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto';
IMMICH_ACCESS_COOKIE,
IMMICH_API_KEY_HEADER,
IMMICH_API_KEY_NAME,
serverVersion,
} from 'src/constants';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { Metadata } from 'src/middleware/auth.guard'; import { Metadata } from 'src/middleware/auth.guard';
@ -143,14 +138,14 @@ export const useSwagger = (app: INestApplication, isDevelopment: boolean) => {
scheme: 'Bearer', scheme: 'Bearer',
in: 'header', in: 'header',
}) })
.addCookieAuth(IMMICH_ACCESS_COOKIE) .addCookieAuth(ImmichCookie.ACCESS_TOKEN)
.addApiKey( .addApiKey(
{ {
type: 'apiKey', type: 'apiKey',
in: 'header', in: 'header',
name: IMMICH_API_KEY_HEADER, name: ImmichHeader.API_KEY,
}, },
IMMICH_API_KEY_NAME, Metadata.API_KEY_SECURITY,
) )
.addServer('/api') .addServer('/api')
.build(); .build();

View file

@ -0,0 +1,36 @@
import { CookieOptions, Response } from 'express';
import { Duration } from 'luxon';
import { CookieResponse, ImmichCookie } from 'src/dtos/auth.dto';
export const respondWithCookie = <T>(res: Response, body: T, { isSecure, values }: CookieResponse) => {
const defaults: CookieOptions = {
path: '/',
sameSite: 'lax',
httpOnly: true,
secure: isSecure,
maxAge: Duration.fromObject({ days: 400 }).toMillis(),
};
const cookieOptions: Record<ImmichCookie, CookieOptions> = {
[ImmichCookie.AUTH_TYPE]: defaults,
[ImmichCookie.ACCESS_TOKEN]: defaults,
// no httpOnly so that the client can know the auth state
[ImmichCookie.IS_AUTHENTICATED]: { ...defaults, httpOnly: false },
[ImmichCookie.SHARED_LINK_TOKEN]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() },
};
for (const { key, value } of values) {
const options = cookieOptions[key];
res.cookie(key, value, options);
}
return body;
};
export const respondWithoutCookie = <T>(res: Response, body: T, cookies: ImmichCookie[]) => {
for (const cookie of cookies) {
res.clearCookie(cookie);
}
return body;
};

View file

@ -129,51 +129,21 @@ export const loginResponseStub = {
}, },
}, },
user1oauth: { user1oauth: {
response: { accessToken: 'cmFuZG9tLWJ5dGVz',
accessToken: 'cmFuZG9tLWJ5dGVz', userId: 'user-id',
userId: 'user-id', userEmail: 'immich@test.com',
userEmail: 'immich@test.com', name: 'immich_name',
name: 'immich_name', profileImagePath: '',
profileImagePath: '', isAdmin: false,
isAdmin: false, shouldChangePassword: false,
shouldChangePassword: false,
},
cookie: [
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
'immich_auth_type=oauth; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
'immich_is_authenticated=true; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
],
}, },
user1password: { user1password: {
response: { accessToken: 'cmFuZG9tLWJ5dGVz',
accessToken: 'cmFuZG9tLWJ5dGVz', userId: 'user-id',
userId: 'user-id', userEmail: 'immich@test.com',
userEmail: 'immich@test.com', name: 'immich_name',
name: 'immich_name', profileImagePath: '',
profileImagePath: '', isAdmin: false,
isAdmin: false, shouldChangePassword: false,
shouldChangePassword: false,
},
cookie: [
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
'immich_auth_type=password; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
'immich_is_authenticated=true; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
],
},
user1insecure: {
response: {
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: 'user-id',
userEmail: 'immich@test.com',
name: 'immich_name',
profileImagePath: '',
isAdmin: false,
shouldChangePassword: false,
},
cookie: [
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;',
'immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;',
'immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;',
],
}, },
}; };