refactor(server): auth service (#1383)

* refactor: auth

* chore: tests

* Remove await on non-async method

* refactor: constants

* chore: remove extra async

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen
2023-01-23 23:13:42 -05:00
committed by GitHub
parent 443d08381a
commit eade36ee82
64 changed files with 1830 additions and 1901 deletions

View File

@@ -1,72 +0,0 @@
import { Body, Controller, Ip, Post, Req, Res, ValidationPipe } from '@nestjs/common';
import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AuthType, IMMICH_AUTH_TYPE_COOKIE } from '../../constants/jwt.constant';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { UserResponseDto } from '@app/domain';
import { AuthService } from './auth.service';
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginCredentialDto } from './dto/login-credential.dto';
import { SignUpDto } from './dto/sign-up.dto';
import { AdminSignupResponseDto } from './response-dto/admin-signup-response.dto';
import { LoginResponseDto } from './response-dto/login-response.dto';
import { LogoutResponseDto } from './response-dto/logout-response.dto';
import { ValidateAccessTokenResponseDto } from './response-dto/validate-asset-token-response.dto,';
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService, private readonly immichJwtService: ImmichJwtService) {}
@Post('/login')
async login(
@Body(new ValidationPipe({ transform: true })) loginCredential: LoginCredentialDto,
@Ip() clientIp: string,
@Res({ passthrough: true }) response: Response,
@Req() request: Request,
): Promise<LoginResponseDto> {
const loginResponse = await this.authService.login(loginCredential, clientIp);
response.setHeader(
'Set-Cookie',
this.immichJwtService.getCookies(loginResponse, AuthType.PASSWORD, request.secure),
);
return loginResponse;
}
@Post('/admin-sign-up')
@ApiBadRequestResponse({ description: 'The server already has an admin' })
async adminSignUp(
@Body(new ValidationPipe({ transform: true })) signUpCredential: SignUpDto,
): Promise<AdminSignupResponseDto> {
return await this.authService.adminSignUp(signUpCredential);
}
@Authenticated()
@ApiBearerAuth()
@Post('/validateToken')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async validateAccessToken(@GetAuthUser() authUser: AuthUserDto): Promise<ValidateAccessTokenResponseDto> {
return new ValidateAccessTokenResponseDto(true);
}
@Authenticated()
@ApiBearerAuth()
@Post('change-password')
async changePassword(@GetAuthUser() authUser: AuthUserDto, @Body() dto: ChangePasswordDto): Promise<UserResponseDto> {
return this.authService.changePassword(authUser, dto);
}
@Post('/logout')
async logout(@Req() req: Request, @Res({ passthrough: true }) response: Response): Promise<LogoutResponseDto> {
const authType: AuthType = req.cookies[IMMICH_AUTH_TYPE_COOKIE];
const cookies = this.immichJwtService.getCookieNames();
for (const cookie of cookies) {
response.clearCookie(cookie);
}
return this.authService.logout(authType);
}
}

View File

@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { OAuthModule } from '../oauth/oauth.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
imports: [ImmichJwtModule, OAuthModule],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}

View File

@@ -1,244 +0,0 @@
import { UserEntity } from '@app/infra';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { SystemConfig } from '@app/infra';
import { SystemConfigService } from '@app/domain';
import { AuthType } from '../../constants/jwt.constant';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { OAuthService } from '../oauth/oauth.service';
import { IUserRepository } from '@app/domain';
import { AuthService } from './auth.service';
import { SignUpDto } from './dto/sign-up.dto';
import { LoginResponseDto } from './response-dto/login-response.dto';
const fixtures = {
login: {
email: 'test@immich.com',
password: 'password',
},
};
const config = {
enabled: {
passwordLogin: {
enabled: true,
},
} as SystemConfig,
disabled: {
passwordLogin: {
enabled: false,
},
} as SystemConfig,
};
const CLIENT_IP = '127.0.0.1';
jest.mock('bcrypt');
jest.mock('@nestjs/common', () => ({
...jest.requireActual('@nestjs/common'),
Logger: jest.fn().mockReturnValue({
verbose: jest.fn(),
debug: jest.fn(),
log: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
describe('AuthService', () => {
let sut: AuthService;
let userRepositoryMock: jest.Mocked<IUserRepository>;
let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
let immichConfigServiceMock: jest.Mocked<SystemConfigService>;
let oauthServiceMock: jest.Mocked<OAuthService>;
let compare: jest.Mock;
afterEach(() => {
jest.resetModules();
});
beforeEach(async () => {
jest.mock('bcrypt');
compare = bcrypt.compare as jest.Mock;
userRepositoryMock = {
get: jest.fn(),
getAdmin: jest.fn(),
getByOAuthId: jest.fn(),
getByEmail: jest.fn(),
getList: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
};
immichJwtServiceMock = {
getCookieNames: jest.fn(),
getCookies: jest.fn(),
createLoginResponse: jest.fn(),
validateToken: jest.fn(),
extractJwtFromHeader: jest.fn(),
extractJwtFromCookie: jest.fn(),
} as unknown as jest.Mocked<ImmichJwtService>;
oauthServiceMock = {
getLogoutEndpoint: jest.fn(),
} as unknown as jest.Mocked<OAuthService>;
immichConfigServiceMock = {
config$: { subscribe: jest.fn() },
} as unknown as jest.Mocked<SystemConfigService>;
sut = new AuthService(
oauthServiceMock,
immichJwtServiceMock,
userRepositoryMock,
immichConfigServiceMock,
config.enabled,
);
});
it('should be defined', () => {
expect(sut).toBeDefined();
});
it('should subscribe to config changes', async () => {
expect(immichConfigServiceMock.config$.subscribe).toHaveBeenCalled();
});
describe('login', () => {
it('should throw an error if password login is disabled', async () => {
sut = new AuthService(
oauthServiceMock,
immichJwtServiceMock,
userRepositoryMock,
immichConfigServiceMock,
config.disabled,
);
await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should check the user exists', async () => {
userRepositoryMock.getByEmail.mockResolvedValue(null);
await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
});
it('should check the user has a password', async () => {
userRepositoryMock.getByEmail.mockResolvedValue({} as UserEntity);
await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
});
it('should successfully log the user in', async () => {
userRepositoryMock.getByEmail.mockResolvedValue({ password: 'password' } as UserEntity);
compare.mockResolvedValue(true);
const dto = { firstName: 'test', lastName: 'immich' } as LoginResponseDto;
immichJwtServiceMock.createLoginResponse.mockResolvedValue(dto);
await expect(sut.login(fixtures.login, CLIENT_IP)).resolves.toEqual(dto);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
expect(immichJwtServiceMock.createLoginResponse).toHaveBeenCalledTimes(1);
});
});
describe('changePassword', () => {
it('should change the password', async () => {
const authUser = { email: 'test@imimch.com' } as UserEntity;
const dto = { password: 'old-password', newPassword: 'new-password' };
compare.mockResolvedValue(true);
userRepositoryMock.getByEmail.mockResolvedValue({
email: 'test@immich.com',
password: 'hash-password',
} as UserEntity);
await sut.changePassword(authUser, dto);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledWith(authUser.email, true);
expect(compare).toHaveBeenCalledWith('old-password', 'hash-password');
});
it('should throw when auth user email is not found', async () => {
const authUser = { email: 'test@imimch.com' } as UserEntity;
const dto = { password: 'old-password', newPassword: 'new-password' };
userRepositoryMock.getByEmail.mockResolvedValue(null);
await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should throw when password does not match existing password', async () => {
const authUser = { email: 'test@imimch.com' } as UserEntity;
const dto = { password: 'old-password', newPassword: 'new-password' };
compare.mockResolvedValue(false);
userRepositoryMock.getByEmail.mockResolvedValue({
email: 'test@immich.com',
password: 'hash-password',
} as UserEntity);
await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(BadRequestException);
});
it('should throw when user does not have a password', async () => {
const authUser = { email: 'test@imimch.com' } as UserEntity;
const dto = { password: 'old-password', newPassword: 'new-password' };
compare.mockResolvedValue(false);
userRepositoryMock.getByEmail.mockResolvedValue({
email: 'test@immich.com',
password: '',
} as UserEntity);
await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('logout', () => {
it('should return the end session endpoint', async () => {
oauthServiceMock.getLogoutEndpoint.mockResolvedValue('end-session-endpoint');
await expect(sut.logout(AuthType.OAUTH)).resolves.toEqual({
successful: true,
redirectUri: 'end-session-endpoint',
});
});
it('should return the default redirect', async () => {
await expect(sut.logout(AuthType.PASSWORD)).resolves.toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
expect(oauthServiceMock.getLogoutEndpoint).not.toHaveBeenCalled();
});
});
describe('adminSignUp', () => {
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', firstName: 'immich', lastName: 'admin' };
it('should only allow one admin', async () => {
userRepositoryMock.getAdmin.mockResolvedValue({} as UserEntity);
await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
expect(userRepositoryMock.getAdmin).toHaveBeenCalled();
});
it('should sign up the admin', async () => {
userRepositoryMock.getAdmin.mockResolvedValue(null);
userRepositoryMock.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: 'today' } as UserEntity);
await expect(sut.adminSignUp(dto)).resolves.toEqual({
id: 'admin',
createdAt: 'today',
email: 'test@immich.com',
firstName: 'immich',
lastName: 'admin',
});
expect(userRepositoryMock.getAdmin).toHaveBeenCalled();
expect(userRepositoryMock.create).toHaveBeenCalled();
});
});
});

View File

@@ -1,119 +0,0 @@
import {
BadRequestException,
Inject,
Injectable,
InternalServerErrorException,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { UserEntity } from '@app/infra';
import { AuthType } from '../../constants/jwt.constant';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { IUserRepository } from '@app/domain';
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginCredentialDto } from './dto/login-credential.dto';
import { SignUpDto } from './dto/sign-up.dto';
import { AdminSignupResponseDto, mapAdminSignupResponse } from './response-dto/admin-signup-response.dto';
import { LoginResponseDto } from './response-dto/login-response.dto';
import { LogoutResponseDto } from './response-dto/logout-response.dto';
import { OAuthService } from '../oauth/oauth.service';
import { UserCore } from '@app/domain';
import { SystemConfigService, INITIAL_SYSTEM_CONFIG } from '@app/domain';
import { SystemConfig } from '@app/infra';
@Injectable()
export class AuthService {
private userCore: UserCore;
private logger = new Logger(AuthService.name);
constructor(
private oauthService: OAuthService,
private immichJwtService: ImmichJwtService,
@Inject(IUserRepository) userRepository: IUserRepository,
private configService: SystemConfigService,
@Inject(INITIAL_SYSTEM_CONFIG) private config: SystemConfig,
) {
this.userCore = new UserCore(userRepository);
this.configService.config$.subscribe((config) => (this.config = config));
}
public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> {
if (!this.config.passwordLogin.enabled) {
throw new UnauthorizedException('Password login has been disabled');
}
let user = await this.userCore.getByEmail(loginCredential.email, true);
if (user) {
const isAuthenticated = await this.validatePassword(loginCredential.password, user);
if (!isAuthenticated) {
user = null;
}
}
if (!user) {
this.logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`);
throw new BadRequestException('Incorrect email or password');
}
return this.immichJwtService.createLoginResponse(user);
}
public async logout(authType: AuthType): Promise<LogoutResponseDto> {
if (authType === AuthType.OAUTH) {
const url = await this.oauthService.getLogoutEndpoint();
if (url) {
return { successful: true, redirectUri: url };
}
}
return { successful: true, redirectUri: '/auth/login?autoLaunch=0' };
}
public async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
const { password, newPassword } = dto;
const user = await this.userCore.getByEmail(authUser.email, true);
if (!user) {
throw new UnauthorizedException();
}
const valid = await this.validatePassword(password, user);
if (!valid) {
throw new BadRequestException('Wrong password');
}
return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
}
public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
const adminUser = await this.userCore.getAdmin();
if (adminUser) {
throw new BadRequestException('The server already has an admin');
}
try {
const admin = await this.userCore.createUser({
isAdmin: true,
email: dto.email,
firstName: dto.firstName,
lastName: dto.lastName,
password: dto.password,
});
return mapAdminSignupResponse(admin);
} catch (error) {
this.logger.error(`Unable to register admin user: ${error}`, (error as Error).stack);
throw new InternalServerErrorException('Failed to register new admin user');
}
}
private async validatePassword(inputPassword: string, user: UserEntity): Promise<boolean> {
if (!user || !user.password) {
return false;
}
return await bcrypt.compare(inputPassword, user.password);
}
}

View File

@@ -1,15 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@IsString()
@IsNotEmpty()
@ApiProperty({ example: 'password' })
password!: string;
@IsString()
@IsNotEmpty()
@MinLength(8)
@ApiProperty({ example: 'password' })
newPassword!: string;
}

View File

@@ -1,9 +0,0 @@
export class JwtPayloadDto {
constructor(userId: string, email: string) {
this.userId = userId;
this.email = email;
}
userId: string;
email: string;
}

View File

@@ -1,33 +0,0 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { LoginCredentialDto } from './login-credential.dto';
describe('LoginCredentialDto', () => {
it('should fail without an email', () => {
const dto = plainToInstance(LoginCredentialDto, { password: 'password' });
const errors = validateSync(dto);
expect(errors).toHaveLength(1);
expect(errors[0].property).toEqual('email');
});
it('should fail with an invalid email', () => {
const dto = plainToInstance(LoginCredentialDto, { email: 'invalid.com', password: 'password' });
const errors = validateSync(dto);
expect(errors).toHaveLength(1);
expect(errors[0].property).toEqual('email');
});
it('should make the email all lowercase', () => {
const dto = plainToInstance(LoginCredentialDto, { email: 'TeSt@ImMiCh.com', password: 'password' });
const errors = validateSync(dto);
expect(errors).toHaveLength(0);
expect(dto.email).toEqual('test@immich.com');
});
it('should fail without a password', () => {
const dto = plainToInstance(LoginCredentialDto, { email: 'test@immich.com', password: '' });
const errors = validateSync(dto);
expect(errors).toHaveLength(1);
expect(errors[0].property).toEqual('password');
});
});

View File

@@ -1,15 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class LoginCredentialDto {
@IsEmail()
@ApiProperty({ example: 'testuser@email.com' })
@Transform(({ value }) => value.toLowerCase())
email!: string;
@IsString()
@IsNotEmpty()
@ApiProperty({ example: 'password' })
password!: string;
}

View File

@@ -1,44 +0,0 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { SignUpDto } from './sign-up.dto';
describe('SignUpDto', () => {
it('should require all fields', () => {
const dto = plainToInstance(SignUpDto, {
email: '',
password: '',
firstName: '',
lastName: '',
});
const errors = validateSync(dto);
expect(errors).toHaveLength(4);
expect(errors[0].property).toEqual('email');
expect(errors[1].property).toEqual('password');
expect(errors[2].property).toEqual('firstName');
expect(errors[3].property).toEqual('lastName');
});
it('should require a valid email', () => {
const dto = plainToInstance(SignUpDto, {
email: 'immich.com',
password: 'password',
firstName: 'first name',
lastName: 'last name',
});
const errors = validateSync(dto);
expect(errors).toHaveLength(1);
expect(errors[0].property).toEqual('email');
});
it('should make the email all lowercase', () => {
const dto = plainToInstance(SignUpDto, {
email: 'TeSt@ImMiCh.com',
password: 'password',
firstName: 'first name',
lastName: 'last name',
});
const errors = validateSync(dto);
expect(errors).toHaveLength(0);
expect(dto.email).toEqual('test@immich.com');
});
});

View File

@@ -1,25 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class SignUpDto {
@IsEmail()
@ApiProperty({ example: 'testuser@email.com' })
@Transform(({ value }) => value.toLowerCase())
email!: string;
@IsString()
@IsNotEmpty()
@ApiProperty({ example: 'password' })
password!: string;
@IsString()
@IsNotEmpty()
@ApiProperty({ example: 'Admin' })
firstName!: string;
@IsString()
@IsNotEmpty()
@ApiProperty({ example: 'Doe' })
lastName!: string;
}

View File

@@ -1,19 +0,0 @@
import { UserEntity } from '@app/infra';
export class AdminSignupResponseDto {
id!: string;
email!: string;
firstName!: string;
lastName!: string;
createdAt!: string;
}
export function mapAdminSignupResponse(entity: UserEntity): AdminSignupResponseDto {
return {
id: entity.id,
email: entity.email,
firstName: entity.firstName,
lastName: entity.lastName,
createdAt: entity.createdAt,
};
}

View File

@@ -1,41 +0,0 @@
import { UserEntity } from '@app/infra';
import { ApiResponseProperty } from '@nestjs/swagger';
export class LoginResponseDto {
@ApiResponseProperty()
accessToken!: string;
@ApiResponseProperty()
userId!: string;
@ApiResponseProperty()
userEmail!: string;
@ApiResponseProperty()
firstName!: string;
@ApiResponseProperty()
lastName!: string;
@ApiResponseProperty()
profileImagePath!: string;
@ApiResponseProperty()
isAdmin!: boolean;
@ApiResponseProperty()
shouldChangePassword!: boolean;
}
export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto {
return {
accessToken: accessToken,
userId: entity.id,
userEmail: entity.email,
firstName: entity.firstName,
lastName: entity.lastName,
isAdmin: entity.isAdmin,
profileImagePath: entity.profileImagePath,
shouldChangePassword: entity.shouldChangePassword,
};
}

View File

@@ -1,13 +0,0 @@
import { ApiResponseProperty } from '@nestjs/swagger';
export class LogoutResponseDto {
constructor(successful: boolean) {
this.successful = successful;
}
@ApiResponseProperty()
successful!: boolean;
@ApiResponseProperty()
redirectUri!: string;
}

View File

@@ -1,10 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class ValidateAccessTokenResponseDto {
constructor(authStatus: boolean) {
this.authStatus = authStatus;
}
@ApiProperty({ type: 'boolean' })
authStatus!: boolean;
}

View File

@@ -1,13 +1,13 @@
import { Logger } from '@nestjs/common';
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { AuthService } from '@app/domain';
@WebSocketGateway({ cors: true })
export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect {
private logger = new Logger(CommunicationGateway.name);
constructor(private immichJwtService: ImmichJwtService) {}
constructor(private authService: AuthService) {}
@WebSocketServer() server!: Server;
@@ -20,7 +20,7 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
try {
this.logger.log(`New websocket connection: ${client.id}`);
const user = await this.immichJwtService.validateSocket(client);
const user = await this.authService.validateSocket(client);
if (user) {
client.join(user.id);
} else {

View File

@@ -1,9 +1,7 @@
import { Module } from '@nestjs/common';
import { CommunicationGateway } from './communication.gateway';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
@Module({
imports: [ImmichJwtModule],
providers: [CommunicationGateway],
exports: [CommunicationGateway],
})

View File

@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { JobService } from './job.service';
import { JobController } from './job.controller';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExifEntity } from '@app/infra';
import { TagModule } from '../tag/tag.module';
@@ -9,7 +8,7 @@ import { AssetModule } from '../asset/asset.module';
import { StorageModule } from '@app/storage';
@Module({
imports: [TypeOrmModule.forFeature([ExifEntity]), ImmichJwtModule, TagModule, AssetModule, StorageModule],
imports: [TypeOrmModule.forFeature([ExifEntity]), TagModule, AssetModule, StorageModule],
controllers: [JobController],
providers: [JobService],
})

View File

@@ -1,9 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class OAuthCallbackDto {
@IsNotEmpty()
@IsString()
@ApiProperty()
url!: string;
}

View File

@@ -1,9 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class OAuthConfigDto {
@IsNotEmpty()
@IsString()
@ApiProperty()
redirectUri!: string;
}

View File

@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { OAuthController } from './oauth.controller';
import { OAuthService } from './oauth.service';
@Module({
imports: [ImmichJwtModule],
controllers: [OAuthController],
providers: [OAuthService],
exports: [OAuthService],
})
export class OAuthModule {}

View File

@@ -1,263 +0,0 @@
import { SystemConfig, UserEntity } from '@app/infra';
import { SystemConfigService } from '@app/domain';
import { BadRequestException } from '@nestjs/common';
import { generators, Issuer } from 'openid-client';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
import { OAuthService } from '../oauth/oauth.service';
import { IUserRepository } from '@app/domain';
const email = 'user@immich.com';
const sub = 'my-auth-user-sub';
const config = {
disabled: {
oauth: {
enabled: false,
buttonText: 'OAuth',
issuerUrl: 'http://issuer,',
autoLaunch: false,
},
passwordLogin: { enabled: true },
} as SystemConfig,
enabled: {
oauth: {
enabled: true,
autoRegister: true,
buttonText: 'OAuth',
autoLaunch: false,
},
passwordLogin: { enabled: true },
} as SystemConfig,
noAutoRegister: {
oauth: {
enabled: true,
autoRegister: false,
autoLaunch: false,
},
passwordLogin: { enabled: true },
} as SystemConfig,
override: {
oauth: {
enabled: true,
autoRegister: true,
autoLaunch: false,
buttonText: 'OAuth',
mobileOverrideEnabled: true,
mobileRedirectUri: 'http://mobile-redirect',
},
passwordLogin: { enabled: true },
} as SystemConfig,
};
const user = {
id: 'user_id',
email,
firstName: 'user',
lastName: 'imimch',
oauthId: '',
} as UserEntity;
const authUser: AuthUserDto = {
id: 'user_id',
email,
isAdmin: true,
};
const loginResponse = {
accessToken: 'access-token',
userId: 'user',
userEmail: 'user@immich.com,',
} as LoginResponseDto;
jest.mock('@nestjs/common', () => ({
...jest.requireActual('@nestjs/common'),
Logger: jest.fn().mockReturnValue({
verbose: jest.fn(),
debug: jest.fn(),
log: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
describe('OAuthService', () => {
let sut: OAuthService;
let userRepositoryMock: jest.Mocked<IUserRepository>;
let immichConfigServiceMock: jest.Mocked<SystemConfigService>;
let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
let callbackMock: jest.Mock;
beforeEach(async () => {
callbackMock = jest.fn().mockReturnValue({ access_token: 'access-token' });
jest.spyOn(generators, 'state').mockReturnValue('state');
jest.spyOn(Issuer, 'discover').mockResolvedValue({
id_token_signing_alg_values_supported: ['HS256'],
Client: jest.fn().mockResolvedValue({
issuer: {
metadata: {
end_session_endpoint: 'http://end-session-endpoint',
},
},
authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'),
callbackParams: jest.fn().mockReturnValue({ state: 'state' }),
callback: callbackMock,
userinfo: jest.fn().mockResolvedValue({ sub, email }),
}),
} as any);
userRepositoryMock = {
get: jest.fn(),
getAdmin: jest.fn(),
getByOAuthId: jest.fn(),
getByEmail: jest.fn(),
getList: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
};
immichJwtServiceMock = {
getCookieNames: jest.fn(),
getCookies: jest.fn(),
createLoginResponse: jest.fn(),
validateToken: jest.fn(),
extractJwtFromHeader: jest.fn(),
extractJwtFromCookie: jest.fn(),
} as unknown as jest.Mocked<ImmichJwtService>;
immichConfigServiceMock = {
config$: { subscribe: jest.fn() },
} as unknown as jest.Mocked<SystemConfigService>;
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.disabled);
});
it('should be defined', () => {
expect(sut).toBeDefined();
});
describe('generateConfig', () => {
it('should work when oauth is not configured', async () => {
await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({
enabled: false,
passwordLoginEnabled: true,
});
});
it('should generate the config', async () => {
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
enabled: true,
buttonText: 'OAuth',
url: 'http://authorization-url',
autoLaunch: false,
passwordLoginEnabled: true,
});
});
});
describe('login', () => {
it('should throw an error if OAuth is not enabled', async () => {
await expect(sut.login({ url: '' })).rejects.toBeInstanceOf(BadRequestException);
});
it('should not allow auto registering', async () => {
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.noAutoRegister);
userRepositoryMock.getByEmail.mockResolvedValue(null);
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
});
it('should link an existing user', async () => {
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.noAutoRegister);
userRepositoryMock.getByEmail.mockResolvedValue(user);
userRepositoryMock.update.mockResolvedValue(user);
immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
expect(userRepositoryMock.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
});
it('should allow auto registering by default', async () => {
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
userRepositoryMock.getByEmail.mockResolvedValue(null);
userRepositoryMock.getAdmin.mockResolvedValue(user);
userRepositoryMock.create.mockResolvedValue(user);
immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
expect(userRepositoryMock.create).toHaveBeenCalledTimes(1);
expect(immichJwtServiceMock.createLoginResponse).toHaveBeenCalledTimes(1);
});
it('should use the mobile redirect override', async () => {
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.override);
userRepositoryMock.getByOAuthId.mockResolvedValue(user);
await sut.login({ url: `app.immich:/?code=abc123` });
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
});
});
describe('link', () => {
it('should link an account', async () => {
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
userRepositoryMock.update.mockResolvedValue(user);
await sut.link(authUser, { url: 'http://immich/user-settings?code=abc123' });
expect(userRepositoryMock.update).toHaveBeenCalledWith(authUser.id, { oauthId: sub });
});
it('should not link an already linked oauth.sub', async () => {
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
userRepositoryMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
await expect(sut.link(authUser, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(userRepositoryMock.update).not.toHaveBeenCalled();
});
});
describe('unlink', () => {
it('should unlink an account', async () => {
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
userRepositoryMock.update.mockResolvedValue(user);
await sut.unlink(authUser);
expect(userRepositoryMock.update).toHaveBeenCalledWith(authUser.id, { oauthId: '' });
});
});
describe('getLogoutEndpoint', () => {
it('should return null if OAuth is not configured', async () => {
await expect(sut.getLogoutEndpoint()).resolves.toBeNull();
});
it('should get the session endpoint from the discovery document', async () => {
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint');
});
});
});

View File

@@ -1,153 +0,0 @@
import { SystemConfig } from '@app/infra';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
import { IUserRepository, UserResponseDto, UserCore, SystemConfigService, INITIAL_SYSTEM_CONFIG } from '@app/domain';
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
import { OAuthConfigDto } from './dto/oauth-config.dto';
import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto';
type OAuthProfile = UserinfoResponse & {
email: string;
};
export const MOBILE_REDIRECT = 'app.immich:/';
@Injectable()
export class OAuthService {
private readonly userCore: UserCore;
private readonly logger = new Logger(OAuthService.name);
constructor(
private immichJwtService: ImmichJwtService,
configService: SystemConfigService,
@Inject(IUserRepository) userRepository: IUserRepository,
@Inject(INITIAL_SYSTEM_CONFIG) private config: SystemConfig,
) {
this.userCore = new UserCore(userRepository);
custom.setHttpOptionsDefaults({
timeout: 30000,
});
configService.config$.subscribe((config) => (this.config = config));
}
public async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
const response = {
enabled: this.config.oauth.enabled,
passwordLoginEnabled: this.config.passwordLogin.enabled,
};
if (!response.enabled) {
return response;
}
const { scope, buttonText, autoLaunch } = this.config.oauth;
const redirectUri = this.normalize(dto.redirectUri);
const url = (await this.getClient()).authorizationUrl({
redirect_uri: redirectUri,
scope,
state: generators.state(),
});
return { ...response, buttonText, url, autoLaunch };
}
public async login(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
const profile = await this.callback(dto.url);
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user = await this.userCore.getByOAuthId(profile.sub);
// link existing user
if (!user) {
const emailUser = await this.userCore.getByEmail(profile.email);
if (emailUser) {
user = await this.userCore.updateUser(emailUser, emailUser.id, { oauthId: profile.sub });
}
}
// register new user
if (!user) {
if (!this.config.oauth.autoRegister) {
this.logger.warn(
`Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
);
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
}
this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
user = await this.userCore.createUser({
firstName: profile.given_name || '',
lastName: profile.family_name || '',
email: profile.email,
oauthId: profile.sub,
});
}
return this.immichJwtService.createLoginResponse(user);
}
public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
const { sub: oauthId } = await this.callback(dto.url);
const duplicate = await this.userCore.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== user.id) {
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
return this.userCore.updateUser(user, user.id, { oauthId });
}
public async unlink(user: AuthUserDto): Promise<UserResponseDto> {
return this.userCore.updateUser(user, user.id, { oauthId: '' });
}
public async getLogoutEndpoint(): Promise<string | null> {
if (!this.config.oauth.enabled) {
return null;
}
return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
}
private async callback(url: string): Promise<any> {
const redirectUri = this.normalize(url.split('?')[0]);
const client = await this.getClient();
const params = client.callbackParams(url);
const tokens = await client.callback(redirectUri, params, { state: params.state });
return await client.userinfo<OAuthProfile>(tokens.access_token || '');
}
private async getClient() {
const { enabled, clientId, clientSecret, issuerUrl } = this.config.oauth;
if (!enabled) {
throw new BadRequestException('OAuth2 is not enabled');
}
const metadata: ClientMetadata = {
client_id: clientId,
client_secret: clientSecret,
response_types: ['code'],
};
const issuer = await Issuer.discover(issuerUrl);
const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
if (algorithms[0] === 'HS256') {
metadata.id_token_signed_response_alg = algorithms[0];
}
return new issuer.Client(metadata);
}
private normalize(redirectUri: string) {
const isMobile = redirectUri === MOBILE_REDIRECT;
const { mobileRedirectUri, mobileOverrideEnabled } = this.config.oauth;
if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
return mobileRedirectUri;
}
return redirectUri;
}
}

View File

@@ -1,7 +0,0 @@
export class OAuthConfigResponseDto {
enabled!: boolean;
passwordLoginEnabled!: boolean;
url?: string;
buttonText?: string;
autoLaunch?: boolean;
}

View File

@@ -1,12 +1,11 @@
import { Module } from '@nestjs/common';
import { ServerInfoService } from './server-info.service';
import { ServerInfoController } from './server-info.controller';
import { AssetEntity, UserEntity } from '@app/infra';
import { AssetEntity } from '@app/infra';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
@Module({
imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity]), ImmichJwtModule],
imports: [TypeOrmModule.forFeature([AssetEntity])],
controllers: [ServerInfoController],
providers: [ServerInfoService],
})

