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:
parent
eaf9e5e477
commit
74c921148b
12 changed files with 158 additions and 155 deletions
|
@ -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',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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();
|
||||||
|
|
36
server/src/utils/response.ts
Normal file
36
server/src/utils/response.ts
Normal 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;
|
||||||
|
};
|
58
server/test/fixtures/auth.stub.ts
vendored
58
server/test/fixtures/auth.stub.ts
vendored
|
@ -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;',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue