mirror of
https://github.com/immich-app/immich.git
synced 2024-11-28 09:33:27 +02:00
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:
parent
443d08381a
commit
eade36ee82
@ -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);
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
export class JwtPayloadDto {
|
||||
constructor(userId: string, email: string) {
|
||||
this.userId = userId;
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
@ -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 {
|
||||
|
@ -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],
|
||||
})
|
||||
|
@ -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],
|
||||
})
|
||||
|
@ -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 {}
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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],
|
||||
})
|
||||
|
@ -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,
|
||||
],
|
||||
|
71
server/apps/immich/src/controllers/auth.controller.ts
Normal file
71
server/apps/immich/src/controllers/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -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()
|
@ -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 {}
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
||||
import { JwtModuleOptions } from '@nestjs/jwt';
|
||||
import { jwtSecret } from '../constants/jwt.constant';
|
||||
import { jwtSecret } from './auth.constant';
|
||||
|
||||
export const jwtConfig: JwtModuleOptions = {
|
||||
secret: jwtSecret,
|
80
server/libs/domain/src/auth/auth.core.ts
Normal file
80
server/libs/domain/src/auth/auth.core.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { SystemConfig, UserEntity } from '@app/infra/db/entities';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { JwtPayloadDto } from './dto/jwt-payload.dto';
|
||||
import { LoginResponseDto, mapLoginResponse } from './response-dto';
|
||||
|
||||
export type JwtValidationResult = {
|
||||
status: boolean;
|
||||
userId: string | null;
|
||||
};
|
||||
|
||||
export class AuthCore {
|
||||
constructor(
|
||||
private cryptoRepository: ICryptoRepository,
|
||||
configRepository: ISystemConfigRepository,
|
||||
private config: SystemConfig,
|
||||
) {
|
||||
const configCore = new SystemConfigCore(configRepository);
|
||||
configCore.config$.subscribe((config) => (this.config = config));
|
||||
}
|
||||
|
||||
isPasswordLoginEnabled() {
|
||||
return this.config.passwordLogin.enabled;
|
||||
}
|
||||
|
||||
public getCookies(loginResponse: LoginResponseDto, authType: AuthType, isSecure: boolean) {
|
||||
const maxAge = 7 * 24 * 3600; // 7 days
|
||||
|
||||
let authTypeCookie = '';
|
||||
let accessTokenCookie = '';
|
||||
|
||||
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 createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) {
|
||||
const payload: JwtPayloadDto = { userId: user.id, email: user.email };
|
||||
const accessToken = this.generateToken(payload);
|
||||
const response = mapLoginResponse(user, accessToken);
|
||||
const cookie = this.getCookies(response, authType, isSecure);
|
||||
return { response, cookie };
|
||||
}
|
||||
|
||||
validatePassword(inputPassword: string, user: UserEntity): boolean {
|
||||
if (!user || !user.password) {
|
||||
return false;
|
||||
}
|
||||
return this.cryptoRepository.compareSync(inputPassword, user.password);
|
||||
}
|
||||
|
||||
extractJwtFromHeader(headers: IncomingHttpHeaders) {
|
||||
if (!headers.authorization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [type, accessToken] = headers.authorization.split(' ');
|
||||
if (type.toLowerCase() !== 'bearer') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
extractJwtFromCookie(cookies: Record<string, string>) {
|
||||
return cookies?.[IMMICH_ACCESS_COOKIE] || null;
|
||||
}
|
||||
|
||||
private generateToken(payload: JwtPayloadDto) {
|
||||
return this.cryptoRepository.signJwt({ ...payload });
|
||||
}
|
||||
}
|
263
server/libs/domain/src/auth/auth.service.spec.ts
Normal file
263
server/libs/domain/src/auth/auth.service.spec.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import { SystemConfig, UserEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { generators, Issuer } from 'openid-client';
|
||||
import { Socket } from 'socket.io';
|
||||
import {
|
||||
authStub,
|
||||
entityStub,
|
||||
loginResponseStub,
|
||||
newCryptoRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
systemConfigStub,
|
||||
} from '../../test';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { SignUpDto } from './dto';
|
||||
|
||||
const email = 'test@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
|
||||
const fixtures = {
|
||||
login: {
|
||||
email,
|
||||
password: 'password',
|
||||
},
|
||||
};
|
||||
|
||||
const CLIENT_IP = '127.0.0.1';
|
||||
|
||||
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 cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let callbackMock: jest.Mock;
|
||||
let create: (config: SystemConfig) => AuthService;
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
|
||||
create = (config) => new AuthService(cryptoMock, configMock, userMock, config);
|
||||
|
||||
sut = create(systemConfigStub.enabled);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should throw an error if password login is disabled', async () => {
|
||||
sut = create(systemConfigStub.disabled);
|
||||
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should check the user exists', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(null);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should check the user has a password', async () => {
|
||||
userMock.getByEmail.mockResolvedValue({} as UserEntity);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should successfully log the user in', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(entityStub.user1);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).resolves.toEqual(loginResponseStub.user1password);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should generate the cookie headers (insecure)', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(entityStub.user1);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, false)).resolves.toEqual(loginResponseStub.user1insecure);
|
||||
expect(userMock.getByEmail).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' };
|
||||
|
||||
userMock.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
password: 'hash-password',
|
||||
} as UserEntity);
|
||||
|
||||
await sut.changePassword(authUser, dto);
|
||||
|
||||
expect(userMock.getByEmail).toHaveBeenCalledWith(authUser.email, true);
|
||||
expect(cryptoMock.compareSync).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' };
|
||||
|
||||
userMock.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' };
|
||||
|
||||
cryptoMock.compareSync.mockReturnValue(false);
|
||||
|
||||
userMock.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' };
|
||||
|
||||
cryptoMock.compareSync.mockReturnValue(false);
|
||||
|
||||
userMock.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 () => {
|
||||
await expect(sut.logout(AuthType.OAUTH)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: 'http://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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('adminSignUp', () => {
|
||||
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', firstName: 'immich', lastName: 'admin' };
|
||||
|
||||
it('should only allow one admin', async () => {
|
||||
userMock.getAdmin.mockResolvedValue({} as UserEntity);
|
||||
await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userMock.getAdmin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sign up the admin', async () => {
|
||||
userMock.getAdmin.mockResolvedValue(null);
|
||||
userMock.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(userMock.getAdmin).toHaveBeenCalled();
|
||||
expect(userMock.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSocket', () => {
|
||||
it('should validate using authorization header', async () => {
|
||||
userMock.get.mockResolvedValue(entityStub.user1);
|
||||
const client = { handshake: { headers: { authorization: 'Bearer jwt-token' } } };
|
||||
await expect(sut.validateSocket(client as Socket)).resolves.toEqual(entityStub.user1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePayload', () => {
|
||||
it('should throw if no user is found', async () => {
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(sut.validatePayload({ email: 'a', userId: 'test' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
userMock.get.mockResolvedValue(entityStub.admin);
|
||||
await expect(sut.validatePayload({ email: 'a', userId: 'test' })).resolves.toEqual(authStub.admin);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractJwtFromCookie', () => {
|
||||
it('should extract the access token', () => {
|
||||
const cookie = { [IMMICH_ACCESS_COOKIE]: 'signed-jwt', [IMMICH_AUTH_TYPE_COOKIE]: 'password' };
|
||||
expect(sut.extractJwtFromCookie(cookie)).toEqual('signed-jwt');
|
||||
});
|
||||
|
||||
it('should work with no cookies', () => {
|
||||
expect(sut.extractJwtFromCookie(undefined as any)).toBeNull();
|
||||
});
|
||||
|
||||
it('should work on empty cookies', () => {
|
||||
expect(sut.extractJwtFromCookie({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractJwtFromHeader', () => {
|
||||
it('should extract the access token', () => {
|
||||
expect(sut.extractJwtFromHeader({ authorization: `Bearer signed-jwt` })).toEqual('signed-jwt');
|
||||
});
|
||||
|
||||
it('should work without the auth header', () => {
|
||||
expect(sut.extractJwtFromHeader({})).toBeNull();
|
||||
});
|
||||
|
||||
it('should ignore basic auth', () => {
|
||||
expect(sut.extractJwtFromHeader({ authorization: `Basic stuff` })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
160
server/libs/domain/src/auth/auth.service.ts
Normal file
160
server/libs/domain/src/auth/auth.service.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { SystemConfig } from '@app/infra/db/entities';
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import * as cookieParser from 'cookie';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
import { Socket } from 'socket.io';
|
||||
import { OAuthCore } from '../oauth/oauth.core';
|
||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository, UserCore, UserResponseDto } from '../user';
|
||||
import { AuthType, jwtSecret } from './auth.constant';
|
||||
import { AuthCore } from './auth.core';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { AuthUserDto, ChangePasswordDto, JwtPayloadDto, LoginCredentialDto, SignUpDto } from './dto';
|
||||
import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private authCore: AuthCore;
|
||||
private oauthCore: OAuthCore;
|
||||
private userCore: UserCore;
|
||||
|
||||
private logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
@Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig,
|
||||
) {
|
||||
this.authCore = new AuthCore(cryptoRepository, configRepository, initialConfig);
|
||||
this.oauthCore = new OAuthCore(configRepository, initialConfig);
|
||||
this.userCore = new UserCore(userRepository);
|
||||
}
|
||||
|
||||
public async login(
|
||||
loginCredential: LoginCredentialDto,
|
||||
clientIp: string,
|
||||
isSecure: boolean,
|
||||
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
|
||||
if (!this.authCore.isPasswordLoginEnabled()) {
|
||||
throw new UnauthorizedException('Password login has been disabled');
|
||||
}
|
||||
|
||||
let user = await this.userCore.getByEmail(loginCredential.email, true);
|
||||
if (user) {
|
||||
const isAuthenticated = await this.authCore.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.authCore.createLoginResponse(user, AuthType.PASSWORD, isSecure);
|
||||
}
|
||||
|
||||
public async logout(authType: AuthType): Promise<LogoutResponseDto> {
|
||||
if (authType === AuthType.OAUTH) {
|
||||
const url = await this.oauthCore.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.authCore.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');
|
||||
}
|
||||
}
|
||||
|
||||
async validateSocket(client: Socket): Promise<UserResponseDto | null> {
|
||||
try {
|
||||
const headers = client.handshake.headers;
|
||||
const accessToken =
|
||||
this.extractJwtFromCookie(cookieParser.parse(headers.cookie || '')) || this.extractJwtFromHeader(headers);
|
||||
|
||||
if (accessToken) {
|
||||
const payload = await this.cryptoRepository.verifyJwtAsync<JwtPayloadDto>(accessToken, { secret: jwtSecret });
|
||||
if (payload?.userId && payload?.email) {
|
||||
const user = await this.userCore.get(payload.userId);
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async validatePayload(payload: JwtPayloadDto) {
|
||||
const { userId } = payload;
|
||||
const user = await this.userCore.get(userId);
|
||||
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;
|
||||
}
|
||||
|
||||
extractJwtFromCookie(cookies: Record<string, string>) {
|
||||
return this.authCore.extractJwtFromCookie(cookies);
|
||||
}
|
||||
|
||||
extractJwtFromHeader(headers: IncomingHttpHeaders) {
|
||||
return this.authCore.extractJwtFromHeader(headers);
|
||||
}
|
||||
}
|
@ -1,7 +1,11 @@
|
||||
import { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
|
||||
|
||||
export const ICryptoRepository = 'ICryptoRepository';
|
||||
|
||||
export interface ICryptoRepository {
|
||||
randomBytes(size: number): Buffer;
|
||||
hash(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
|
||||
compareSync(data: Buffer | string, encrypted: string): boolean;
|
||||
signJwt(payload: string | Buffer | object, options?: JwtSignOptions): string;
|
||||
verifyJwtAsync<T extends object = any>(token: string, options?: JwtVerifyOptions): Promise<T>;
|
||||
}
|
||||
|
@ -1 +1,5 @@
|
||||
export * from './auth-user.dto';
|
||||
export * from './change-password.dto';
|
||||
export * from './jwt-payload.dto';
|
||||
export * from './login-credential.dto';
|
||||
export * from './sign-up.dto';
|
||||
|
4
server/libs/domain/src/auth/dto/jwt-payload.dto.ts
Normal file
4
server/libs/domain/src/auth/dto/jwt-payload.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export class JwtPayloadDto {
|
||||
userId!: string;
|
||||
email!: string;
|
||||
}
|
@ -1,2 +1,6 @@
|
||||
export * from './auth.config';
|
||||
export * from './auth.constant';
|
||||
export * from './auth.service';
|
||||
export * from './crypto.repository';
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { UserEntity } from '@app/infra';
|
||||
import { UserEntity } from '@app/infra/db/entities';
|
||||
|
||||
export class AdminSignupResponseDto {
|
||||
id!: string;
|
4
server/libs/domain/src/auth/response-dto/index.ts
Normal file
4
server/libs/domain/src/auth/response-dto/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './admin-signup-response.dto';
|
||||
export * from './login-response.dto';
|
||||
export * from './logout-response.dto';
|
||||
export * from './validate-asset-token-response.dto';
|
@ -1,4 +1,4 @@
|
||||
import { UserEntity } from '@app/infra';
|
||||
import { UserEntity } from '@app/infra/db/entities';
|
||||
import { ApiResponseProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginResponseDto {
|
@ -1,13 +1,14 @@
|
||||
import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common';
|
||||
import { APIKeyService } from './api-key';
|
||||
import { SystemConfigService } from './system-config';
|
||||
import { AuthService } from './auth';
|
||||
import { OAuthService } from './oauth';
|
||||
import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
|
||||
import { UserService } from './user';
|
||||
|
||||
export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG';
|
||||
|
||||
const providers: Provider[] = [
|
||||
//
|
||||
APIKeyService,
|
||||
AuthService,
|
||||
OAuthService,
|
||||
SystemConfigService,
|
||||
UserService,
|
||||
|
||||
|
@ -2,5 +2,6 @@ export * from './api-key';
|
||||
export * from './auth';
|
||||
export * from './domain.module';
|
||||
export * from './job';
|
||||
export * from './oauth';
|
||||
export * from './system-config';
|
||||
export * from './user';
|
||||
|
2
server/libs/domain/src/oauth/dto/index.ts
Normal file
2
server/libs/domain/src/oauth/dto/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './oauth-auth-code.dto';
|
||||
export * from './oauth-config.dto';
|
4
server/libs/domain/src/oauth/index.ts
Normal file
4
server/libs/domain/src/oauth/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './dto';
|
||||
export * from './oauth.constants';
|
||||
export * from './oauth.service';
|
||||
export * from './response-dto';
|
1
server/libs/domain/src/oauth/oauth.constants.ts
Normal file
1
server/libs/domain/src/oauth/oauth.constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const MOBILE_REDIRECT = 'app.immich:/';
|
107
server/libs/domain/src/oauth/oauth.core.ts
Normal file
107
server/libs/domain/src/oauth/oauth.core.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { SystemConfig } from '@app/infra/db/entities';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { OAuthConfigDto } from './dto';
|
||||
import { MOBILE_REDIRECT } from './oauth.constants';
|
||||
import { OAuthConfigResponseDto } from './response-dto';
|
||||
|
||||
type OAuthProfile = UserinfoResponse & {
|
||||
email: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OAuthCore {
|
||||
private readonly logger = new Logger(OAuthCore.name);
|
||||
private configCore: SystemConfigCore;
|
||||
|
||||
constructor(configRepository: ISystemConfigRepository, private config: SystemConfig) {
|
||||
this.configCore = new SystemConfigCore(configRepository);
|
||||
|
||||
custom.setHttpOptionsDefaults({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
this.configCore.config$.subscribe((config) => (this.config = config));
|
||||
}
|
||||
|
||||
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 url = (await this.getClient()).authorizationUrl({
|
||||
redirect_uri: this.normalize(dto.redirectUri),
|
||||
scope,
|
||||
state: generators.state(),
|
||||
});
|
||||
|
||||
return { ...response, buttonText, url, autoLaunch };
|
||||
}
|
||||
|
||||
async callback(url: string): Promise<OAuthProfile> {
|
||||
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 || '');
|
||||
}
|
||||
|
||||
isAutoRegisterEnabled() {
|
||||
return this.config.oauth.autoRegister;
|
||||
}
|
||||
|
||||
asUser(profile: OAuthProfile) {
|
||||
return {
|
||||
firstName: profile.given_name || '',
|
||||
lastName: profile.family_name || '',
|
||||
email: profile.email,
|
||||
oauthId: profile.sub,
|
||||
};
|
||||
}
|
||||
|
||||
async getLogoutEndpoint(): Promise<string | null> {
|
||||
if (!this.config.oauth.enabled) {
|
||||
return null;
|
||||
}
|
||||
return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
193
server/libs/domain/src/oauth/oauth.service.spec.ts
Normal file
193
server/libs/domain/src/oauth/oauth.service.spec.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import { SystemConfig, UserEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { generators, Issuer } from 'openid-client';
|
||||
import {
|
||||
authStub,
|
||||
entityStub,
|
||||
loginResponseStub,
|
||||
newCryptoRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
systemConfigStub,
|
||||
} from '../../test';
|
||||
import { ICryptoRepository } from '../auth';
|
||||
import { OAuthService } from '../oauth';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user';
|
||||
|
||||
const email = 'user@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
|
||||
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 userMock: jest.Mocked<IUserRepository>;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let callbackMock: jest.Mock;
|
||||
let create: (config: SystemConfig) => OAuthService;
|
||||
|
||||
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);
|
||||
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
|
||||
create = (config) => new OAuthService(cryptoMock, configMock, userMock, config);
|
||||
|
||||
sut = create(systemConfigStub.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: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate the config', async () => {
|
||||
sut = create(systemConfigStub.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: '' }, true)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should not allow auto registering', async () => {
|
||||
sut = create(systemConfigStub.noAutoRegister);
|
||||
userMock.getByEmail.mockResolvedValue(null);
|
||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should link an existing user', async () => {
|
||||
sut = create(systemConfigStub.noAutoRegister);
|
||||
userMock.getByEmail.mockResolvedValue(entityStub.user1);
|
||||
userMock.update.mockResolvedValue(entityStub.user1);
|
||||
|
||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual(
|
||||
loginResponseStub.user1oauth,
|
||||
);
|
||||
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(userMock.update).toHaveBeenCalledWith(entityStub.user1.id, { oauthId: sub });
|
||||
});
|
||||
|
||||
it('should allow auto registering by default', async () => {
|
||||
sut = create(systemConfigStub.enabled);
|
||||
|
||||
userMock.getByEmail.mockResolvedValue(null);
|
||||
userMock.getAdmin.mockResolvedValue(entityStub.user1);
|
||||
userMock.create.mockResolvedValue(entityStub.user1);
|
||||
|
||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual(
|
||||
loginResponseStub.user1oauth,
|
||||
);
|
||||
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
|
||||
expect(userMock.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should use the mobile redirect override', async () => {
|
||||
sut = create(systemConfigStub.override);
|
||||
|
||||
userMock.getByOAuthId.mockResolvedValue(entityStub.user1);
|
||||
|
||||
await sut.login({ url: `app.immich:/?code=abc123` }, true);
|
||||
|
||||
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('link', () => {
|
||||
it('should link an account', async () => {
|
||||
sut = create(systemConfigStub.enabled);
|
||||
|
||||
userMock.update.mockResolvedValue(entityStub.user1);
|
||||
|
||||
await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
|
||||
|
||||
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: sub });
|
||||
});
|
||||
|
||||
it('should not link an already linked oauth.sub', async () => {
|
||||
sut = create(systemConfigStub.enabled);
|
||||
|
||||
userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
|
||||
|
||||
await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlink', () => {
|
||||
it('should unlink an account', async () => {
|
||||
sut = create(systemConfigStub.enabled);
|
||||
|
||||
userMock.update.mockResolvedValue(entityStub.user1);
|
||||
|
||||
await sut.unlink(authStub.user1);
|
||||
|
||||
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.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 = create(systemConfigStub.enabled);
|
||||
|
||||
await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint');
|
||||
});
|
||||
});
|
||||
});
|
81
server/libs/domain/src/oauth/oauth.service.ts
Normal file
81
server/libs/domain/src/oauth/oauth.service.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { SystemConfig } from '@app/infra/db/entities';
|
||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { AuthType, AuthUserDto, ICryptoRepository, LoginResponseDto } from '../auth';
|
||||
import { AuthCore } from '../auth/auth.core';
|
||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository, UserCore, UserResponseDto } from '../user';
|
||||
import { OAuthCallbackDto, OAuthConfigDto } from './dto';
|
||||
import { OAuthCore } from './oauth.core';
|
||||
import { OAuthConfigResponseDto } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
private authCore: AuthCore;
|
||||
private oauthCore: OAuthCore;
|
||||
private userCore: UserCore;
|
||||
|
||||
private readonly logger = new Logger(OAuthService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
@Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig,
|
||||
) {
|
||||
this.authCore = new AuthCore(cryptoRepository, configRepository, initialConfig);
|
||||
this.userCore = new UserCore(userRepository);
|
||||
this.oauthCore = new OAuthCore(configRepository, initialConfig);
|
||||
}
|
||||
|
||||
generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
|
||||
return this.oauthCore.generateConfig(dto);
|
||||
}
|
||||
|
||||
async login(dto: OAuthCallbackDto, isSecure: boolean): Promise<{ response: LoginResponseDto; cookie: string[] }> {
|
||||
const profile = await this.oauthCore.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.oauthCore.isAutoRegisterEnabled()) {
|
||||
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(this.oauthCore.asUser(profile));
|
||||
}
|
||||
|
||||
return this.authCore.createLoginResponse(user, AuthType.OAUTH, isSecure);
|
||||
}
|
||||
|
||||
public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
|
||||
const { sub: oauthId } = await this.oauthCore.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> {
|
||||
return this.oauthCore.getLogoutEndpoint();
|
||||
}
|
||||
}
|
1
server/libs/domain/src/oauth/response-dto/index.ts
Normal file
1
server/libs/domain/src/oauth/response-dto/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './oauth-config-response.dto';
|
@ -1,5 +1,5 @@
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
export * from './system-config.constants';
|
||||
export * from './system-config.repository';
|
||||
export * from './system-config.service';
|
||||
export * from './system-config.datetime-variables';
|
||||
|
@ -18,3 +18,5 @@ export const supportedPresetTokens = [
|
||||
'{{y}}-{{MMM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}-{{MMMM}}-{{dd}}/{{filename}}',
|
||||
];
|
||||
|
||||
export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG';
|
@ -37,12 +37,14 @@ const defaults: SystemConfig = Object.freeze({
|
||||
},
|
||||
});
|
||||
|
||||
const singleton = new Subject<SystemConfig>();
|
||||
|
||||
@Injectable()
|
||||
export class SystemConfigCore {
|
||||
private logger = new Logger(SystemConfigCore.name);
|
||||
private validators: SystemConfigValidator[] = [];
|
||||
|
||||
public config$ = new Subject<SystemConfig>();
|
||||
public config$ = singleton;
|
||||
|
||||
constructor(private repository: ISystemConfigRepository) {}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { SystemConfigEntity, SystemConfigKey } from '@app/infra';
|
||||
import { SystemConfigEntity, SystemConfigKey } from '@app/infra/db/entities';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '../../test';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
supportedPresetTokens,
|
||||
supportedSecondTokens,
|
||||
supportedYearTokens,
|
||||
} from './system-config.datetime-variables';
|
||||
} from './system-config.constants';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { mapConfig, SystemConfigDto } from './dto/system-config.dto';
|
||||
|
@ -5,5 +5,7 @@ export const newCryptoRepositoryMock = (): jest.Mocked<ICryptoRepository> => {
|
||||
randomBytes: jest.fn().mockReturnValue(Buffer.from('random-bytes', 'utf8')),
|
||||
compareSync: jest.fn().mockReturnValue(true),
|
||||
hash: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)),
|
||||
signJwt: jest.fn().mockReturnValue('signed-jwt'),
|
||||
verifyJwtAsync: jest.fn().mockResolvedValue({ userId: 'test', email: 'test' }),
|
||||
};
|
||||
};
|
||||
|
@ -72,4 +72,96 @@ export const systemConfigStub = {
|
||||
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
},
|
||||
} as SystemConfig),
|
||||
enabled: Object.freeze({
|
||||
passwordLogin: {
|
||||
enabled: true,
|
||||
},
|
||||
oauth: {
|
||||
enabled: true,
|
||||
autoRegister: true,
|
||||
buttonText: 'OAuth',
|
||||
autoLaunch: false,
|
||||
},
|
||||
} as SystemConfig),
|
||||
disabled: Object.freeze({
|
||||
passwordLogin: {
|
||||
enabled: false,
|
||||
},
|
||||
oauth: {
|
||||
enabled: false,
|
||||
buttonText: 'OAuth',
|
||||
issuerUrl: 'http://issuer,',
|
||||
autoLaunch: false,
|
||||
},
|
||||
} 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,
|
||||
};
|
||||
|
||||
export const loginResponseStub = {
|
||||
user1oauth: {
|
||||
response: {
|
||||
accessToken: 'signed-jwt',
|
||||
userId: 'immich_id',
|
||||
userEmail: 'immich@test.com',
|
||||
firstName: 'immich_first_name',
|
||||
lastName: 'immich_last_name',
|
||||
profileImagePath: '',
|
||||
isAdmin: false,
|
||||
shouldChangePassword: false,
|
||||
},
|
||||
cookie: [
|
||||
'immich_access_token=signed-jwt; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_auth_type=oauth; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
],
|
||||
},
|
||||
user1password: {
|
||||
response: {
|
||||
accessToken: 'signed-jwt',
|
||||
userId: 'immich_id',
|
||||
userEmail: 'immich@test.com',
|
||||
firstName: 'immich_first_name',
|
||||
lastName: 'immich_last_name',
|
||||
profileImagePath: '',
|
||||
isAdmin: false,
|
||||
shouldChangePassword: false,
|
||||
},
|
||||
cookie: [
|
||||
'immich_access_token=signed-jwt; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_auth_type=password; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
],
|
||||
},
|
||||
user1insecure: {
|
||||
response: {
|
||||
accessToken: 'signed-jwt',
|
||||
userId: 'immich_id',
|
||||
userEmail: 'immich@test.com',
|
||||
firstName: 'immich_first_name',
|
||||
lastName: 'immich_last_name',
|
||||
profileImagePath: '',
|
||||
isAdmin: false,
|
||||
shouldChangePassword: false,
|
||||
},
|
||||
cookie: [
|
||||
'immich_access_token=signed-jwt; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_auth_type=password; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
@ -1,9 +1,22 @@
|
||||
import { ICryptoRepository } from '@app/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService, JwtVerifyOptions } from '@nestjs/jwt';
|
||||
import { compareSync, hash } from 'bcrypt';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export const cryptoRepository: ICryptoRepository = {
|
||||
randomBytes,
|
||||
hash,
|
||||
compareSync,
|
||||
};
|
||||
@Injectable()
|
||||
export class CryptoRepository implements ICryptoRepository {
|
||||
constructor(private jwtService: JwtService) {}
|
||||
|
||||
randomBytes = randomBytes;
|
||||
hash = hash;
|
||||
compareSync = compareSync;
|
||||
|
||||
signJwt(payload: string | Buffer | object) {
|
||||
return this.jwtService.sign(payload);
|
||||
}
|
||||
|
||||
verifyJwtAsync<T extends object = any>(token: string, options?: JwtVerifyOptions): Promise<T> {
|
||||
return this.jwtService.verifyAsync(token, options);
|
||||
}
|
||||
}
|
||||
|
@ -6,19 +6,20 @@ import {
|
||||
IUserRepository,
|
||||
QueueName,
|
||||
} from '@app/domain';
|
||||
import { databaseConfig, UserEntity } from '@app/infra';
|
||||
import { databaseConfig, UserEntity } from './db';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Global, Module, Provider } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { cryptoRepository } from './auth/crypto.repository';
|
||||
import { jwtConfig } from '@app/domain';
|
||||
import { CryptoRepository } from './auth/crypto.repository';
|
||||
import { APIKeyEntity, SystemConfigEntity, UserRepository } from './db';
|
||||
import { APIKeyRepository } from './db/repository';
|
||||
import { SystemConfigRepository } from './db/repository/system-config.repository';
|
||||
import { JobRepository } from './job';
|
||||
|
||||
const providers: Provider[] = [
|
||||
//
|
||||
{ provide: ICryptoRepository, useValue: cryptoRepository },
|
||||
{ provide: ICryptoRepository, useClass: CryptoRepository },
|
||||
{ provide: IKeyRepository, useClass: APIKeyRepository },
|
||||
{ provide: IJobRepository, useClass: JobRepository },
|
||||
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
|
||||
@ -28,6 +29,7 @@ const providers: Provider[] = [
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register(jwtConfig),
|
||||
TypeOrmModule.forRoot(databaseConfig),
|
||||
TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SystemConfigEntity]),
|
||||
BullModule.forRootAsync({
|
||||
@ -60,6 +62,6 @@ const providers: Provider[] = [
|
||||
),
|
||||
],
|
||||
providers: [...providers],
|
||||
exports: [...providers, BullModule],
|
||||
exports: [...providers, BullModule, JwtModule],
|
||||
})
|
||||
export class InfraModule {}
|
||||
|
@ -27,7 +27,7 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./apps/immich/test/jest-e2e.json",
|
||||
"test:e2e": "jest --config ./apps/immich/test/jest-e2e.json --runInBand",
|
||||
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js",
|
||||
"api:typescript": "bash ./bin/generate-open-api.sh web",
|
||||
"api:dart": "bash ./bin/generate-open-api.sh mobile",
|
||||
|
Loading…
Reference in New Issue
Block a user