View File

@@ -1,7 +1,6 @@
import { immichAppConfig } from '@app/common/config';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AssetModule } from './api-v1/asset/asset.module';
import { AuthModule } from './api-v1/auth/auth.module';
import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
import { ConfigModule } from '@nestjs/config';
@@ -13,12 +12,17 @@ import { AppController } from './app.controller';
import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
import { JobModule } from './api-v1/job/job.module';
import { OAuthModule } from './api-v1/oauth/oauth.module';
import { TagModule } from './api-v1/tag/tag.module';
import { ShareModule } from './api-v1/share/share.module';
import { DomainModule } from '@app/domain';
import { InfraModule } from '@app/infra';
import { APIKeyController, SystemConfigController, UserController } from './controllers';
import {
APIKeyController,
AuthController,
OAuthController,
SystemConfigController,
UserController,
} from './controllers';
@Module({
imports: [
@@ -30,9 +34,6 @@ import { APIKeyController, SystemConfigController, UserController } from './cont
AssetModule,
AuthModule,
OAuthModule,
ImmichJwtModule,
DeviceInfoModule,
@@ -59,6 +60,8 @@ import { APIKeyController, SystemConfigController, UserController } from './cont
//
AppController,
APIKeyController,
AuthController,
OAuthController,
SystemConfigController,
UserController,
],

View File

@@ -1,7 +0,0 @@
import { JwtModuleOptions } from '@nestjs/jwt';
import { jwtSecret } from '../constants/jwt.constant';
export const jwtConfig: JwtModuleOptions = {
secret: jwtSecret,
signOptions: { expiresIn: '30d' },
};

View File

@@ -1,7 +0,0 @@
export const jwtSecret = process.env.JWT_SECRET;
export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
export enum AuthType {
PASSWORD = 'password',
OAUTH = 'oauth',
}

View File

@@ -0,0 +1,71 @@
import {
AdminSignupResponseDto,
AuthService,
AuthType,
AuthUserDto,
ChangePasswordDto,
IMMICH_ACCESS_COOKIE,
IMMICH_AUTH_TYPE_COOKIE,
LoginCredentialDto,
LoginResponseDto,
LogoutResponseDto,
SignUpDto,
UserResponseDto,
ValidateAccessTokenResponseDto,
} from '@app/domain';
import { Body, Controller, Ip, Post, Req, Res, ValidationPipe } from '@nestjs/common';
import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
async login(
@Body(new ValidationPipe({ transform: true })) loginCredential: LoginCredentialDto,
@Ip() clientIp: string,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<LoginResponseDto> {
const { response, cookie } = await this.authService.login(loginCredential, clientIp, req.secure);
res.setHeader('Set-Cookie', cookie);
return response;
}
@Post('admin-sign-up')
@ApiBadRequestResponse({ description: 'The server already has an admin' })
adminSignUp(
@Body(new ValidationPipe({ transform: true })) signUpCredential: SignUpDto,
): Promise<AdminSignupResponseDto> {
return this.authService.adminSignUp(signUpCredential);
}
@Authenticated()
@ApiBearerAuth()
@Post('validateToken')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
validateAccessToken(@GetAuthUser() authUser: AuthUserDto): ValidateAccessTokenResponseDto {
return { authStatus: true };
}
@Authenticated()
@ApiBearerAuth()
@Post('change-password')
async changePassword(@GetAuthUser() authUser: AuthUserDto, @Body() dto: ChangePasswordDto): Promise<UserResponseDto> {
return this.authService.changePassword(authUser, dto);
}
@Post('logout')
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<LogoutResponseDto> {
const authType: AuthType = req.cookies[IMMICH_AUTH_TYPE_COOKIE];
res.clearCookie(IMMICH_ACCESS_COOKIE);
res.clearCookie(IMMICH_AUTH_TYPE_COOKIE);
return this.authService.logout(authType);
}
}

View File

@@ -1,3 +1,5 @@
export * from './api-key.controller';
export * from './auth.controller';
export * from './oauth.controller';
export * from './system-config.controller';
export * from './user.controller';

View File

@@ -1,21 +1,23 @@
import {
AuthUserDto,
LoginResponseDto,
MOBILE_REDIRECT,
OAuthCallbackDto,
OAuthConfigDto,
OAuthConfigResponseDto,
OAuthService,
UserResponseDto,
} from '@app/domain';
import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res, ValidationPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AuthType } from '../../constants/jwt.constant';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
import { UserResponseDto } from '@app/domain';
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
import { OAuthConfigDto } from './dto/oauth-config.dto';
import { MOBILE_REDIRECT, OAuthService } from './oauth.service';
import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
@ApiTags('OAuth')
@Controller('oauth')
export class OAuthController {
constructor(private readonly immichJwtService: ImmichJwtService, private readonly oauthService: OAuthService) {}
constructor(private readonly oauthService: OAuthService) {}
@Get('mobile-redirect')
@Redirect()
@@ -31,13 +33,13 @@ export class OAuthController {
@Post('callback')
public async callback(
@Res({ passthrough: true }) response: Response,
@Res({ passthrough: true }) res: Response,
@Body(ValidationPipe) dto: OAuthCallbackDto,
@Req() request: Request,
@Req() req: Request,
): Promise<LoginResponseDto> {
const loginResponse = await this.oauthService.login(dto);
response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH, request.secure));
return loginResponse;
const { response, cookie } = await this.oauthService.login(dto, req.secure);
res.setHeader('Set-Cookie', cookie);
return response;
}
@Authenticated()

View File

@@ -1,15 +1,11 @@
import { Module } from '@nestjs/common';
import { ImmichJwtService } from './immich-jwt.service';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
import { JwtStrategy } from './strategies/jwt.strategy';
import { APIKeyStrategy } from './strategies/api-key.strategy';
import { ShareModule } from '../../api-v1/share/share.module';
import { APIKeyStrategy } from './strategies/api-key.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { PublicShareStrategy } from './strategies/public-share.strategy';
@Module({
imports: [JwtModule.register(jwtConfig), ShareModule],
providers: [ImmichJwtService, JwtStrategy, APIKeyStrategy, PublicShareStrategy],
exports: [ImmichJwtService],
imports: [ShareModule],
providers: [JwtStrategy, APIKeyStrategy, PublicShareStrategy],
})
export class ImmichJwtModule {}

View File

@@ -1,160 +0,0 @@
import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { UserEntity } from '@app/infra';
import { LoginResponseDto } from '../../api-v1/auth/response-dto/login-response.dto';
import { AuthType } from '../../constants/jwt.constant';
import { ImmichJwtService } from './immich-jwt.service';
import { UserService } from '@app/domain';
describe('ImmichJwtService', () => {
let jwtServiceMock: jest.Mocked<JwtService>;
let userServiceMock: jest.Mocked<UserService>;
let sut: ImmichJwtService;
beforeEach(() => {
jwtServiceMock = {
sign: jest.fn(),
verifyAsync: jest.fn(),
} as unknown as jest.Mocked<JwtService>;
userServiceMock = {
getUserById: jest.fn(),
} as unknown as jest.Mocked<UserService>;
sut = new ImmichJwtService(jwtServiceMock, userServiceMock);
});
afterEach(() => {
jest.resetModules();
});
describe('getCookieNames', () => {
it('should return the cookie names', async () => {
expect(sut.getCookieNames()).toEqual(['immich_access_token', 'immich_auth_type']);
});
});
describe('getCookies', () => {
it('should generate the cookie headers (secure)', async () => {
jwtServiceMock.sign.mockImplementation((value) => value as string);
const dto = { accessToken: 'test-user@immich.com', userId: 'test-user' };
const cookies = sut.getCookies(dto as LoginResponseDto, AuthType.PASSWORD, true);
expect(cookies).toEqual([
'immich_access_token=test-user@immich.com; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
'immich_auth_type=password; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
]);
});
it('should generate the cookie headers (insecure)', () => {
jwtServiceMock.sign.mockImplementation((value) => value as string);
const dto = { accessToken: 'test-user@immich.com', userId: 'test-user' };
const cookies = sut.getCookies(dto as LoginResponseDto, AuthType.PASSWORD, false);
expect(cookies).toEqual([
'immich_access_token=test-user@immich.com; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;',
'immich_auth_type=password; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;',
]);
});
});
describe('createLoginResponse', () => {
it('should create the login response', async () => {
jwtServiceMock.sign.mockReturnValue('fancy-token');
const user: UserEntity = {
id: 'user',
firstName: 'immich',
lastName: 'user',
isAdmin: false,
email: 'test@immich.com',
password: 'changeme',
oauthId: '',
profileImagePath: '',
shouldChangePassword: false,
createdAt: 'today',
tags: [],
};
const dto: LoginResponseDto = {
accessToken: 'fancy-token',
firstName: 'immich',
isAdmin: false,
lastName: 'user',
profileImagePath: '',
shouldChangePassword: false,
userEmail: 'test@immich.com',
userId: 'user',
};
await expect(sut.createLoginResponse(user)).resolves.toEqual(dto);
});
});
describe('validateToken', () => {
it('should validate the token', async () => {
const dto = { userId: 'test-user', email: 'test-user@immich.com' };
jwtServiceMock.verifyAsync.mockImplementation(() => dto as any);
const response = await sut.validateToken('access-token');
expect(jwtServiceMock.verifyAsync).toHaveBeenCalledTimes(1);
expect(response).toEqual({ userId: 'test-user', status: true });
});
it('should handle an invalid token', async () => {
jwtServiceMock.verifyAsync.mockImplementation(() => {
throw new Error('Invalid token!');
});
const error = jest.spyOn(Logger, 'error');
error.mockImplementation(() => null);
const response = await sut.validateToken('access-token');
expect(jwtServiceMock.verifyAsync).toHaveBeenCalledTimes(1);
expect(error).toHaveBeenCalledTimes(1);
expect(response).toEqual({ userId: null, status: false });
});
});
describe('extractJwtFromHeader', () => {
it('should handle no authorization header', () => {
const request = {
headers: {},
} as Request;
const token = sut.extractJwtFromHeader(request.headers);
expect(token).toBe(null);
});
it('should get the token from the authorization header', () => {
const upper = {
headers: {
authorization: 'Bearer token',
},
} as Request;
const lower = {
headers: {
authorization: 'bearer token',
},
} as Request;
expect(sut.extractJwtFromHeader(upper.headers)).toBe('token');
expect(sut.extractJwtFromHeader(lower.headers)).toBe('token');
});
});
describe('extracJwtFromCookie', () => {
it('should handle no cookie', () => {
const request = {} as Request;
const token = sut.extractJwtFromCookie(request.cookies);
expect(token).toBe(null);
});
it('should get the token from the immich cookie', () => {
const request = {
cookies: {
immich_access_token: 'cookie',
},
} as Request;
const token = sut.extractJwtFromCookie(request.cookies);
expect(token).toBe('cookie');
});
});
});

View File

@@ -1,104 +0,0 @@
import { UserEntity } from '@app/infra';
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { IncomingHttpHeaders } from 'http';
import { JwtPayloadDto } from '../../api-v1/auth/dto/jwt-payload.dto';
import { LoginResponseDto, mapLoginResponse } from '../../api-v1/auth/response-dto/login-response.dto';
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE, jwtSecret } from '../../constants/jwt.constant';
import { Socket } from 'socket.io';
import cookieParser from 'cookie';
import { UserResponseDto, UserService } from '@app/domain';
export type JwtValidationResult = {
status: boolean;
userId: string | null;
};
@Injectable()
export class ImmichJwtService {
constructor(private jwtService: JwtService, private userService: UserService) {}
public getCookieNames() {
return [IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE];
}
public getCookies(loginResponse: LoginResponseDto, authType: AuthType, isSecure: boolean) {
const maxAge = 7 * 24 * 3600; // 7 days
let accessTokenCookie = '';
let authTypeCookie = '';
if (isSecure) {
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
} else {
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
}
return [accessTokenCookie, authTypeCookie];
}
public async createLoginResponse(user: UserEntity): Promise<LoginResponseDto> {
const payload = new JwtPayloadDto(user.id, user.email);
const accessToken = await this.generateToken(payload);
return mapLoginResponse(user, accessToken);
}
public async validateToken(accessToken: string): Promise<JwtValidationResult> {
try {
const payload = await this.jwtService.verifyAsync<JwtPayloadDto>(accessToken, { secret: jwtSecret });
return {
userId: payload.userId,
status: true,
};
} catch (e) {
Logger.error('Error validating token from websocket request', 'ValidateWebsocketToken');
return {
userId: null,
status: false,
};
}
}
public extractJwtFromHeader(headers: IncomingHttpHeaders) {
if (!headers.authorization) {
return null;
}
const [type, accessToken] = headers.authorization.split(' ');
if (type.toLowerCase() !== 'bearer') {
return null;
}
return accessToken;
}
public extractJwtFromCookie(cookies: Record<string, string>) {
return cookies?.[IMMICH_ACCESS_COOKIE] || null;
}
public async validateSocket(client: Socket): Promise<UserResponseDto | null> {
const headers = client.handshake.headers;
const accessToken =
this.extractJwtFromCookie(cookieParser.parse(headers.cookie || '')) || this.extractJwtFromHeader(headers);
if (accessToken) {
const { userId, status } = await this.validateToken(accessToken);
if (userId && status) {
const user = await this.userService.getUserById(userId).catch(() => null);
if (user) {
return user;
}
}
}
return null;
}
private async generateToken(payload: JwtPayloadDto) {
return this.jwtService.sign({
...payload,
});
}
}

View File

@@ -1,21 +1,17 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService, AuthUserDto, JwtPayloadDto, jwtSecret } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt';
import { UserService } from '@app/domain';
import { JwtPayloadDto } from '../../../api-v1/auth/dto/jwt-payload.dto';
import { jwtSecret } from '../../../constants/jwt.constant';
import { AuthUserDto } from '../../../decorators/auth-user.decorator';
import { ImmichJwtService } from '../immich-jwt.service';
export const JWT_STRATEGY = 'jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
constructor(private userService: UserService, immichJwtService: ImmichJwtService) {
constructor(private authService: AuthService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(req) => immichJwtService.extractJwtFromCookie(req.cookies),
(req) => immichJwtService.extractJwtFromHeader(req.headers),
(req) => authService.extractJwtFromCookie(req.cookies),
(req) => authService.extractJwtFromHeader(req.headers),
]),
ignoreExpiration: false,
secretOrKey: jwtSecret,
@@ -23,19 +19,6 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
}
async validate(payload: JwtPayloadDto): Promise<AuthUserDto> {
const { userId } = payload;
const user = await this.userService.getUserById(userId).catch(() => null);
if (!user) {
throw new UnauthorizedException('Failure to validate JWT payload');
}
const authUser = new AuthUserDto();
authUser.id = user.id;
authUser.email = user.email;
authUser.isAdmin = user.isAdmin;
authUser.isPublicUser = false;
authUser.isAllowUpload = true;
return authUser;
return this.authService.validatePayload(payload);
}
}

View File

@@ -7,10 +7,8 @@ import { AlbumModule } from '../src/api-v1/album/album.module';
import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto';
import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module';
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
import { DomainModule, UserService } from '@app/domain';
import { AuthService, DomainModule, UserService } from '@app/domain';
import { DataSource } from 'typeorm';
import { AuthService } from '../src/api-v1/auth/auth.service';
import { AuthModule } from '../src/api-v1/auth/auth.module';
function _createAlbum(app: INestApplication, data: CreateAlbumDto) {
return request(app.getHttpServer()).post('/album').send(data);
@@ -49,7 +47,7 @@ describe('Album', () => {
beforeAll(async () => {
const builder = Test.createTestingModule({
imports: [DomainModule.register({ imports: [InfraModule] }), AuthModule, AlbumModule],
imports: [DomainModule.register({ imports: [InfraModule] }), AlbumModule],
});
authUser = getAuthUser(); // set default auth user
const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();

View File

@@ -7,8 +7,7 @@ import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module';
import { DomainModule, CreateUserDto, UserService, AuthUserDto } from '@app/domain';
import { DataSource } from 'typeorm';
import { UserController } from '../src/controllers';
import { AuthModule } from '../src/api-v1/auth/auth.module';
import { AuthService } from '../src/api-v1/auth/auth.service';
import { AuthService } from '@app/domain';
function _createUser(userService: UserService, data: CreateUserDto) {
return userService.createUser(data);
@@ -52,7 +51,7 @@ describe('User', () => {
beforeAll(async () => {
const builder = Test.createTestingModule({
imports: [DomainModule.register({ imports: [InfraModule] }), AuthModule],
imports: [DomainModule.register({ imports: [InfraModule] })],
controllers: [UserController],
});
const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();