diff --git a/server/apps/immich/src/api-v1/auth/auth.controller.ts b/server/apps/immich/src/api-v1/auth/auth.controller.ts deleted file mode 100644 index 1c3fe2ac9c..0000000000 --- a/server/apps/immich/src/api-v1/auth/auth.controller.ts +++ /dev/null @@ -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 { - 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 { - 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 { - return new ValidateAccessTokenResponseDto(true); - } - - @Authenticated() - @ApiBearerAuth() - @Post('change-password') - async changePassword(@GetAuthUser() authUser: AuthUserDto, @Body() dto: ChangePasswordDto): Promise { - return this.authService.changePassword(authUser, dto); - } - - @Post('/logout') - async logout(@Req() req: Request, @Res({ passthrough: true }) response: Response): Promise { - 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); - } -} diff --git a/server/apps/immich/src/api-v1/auth/auth.module.ts b/server/apps/immich/src/api-v1/auth/auth.module.ts deleted file mode 100644 index 1affdbf656..0000000000 --- a/server/apps/immich/src/api-v1/auth/auth.module.ts +++ /dev/null @@ -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 {} diff --git a/server/apps/immich/src/api-v1/auth/auth.service.spec.ts b/server/apps/immich/src/api-v1/auth/auth.service.spec.ts deleted file mode 100644 index 3c43e243b8..0000000000 --- a/server/apps/immich/src/api-v1/auth/auth.service.spec.ts +++ /dev/null @@ -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; - let immichJwtServiceMock: jest.Mocked; - let immichConfigServiceMock: jest.Mocked; - let oauthServiceMock: jest.Mocked; - 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; - - oauthServiceMock = { - getLogoutEndpoint: jest.fn(), - } as unknown as jest.Mocked; - - immichConfigServiceMock = { - config$: { subscribe: jest.fn() }, - } as unknown as jest.Mocked; - - 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(); - }); - }); -}); diff --git a/server/apps/immich/src/api-v1/auth/auth.service.ts b/server/apps/immich/src/api-v1/auth/auth.service.ts deleted file mode 100644 index 7f36a0372d..0000000000 --- a/server/apps/immich/src/api-v1/auth/auth.service.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - if (!user || !user.password) { - return false; - } - return await bcrypt.compare(inputPassword, user.password); - } -} diff --git a/server/apps/immich/src/api-v1/auth/dto/jwt-payload.dto.ts b/server/apps/immich/src/api-v1/auth/dto/jwt-payload.dto.ts deleted file mode 100644 index ccdfe762e0..0000000000 --- a/server/apps/immich/src/api-v1/auth/dto/jwt-payload.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class JwtPayloadDto { - constructor(userId: string, email: string) { - this.userId = userId; - this.email = email; - } - - userId: string; - email: string; -} diff --git a/server/apps/immich/src/api-v1/communication/communication.gateway.ts b/server/apps/immich/src/api-v1/communication/communication.gateway.ts index a0f5a99952..9ca2a3e23f 100644 --- a/server/apps/immich/src/api-v1/communication/communication.gateway.ts +++ b/server/apps/immich/src/api-v1/communication/communication.gateway.ts @@ -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 { diff --git a/server/apps/immich/src/api-v1/communication/communication.module.ts b/server/apps/immich/src/api-v1/communication/communication.module.ts index 1e3fd66618..946a2f8b3c 100644 --- a/server/apps/immich/src/api-v1/communication/communication.module.ts +++ b/server/apps/immich/src/api-v1/communication/communication.module.ts @@ -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], }) diff --git a/server/apps/immich/src/api-v1/job/job.module.ts b/server/apps/immich/src/api-v1/job/job.module.ts index 96ade41e32..38c1326e27 100644 --- a/server/apps/immich/src/api-v1/job/job.module.ts +++ b/server/apps/immich/src/api-v1/job/job.module.ts @@ -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], }) diff --git a/server/apps/immich/src/api-v1/oauth/oauth.module.ts b/server/apps/immich/src/api-v1/oauth/oauth.module.ts deleted file mode 100644 index 093657c893..0000000000 --- a/server/apps/immich/src/api-v1/oauth/oauth.module.ts +++ /dev/null @@ -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 {} diff --git a/server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts b/server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts deleted file mode 100644 index 92ec9cc751..0000000000 --- a/server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts +++ /dev/null @@ -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; - let immichConfigServiceMock: jest.Mocked; - let immichJwtServiceMock: jest.Mocked; - 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; - - immichConfigServiceMock = { - config$: { subscribe: jest.fn() }, - } as unknown as jest.Mocked; - - 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'); - }); - }); -}); diff --git a/server/apps/immich/src/api-v1/oauth/oauth.service.ts b/server/apps/immich/src/api-v1/oauth/oauth.service.ts deleted file mode 100644 index 5096d5b712..0000000000 --- a/server/apps/immich/src/api-v1/oauth/oauth.service.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - return this.userCore.updateUser(user, user.id, { oauthId: '' }); - } - - public async getLogoutEndpoint(): Promise { - if (!this.config.oauth.enabled) { - return null; - } - return (await this.getClient()).issuer.metadata.end_session_endpoint || null; - } - - private async callback(url: string): Promise { - 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(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; - } -} diff --git a/server/apps/immich/src/api-v1/server-info/server-info.module.ts b/server/apps/immich/src/api-v1/server-info/server-info.module.ts index 02bbbc3ca3..1b5154695e 100644 --- a/server/apps/immich/src/api-v1/server-info/server-info.module.ts +++ b/server/apps/immich/src/api-v1/server-info/server-info.module.ts @@ -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], }) diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index b54ed270cc..4c1262ab55 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -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, ], diff --git a/server/apps/immich/src/controllers/auth.controller.ts b/server/apps/immich/src/controllers/auth.controller.ts new file mode 100644 index 0000000000..b7ab6c9519 --- /dev/null +++ b/server/apps/immich/src/controllers/auth.controller.ts @@ -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 { + 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 { + 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 { + return this.authService.changePassword(authUser, dto); + } + + @Post('logout') + async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise { + 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); + } +} diff --git a/server/apps/immich/src/controllers/index.ts b/server/apps/immich/src/controllers/index.ts index 545d8ba79f..615f841b7f 100644 --- a/server/apps/immich/src/controllers/index.ts +++ b/server/apps/immich/src/controllers/index.ts @@ -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'; diff --git a/server/apps/immich/src/api-v1/oauth/oauth.controller.ts b/server/apps/immich/src/controllers/oauth.controller.ts similarity index 52% rename from server/apps/immich/src/api-v1/oauth/oauth.controller.ts rename to server/apps/immich/src/controllers/oauth.controller.ts index d8142be274..b480e7eb23 100644 --- a/server/apps/immich/src/api-v1/oauth/oauth.controller.ts +++ b/server/apps/immich/src/controllers/oauth.controller.ts @@ -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 { - 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() diff --git a/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts b/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts index a8e60802b4..780cec682a 100644 --- a/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts +++ b/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts @@ -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 {} diff --git a/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.spec.ts b/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.spec.ts deleted file mode 100644 index 5d7dbec99c..0000000000 --- a/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.spec.ts +++ /dev/null @@ -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; - let userServiceMock: jest.Mocked; - let sut: ImmichJwtService; - - beforeEach(() => { - jwtServiceMock = { - sign: jest.fn(), - verifyAsync: jest.fn(), - } as unknown as jest.Mocked; - - userServiceMock = { - getUserById: jest.fn(), - } as unknown as jest.Mocked; - - 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'); - }); - }); -}); diff --git a/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.ts b/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.ts deleted file mode 100644 index 2fe4a966a8..0000000000 --- a/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.ts +++ /dev/null @@ -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 { - const payload = new JwtPayloadDto(user.id, user.email); - const accessToken = await this.generateToken(payload); - - return mapLoginResponse(user, accessToken); - } - - public async validateToken(accessToken: string): Promise { - try { - const payload = await this.jwtService.verifyAsync(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) { - return cookies?.[IMMICH_ACCESS_COOKIE] || null; - } - - public async validateSocket(client: Socket): Promise { - 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, - }); - } -} diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts b/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts index 4150664913..1468dbfec9 100644 --- a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts +++ b/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts @@ -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 { - 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); } } diff --git a/server/apps/immich/test/album.e2e-spec.ts b/server/apps/immich/test/album.e2e-spec.ts index 0bc660eaa7..e9de296efe 100644 --- a/server/apps/immich/test/album.e2e-spec.ts +++ b/server/apps/immich/test/album.e2e-spec.ts @@ -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(); diff --git a/server/apps/immich/test/user.e2e-spec.ts b/server/apps/immich/test/user.e2e-spec.ts index 9061bf8d89..dde23b141a 100644 --- a/server/apps/immich/test/user.e2e-spec.ts +++ b/server/apps/immich/test/user.e2e-spec.ts @@ -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(); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 1be130bf9d..212271b3a9 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -148,6 +148,292 @@ ] } }, + "/auth/login": { + "post": { + "operationId": "login", + "description": "", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginCredentialDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponseDto" + } + } + } + } + }, + "tags": [ + "Authentication" + ] + } + }, + "/auth/admin-sign-up": { + "post": { + "operationId": "adminSignUp", + "description": "", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignUpDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminSignupResponseDto" + } + } + } + }, + "400": { + "description": "The server already has an admin" + } + }, + "tags": [ + "Authentication" + ] + } + }, + "/auth/validateToken": { + "post": { + "operationId": "validateAccessToken", + "description": "", + "parameters": [], + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateAccessTokenResponseDto" + } + } + } + } + }, + "tags": [ + "Authentication" + ], + "security": [ + { + "bearer": [] + } + ] + } + }, + "/auth/change-password": { + "post": { + "operationId": "changePassword", + "description": "", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponseDto" + } + } + } + } + }, + "tags": [ + "Authentication" + ], + "security": [ + { + "bearer": [] + } + ] + } + }, + "/auth/logout": { + "post": { + "operationId": "logout", + "description": "", + "parameters": [], + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogoutResponseDto" + } + } + } + } + }, + "tags": [ + "Authentication" + ] + } + }, + "/oauth/mobile-redirect": { + "get": { + "operationId": "mobileRedirect", + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "OAuth" + ] + } + }, + "/oauth/config": { + "post": { + "operationId": "generateConfig", + "description": "", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthConfigDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthConfigResponseDto" + } + } + } + } + }, + "tags": [ + "OAuth" + ] + } + }, + "/oauth/callback": { + "post": { + "operationId": "callback", + "description": "", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthCallbackDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponseDto" + } + } + } + } + }, + "tags": [ + "OAuth" + ] + } + }, + "/oauth/link": { + "post": { + "operationId": "link", + "description": "", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthCallbackDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponseDto" + } + } + } + } + }, + "tags": [ + "OAuth" + ] + } + }, + "/oauth/unlink": { + "post": { + "operationId": "unlink", + "description": "", + "parameters": [], + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponseDto" + } + } + } + } + }, + "tags": [ + "OAuth" + ] + } + }, "/system-config": { "get": { "operationId": "getConfig", @@ -1446,152 +1732,6 @@ ] } }, - "/share": { - "get": { - "operationId": "getAllSharedLinks", - "description": "", - "parameters": [], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SharedLinkResponseDto" - } - } - } - } - } - }, - "tags": [ - "share" - ] - } - }, - "/share/me": { - "get": { - "operationId": "getMySharedLink", - "description": "", - "parameters": [], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SharedLinkResponseDto" - } - } - } - } - }, - "tags": [ - "share" - ] - } - }, - "/share/{id}": { - "get": { - "operationId": "getSharedLinkById", - "description": "", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SharedLinkResponseDto" - } - } - } - } - }, - "tags": [ - "share" - ] - }, - "delete": { - "operationId": "removeSharedLink", - "description": "", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - }, - "tags": [ - "share" - ] - }, - "patch": { - "operationId": "editSharedLink", - "description": "", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EditSharedLinkDto" - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SharedLinkResponseDto" - } - } - } - } - }, - "tags": [ - "share" - ] - } - }, "/tag": { "post": { "operationId": "create", @@ -2210,289 +2350,149 @@ ] } }, - "/auth/login": { - "post": { - "operationId": "login", - "description": "", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginCredentialDto" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginResponseDto" - } - } - } - } - }, - "tags": [ - "Authentication" - ] - } - }, - "/auth/admin-sign-up": { - "post": { - "operationId": "adminSignUp", - "description": "", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SignUpDto" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminSignupResponseDto" - } - } - } - }, - "400": { - "description": "The server already has an admin" - } - }, - "tags": [ - "Authentication" - ] - } - }, - "/auth/validateToken": { - "post": { - "operationId": "validateAccessToken", - "description": "", - "parameters": [], - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidateAccessTokenResponseDto" - } - } - } - } - }, - "tags": [ - "Authentication" - ], - "security": [ - { - "bearer": [] - } - ] - } - }, - "/auth/change-password": { - "post": { - "operationId": "changePassword", - "description": "", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordDto" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserResponseDto" - } - } - } - } - }, - "tags": [ - "Authentication" - ], - "security": [ - { - "bearer": [] - } - ] - } - }, - "/auth/logout": { - "post": { - "operationId": "logout", - "description": "", - "parameters": [], - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LogoutResponseDto" - } - } - } - } - }, - "tags": [ - "Authentication" - ] - } - }, - "/oauth/mobile-redirect": { + "/share": { "get": { - "operationId": "mobileRedirect", + "operationId": "getAllSharedLinks", "description": "", "parameters": [], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } } }, "tags": [ - "OAuth" + "share" ] } }, - "/oauth/config": { - "post": { - "operationId": "generateConfig", + "/share/me": { + "get": { + "operationId": "getMySharedLink", "description": "", "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + }, + "tags": [ + "share" + ] + } + }, + "/share/{id}": { + "get": { + "operationId": "getSharedLinkById", + "description": "", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + }, + "tags": [ + "share" + ] + }, + "delete": { + "operationId": "removeSharedLink", + "description": "", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + }, + "tags": [ + "share" + ] + }, + "patch": { + "operationId": "editSharedLink", + "description": "", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OAuthConfigDto" + "$ref": "#/components/schemas/EditSharedLinkDto" } } } }, "responses": { - "201": { + "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OAuthConfigResponseDto" + "$ref": "#/components/schemas/SharedLinkResponseDto" } } } } }, "tags": [ - "OAuth" - ] - } - }, - "/oauth/callback": { - "post": { - "operationId": "callback", - "description": "", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuthCallbackDto" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginResponseDto" - } - } - } - } - }, - "tags": [ - "OAuth" - ] - } - }, - "/oauth/link": { - "post": { - "operationId": "link", - "description": "", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuthCallbackDto" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserResponseDto" - } - } - } - } - }, - "tags": [ - "OAuth" - ] - } - }, - "/oauth/unlink": { - "post": { - "operationId": "unlink", - "description": "", - "parameters": [], - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserResponseDto" - } - } - } - } - }, - "tags": [ - "OAuth" + "share" ] } }, @@ -2848,6 +2848,262 @@ "name" ] }, + "LoginCredentialDto": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "testuser@email.com" + }, + "password": { + "type": "string", + "example": "password" + } + }, + "required": [ + "email", + "password" + ] + }, + "LoginResponseDto": { + "type": "object", + "properties": { + "accessToken": { + "type": "string", + "readOnly": true + }, + "userId": { + "type": "string", + "readOnly": true + }, + "userEmail": { + "type": "string", + "readOnly": true + }, + "firstName": { + "type": "string", + "readOnly": true + }, + "lastName": { + "type": "string", + "readOnly": true + }, + "profileImagePath": { + "type": "string", + "readOnly": true + }, + "isAdmin": { + "type": "boolean", + "readOnly": true + }, + "shouldChangePassword": { + "type": "boolean", + "readOnly": true + } + }, + "required": [ + "accessToken", + "userId", + "userEmail", + "firstName", + "lastName", + "profileImagePath", + "isAdmin", + "shouldChangePassword" + ] + }, + "SignUpDto": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "testuser@email.com" + }, + "password": { + "type": "string", + "example": "password" + }, + "firstName": { + "type": "string", + "example": "Admin" + }, + "lastName": { + "type": "string", + "example": "Doe" + } + }, + "required": [ + "email", + "password", + "firstName", + "lastName" + ] + }, + "AdminSignupResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "email", + "firstName", + "lastName", + "createdAt" + ] + }, + "ValidateAccessTokenResponseDto": { + "type": "object", + "properties": { + "authStatus": { + "type": "boolean" + } + }, + "required": [ + "authStatus" + ] + }, + "ChangePasswordDto": { + "type": "object", + "properties": { + "password": { + "type": "string", + "example": "password" + }, + "newPassword": { + "type": "string", + "example": "password" + } + }, + "required": [ + "password", + "newPassword" + ] + }, + "UserResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "profileImagePath": { + "type": "string" + }, + "shouldChangePassword": { + "type": "boolean" + }, + "isAdmin": { + "type": "boolean" + }, + "deletedAt": { + "format": "date-time", + "type": "string" + }, + "oauthId": { + "type": "string" + } + }, + "required": [ + "id", + "email", + "firstName", + "lastName", + "createdAt", + "profileImagePath", + "shouldChangePassword", + "isAdmin", + "oauthId" + ] + }, + "LogoutResponseDto": { + "type": "object", + "properties": { + "successful": { + "type": "boolean", + "readOnly": true + }, + "redirectUri": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "successful", + "redirectUri" + ] + }, + "OAuthConfigDto": { + "type": "object", + "properties": { + "redirectUri": { + "type": "string" + } + }, + "required": [ + "redirectUri" + ] + }, + "OAuthConfigResponseDto": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "passwordLoginEnabled": { + "type": "boolean" + }, + "url": { + "type": "string" + }, + "buttonText": { + "type": "string" + }, + "autoLaunch": { + "type": "boolean" + } + }, + "required": [ + "enabled", + "passwordLoginEnabled" + ] + }, + "OAuthCallbackDto": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ] + }, "SystemConfigFFmpegDto": { "type": "object", "properties": { @@ -3027,53 +3283,6 @@ "presetOptions" ] }, - "UserResponseDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "email": { - "type": "string" - }, - "firstName": { - "type": "string" - }, - "lastName": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "profileImagePath": { - "type": "string" - }, - "shouldChangePassword": { - "type": "boolean" - }, - "isAdmin": { - "type": "boolean" - }, - "deletedAt": { - "format": "date-time", - "type": "string" - }, - "oauthId": { - "type": "string" - } - }, - "required": [ - "id", - "email", - "firstName", - "lastName", - "createdAt", - "profileImagePath", - "shouldChangePassword", - "isAdmin", - "oauthId" - ] - }, "CreateUserDto": { "type": "object", "properties": { @@ -3928,29 +4137,6 @@ "assetIds" ] }, - "EditSharedLinkDto": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "expiredAt": { - "type": "string" - }, - "allowUpload": { - "type": "boolean" - }, - "allowDownload": { - "type": "boolean" - }, - "showExif": { - "type": "boolean" - }, - "isEditExpireTime": { - "type": "boolean" - } - } - }, "CreateTagDto": { "type": "object", "properties": { @@ -4119,214 +4305,28 @@ "albumId" ] }, - "LoginCredentialDto": { + "EditSharedLinkDto": { "type": "object", "properties": { - "email": { - "type": "string", - "example": "testuser@email.com" - }, - "password": { - "type": "string", - "example": "password" - } - }, - "required": [ - "email", - "password" - ] - }, - "LoginResponseDto": { - "type": "object", - "properties": { - "accessToken": { - "type": "string", - "readOnly": true - }, - "userId": { - "type": "string", - "readOnly": true - }, - "userEmail": { - "type": "string", - "readOnly": true - }, - "firstName": { - "type": "string", - "readOnly": true - }, - "lastName": { - "type": "string", - "readOnly": true - }, - "profileImagePath": { - "type": "string", - "readOnly": true - }, - "isAdmin": { - "type": "boolean", - "readOnly": true - }, - "shouldChangePassword": { - "type": "boolean", - "readOnly": true - } - }, - "required": [ - "accessToken", - "userId", - "userEmail", - "firstName", - "lastName", - "profileImagePath", - "isAdmin", - "shouldChangePassword" - ] - }, - "SignUpDto": { - "type": "object", - "properties": { - "email": { - "type": "string", - "example": "testuser@email.com" - }, - "password": { - "type": "string", - "example": "password" - }, - "firstName": { - "type": "string", - "example": "Admin" - }, - "lastName": { - "type": "string", - "example": "Doe" - } - }, - "required": [ - "email", - "password", - "firstName", - "lastName" - ] - }, - "AdminSignupResponseDto": { - "type": "object", - "properties": { - "id": { + "description": { "type": "string" }, - "email": { + "expiredAt": { "type": "string" }, - "firstName": { - "type": "string" - }, - "lastName": { - "type": "string" - }, - "createdAt": { - "type": "string" - } - }, - "required": [ - "id", - "email", - "firstName", - "lastName", - "createdAt" - ] - }, - "ValidateAccessTokenResponseDto": { - "type": "object", - "properties": { - "authStatus": { - "type": "boolean" - } - }, - "required": [ - "authStatus" - ] - }, - "ChangePasswordDto": { - "type": "object", - "properties": { - "password": { - "type": "string", - "example": "password" - }, - "newPassword": { - "type": "string", - "example": "password" - } - }, - "required": [ - "password", - "newPassword" - ] - }, - "LogoutResponseDto": { - "type": "object", - "properties": { - "successful": { - "type": "boolean", - "readOnly": true - }, - "redirectUri": { - "type": "string", - "readOnly": true - } - }, - "required": [ - "successful", - "redirectUri" - ] - }, - "OAuthConfigDto": { - "type": "object", - "properties": { - "redirectUri": { - "type": "string" - } - }, - "required": [ - "redirectUri" - ] - }, - "OAuthConfigResponseDto": { - "type": "object", - "properties": { - "enabled": { + "allowUpload": { "type": "boolean" }, - "passwordLoginEnabled": { + "allowDownload": { "type": "boolean" }, - "url": { - "type": "string" + "showExif": { + "type": "boolean" }, - "buttonText": { - "type": "string" - }, - "autoLaunch": { + "isEditExpireTime": { "type": "boolean" } - }, - "required": [ - "enabled", - "passwordLoginEnabled" - ] - }, - "OAuthCallbackDto": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": [ - "url" - ] + } }, "DeviceTypeEnum": { "type": "string", diff --git a/server/apps/immich/src/config/jwt.config.ts b/server/libs/domain/src/auth/auth.config.ts similarity index 73% rename from server/apps/immich/src/config/jwt.config.ts rename to server/libs/domain/src/auth/auth.config.ts index d0eee0ddd0..71dcd3a98c 100644 --- a/server/apps/immich/src/config/jwt.config.ts +++ b/server/libs/domain/src/auth/auth.config.ts @@ -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, diff --git a/server/apps/immich/src/constants/jwt.constant.ts b/server/libs/domain/src/auth/auth.constant.ts similarity index 100% rename from server/apps/immich/src/constants/jwt.constant.ts rename to server/libs/domain/src/auth/auth.constant.ts diff --git a/server/libs/domain/src/auth/auth.core.ts b/server/libs/domain/src/auth/auth.core.ts new file mode 100644 index 0000000000..109fac8dab --- /dev/null +++ b/server/libs/domain/src/auth/auth.core.ts @@ -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) { + return cookies?.[IMMICH_ACCESS_COOKIE] || null; + } + + private generateToken(payload: JwtPayloadDto) { + return this.cryptoRepository.signJwt({ ...payload }); + } +} diff --git a/server/libs/domain/src/auth/auth.service.spec.ts b/server/libs/domain/src/auth/auth.service.spec.ts new file mode 100644 index 0000000000..db71cb54b9 --- /dev/null +++ b/server/libs/domain/src/auth/auth.service.spec.ts @@ -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; + let userMock: jest.Mocked; + let configMock: jest.Mocked; + 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(); + }); + }); +}); diff --git a/server/libs/domain/src/auth/auth.service.ts b/server/libs/domain/src/auth/auth.service.ts new file mode 100644 index 0000000000..0fcb799672 --- /dev/null +++ b/server/libs/domain/src/auth/auth.service.ts @@ -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 { + 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 { + 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 { + 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(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) { + return this.authCore.extractJwtFromCookie(cookies); + } + + extractJwtFromHeader(headers: IncomingHttpHeaders) { + return this.authCore.extractJwtFromHeader(headers); + } +} diff --git a/server/libs/domain/src/auth/crypto.repository.ts b/server/libs/domain/src/auth/crypto.repository.ts index bce8b221c0..e12240c74d 100644 --- a/server/libs/domain/src/auth/crypto.repository.ts +++ b/server/libs/domain/src/auth/crypto.repository.ts @@ -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; compareSync(data: Buffer | string, encrypted: string): boolean; + signJwt(payload: string | Buffer | object, options?: JwtSignOptions): string; + verifyJwtAsync(token: string, options?: JwtVerifyOptions): Promise; } diff --git a/server/apps/immich/src/api-v1/auth/dto/change-password.dto.ts b/server/libs/domain/src/auth/dto/change-password.dto.ts similarity index 100% rename from server/apps/immich/src/api-v1/auth/dto/change-password.dto.ts rename to server/libs/domain/src/auth/dto/change-password.dto.ts diff --git a/server/libs/domain/src/auth/dto/index.ts b/server/libs/domain/src/auth/dto/index.ts index 8b7a427587..87c5cc3707 100644 --- a/server/libs/domain/src/auth/dto/index.ts +++ b/server/libs/domain/src/auth/dto/index.ts @@ -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'; diff --git a/server/libs/domain/src/auth/dto/jwt-payload.dto.ts b/server/libs/domain/src/auth/dto/jwt-payload.dto.ts new file mode 100644 index 0000000000..4f3b7993fc --- /dev/null +++ b/server/libs/domain/src/auth/dto/jwt-payload.dto.ts @@ -0,0 +1,4 @@ +export class JwtPayloadDto { + userId!: string; + email!: string; +} diff --git a/server/apps/immich/src/api-v1/auth/dto/login-credential.dto.spec.ts b/server/libs/domain/src/auth/dto/login-credential.dto.spec.ts similarity index 100% rename from server/apps/immich/src/api-v1/auth/dto/login-credential.dto.spec.ts rename to server/libs/domain/src/auth/dto/login-credential.dto.spec.ts diff --git a/server/apps/immich/src/api-v1/auth/dto/login-credential.dto.ts b/server/libs/domain/src/auth/dto/login-credential.dto.ts similarity index 100% rename from server/apps/immich/src/api-v1/auth/dto/login-credential.dto.ts rename to server/libs/domain/src/auth/dto/login-credential.dto.ts diff --git a/server/apps/immich/src/api-v1/auth/dto/sign-up.dto.spec.ts b/server/libs/domain/src/auth/dto/sign-up.dto.spec.ts similarity index 100% rename from server/apps/immich/src/api-v1/auth/dto/sign-up.dto.spec.ts rename to server/libs/domain/src/auth/dto/sign-up.dto.spec.ts diff --git a/server/apps/immich/src/api-v1/auth/dto/sign-up.dto.ts b/server/libs/domain/src/auth/dto/sign-up.dto.ts similarity index 100% rename from server/apps/immich/src/api-v1/auth/dto/sign-up.dto.ts rename to server/libs/domain/src/auth/dto/sign-up.dto.ts diff --git a/server/libs/domain/src/auth/index.ts b/server/libs/domain/src/auth/index.ts index bdef02bdaa..be6def62bb 100644 --- a/server/libs/domain/src/auth/index.ts +++ b/server/libs/domain/src/auth/index.ts @@ -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'; diff --git a/server/apps/immich/src/api-v1/auth/response-dto/admin-signup-response.dto.ts b/server/libs/domain/src/auth/response-dto/admin-signup-response.dto.ts similarity index 87% rename from server/apps/immich/src/api-v1/auth/response-dto/admin-signup-response.dto.ts rename to server/libs/domain/src/auth/response-dto/admin-signup-response.dto.ts index 0577f70a52..68216b4df2 100644 --- a/server/apps/immich/src/api-v1/auth/response-dto/admin-signup-response.dto.ts +++ b/server/libs/domain/src/auth/response-dto/admin-signup-response.dto.ts @@ -1,4 +1,4 @@ -import { UserEntity } from '@app/infra'; +import { UserEntity } from '@app/infra/db/entities'; export class AdminSignupResponseDto { id!: string; diff --git a/server/libs/domain/src/auth/response-dto/index.ts b/server/libs/domain/src/auth/response-dto/index.ts new file mode 100644 index 0000000000..1ef1ef8b04 --- /dev/null +++ b/server/libs/domain/src/auth/response-dto/index.ts @@ -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'; diff --git a/server/apps/immich/src/api-v1/auth/response-dto/login-response.dto.ts b/server/libs/domain/src/auth/response-dto/login-response.dto.ts similarity index 94% rename from server/apps/immich/src/api-v1/auth/response-dto/login-response.dto.ts rename to server/libs/domain/src/auth/response-dto/login-response.dto.ts index 96986d1a1a..823f94cca6 100644 --- a/server/apps/immich/src/api-v1/auth/response-dto/login-response.dto.ts +++ b/server/libs/domain/src/auth/response-dto/login-response.dto.ts @@ -1,4 +1,4 @@ -import { UserEntity } from '@app/infra'; +import { UserEntity } from '@app/infra/db/entities'; import { ApiResponseProperty } from '@nestjs/swagger'; export class LoginResponseDto { diff --git a/server/apps/immich/src/api-v1/auth/response-dto/logout-response.dto.ts b/server/libs/domain/src/auth/response-dto/logout-response.dto.ts similarity index 100% rename from server/apps/immich/src/api-v1/auth/response-dto/logout-response.dto.ts rename to server/libs/domain/src/auth/response-dto/logout-response.dto.ts diff --git a/server/apps/immich/src/api-v1/auth/response-dto/validate-asset-token-response.dto,.ts b/server/libs/domain/src/auth/response-dto/validate-asset-token-response.dto.ts similarity index 100% rename from server/apps/immich/src/api-v1/auth/response-dto/validate-asset-token-response.dto,.ts rename to server/libs/domain/src/auth/response-dto/validate-asset-token-response.dto.ts diff --git a/server/libs/domain/src/domain.module.ts b/server/libs/domain/src/domain.module.ts index 0a9b278b4c..50fe668bc5 100644 --- a/server/libs/domain/src/domain.module.ts +++ b/server/libs/domain/src/domain.module.ts @@ -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, diff --git a/server/libs/domain/src/index.ts b/server/libs/domain/src/index.ts index f8160e5c65..0f1706cf47 100644 --- a/server/libs/domain/src/index.ts +++ b/server/libs/domain/src/index.ts @@ -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'; diff --git a/server/libs/domain/src/oauth/dto/index.ts b/server/libs/domain/src/oauth/dto/index.ts new file mode 100644 index 0000000000..7143c36885 --- /dev/null +++ b/server/libs/domain/src/oauth/dto/index.ts @@ -0,0 +1,2 @@ +export * from './oauth-auth-code.dto'; +export * from './oauth-config.dto'; diff --git a/server/apps/immich/src/api-v1/oauth/dto/oauth-auth-code.dto.ts b/server/libs/domain/src/oauth/dto/oauth-auth-code.dto.ts similarity index 100% rename from server/apps/immich/src/api-v1/oauth/dto/oauth-auth-code.dto.ts rename to server/libs/domain/src/oauth/dto/oauth-auth-code.dto.ts diff --git a/server/apps/immich/src/api-v1/oauth/dto/oauth-config.dto.ts b/server/libs/domain/src/oauth/dto/oauth-config.dto.ts similarity index 100% rename from server/apps/immich/src/api-v1/oauth/dto/oauth-config.dto.ts rename to server/libs/domain/src/oauth/dto/oauth-config.dto.ts diff --git a/server/libs/domain/src/oauth/index.ts b/server/libs/domain/src/oauth/index.ts new file mode 100644 index 0000000000..39f22726b9 --- /dev/null +++ b/server/libs/domain/src/oauth/index.ts @@ -0,0 +1,4 @@ +export * from './dto'; +export * from './oauth.constants'; +export * from './oauth.service'; +export * from './response-dto'; diff --git a/server/libs/domain/src/oauth/oauth.constants.ts b/server/libs/domain/src/oauth/oauth.constants.ts new file mode 100644 index 0000000000..3a8932880b --- /dev/null +++ b/server/libs/domain/src/oauth/oauth.constants.ts @@ -0,0 +1 @@ +export const MOBILE_REDIRECT = 'app.immich:/'; diff --git a/server/libs/domain/src/oauth/oauth.core.ts b/server/libs/domain/src/oauth/oauth.core.ts new file mode 100644 index 0000000000..d21eaad5d2 --- /dev/null +++ b/server/libs/domain/src/oauth/oauth.core.ts @@ -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 { + 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 { + 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(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 { + 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; + } +} diff --git a/server/libs/domain/src/oauth/oauth.service.spec.ts b/server/libs/domain/src/oauth/oauth.service.spec.ts new file mode 100644 index 0000000000..5408ee0392 --- /dev/null +++ b/server/libs/domain/src/oauth/oauth.service.spec.ts @@ -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; + let cryptoMock: jest.Mocked; + let configMock: jest.Mocked; + 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'); + }); + }); +}); diff --git a/server/libs/domain/src/oauth/oauth.service.ts b/server/libs/domain/src/oauth/oauth.service.ts new file mode 100644 index 0000000000..f054f019e8 --- /dev/null +++ b/server/libs/domain/src/oauth/oauth.service.ts @@ -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 { + 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 { + 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 { + return this.userCore.updateUser(user, user.id, { oauthId: '' }); + } + + public async getLogoutEndpoint(): Promise { + return this.oauthCore.getLogoutEndpoint(); + } +} diff --git a/server/libs/domain/src/oauth/response-dto/index.ts b/server/libs/domain/src/oauth/response-dto/index.ts new file mode 100644 index 0000000000..767bdfcaec --- /dev/null +++ b/server/libs/domain/src/oauth/response-dto/index.ts @@ -0,0 +1 @@ +export * from './oauth-config-response.dto'; diff --git a/server/apps/immich/src/api-v1/oauth/response-dto/oauth-config-response.dto.ts b/server/libs/domain/src/oauth/response-dto/oauth-config-response.dto.ts similarity index 100% rename from server/apps/immich/src/api-v1/oauth/response-dto/oauth-config-response.dto.ts rename to server/libs/domain/src/oauth/response-dto/oauth-config-response.dto.ts diff --git a/server/libs/domain/src/system-config/index.ts b/server/libs/domain/src/system-config/index.ts index d9a12bf372..e5a685a30f 100644 --- a/server/libs/domain/src/system-config/index.ts +++ b/server/libs/domain/src/system-config/index.ts @@ -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'; diff --git a/server/libs/domain/src/system-config/system-config.datetime-variables.ts b/server/libs/domain/src/system-config/system-config.constants.ts similarity index 92% rename from server/libs/domain/src/system-config/system-config.datetime-variables.ts rename to server/libs/domain/src/system-config/system-config.constants.ts index e6d9bc723d..45b880257e 100644 --- a/server/libs/domain/src/system-config/system-config.datetime-variables.ts +++ b/server/libs/domain/src/system-config/system-config.constants.ts @@ -18,3 +18,5 @@ export const supportedPresetTokens = [ '{{y}}-{{MMM}}-{{dd}}/{{filename}}', '{{y}}-{{MMMM}}-{{dd}}/{{filename}}', ]; + +export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG'; diff --git a/server/libs/domain/src/system-config/system-config.core.ts b/server/libs/domain/src/system-config/system-config.core.ts index 36e1b548f3..b43a69b300 100644 --- a/server/libs/domain/src/system-config/system-config.core.ts +++ b/server/libs/domain/src/system-config/system-config.core.ts @@ -37,12 +37,14 @@ const defaults: SystemConfig = Object.freeze({ }, }); +const singleton = new Subject(); + @Injectable() export class SystemConfigCore { private logger = new Logger(SystemConfigCore.name); private validators: SystemConfigValidator[] = []; - public config$ = new Subject(); + public config$ = singleton; constructor(private repository: ISystemConfigRepository) {} diff --git a/server/libs/domain/src/system-config/system-config.service.spec.ts b/server/libs/domain/src/system-config/system-config.service.spec.ts index 6a2b5ce953..c7880d8796 100644 --- a/server/libs/domain/src/system-config/system-config.service.spec.ts +++ b/server/libs/domain/src/system-config/system-config.service.spec.ts @@ -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'; diff --git a/server/libs/domain/src/system-config/system-config.service.ts b/server/libs/domain/src/system-config/system-config.service.ts index 2bf6bd5eda..a03602ed5f 100644 --- a/server/libs/domain/src/system-config/system-config.service.ts +++ b/server/libs/domain/src/system-config/system-config.service.ts @@ -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'; diff --git a/server/libs/domain/test/crypto.repository.mock.ts b/server/libs/domain/test/crypto.repository.mock.ts index 6c747b2087..fe7e1dccc9 100644 --- a/server/libs/domain/test/crypto.repository.mock.ts +++ b/server/libs/domain/test/crypto.repository.mock.ts @@ -5,5 +5,7 @@ export const newCryptoRepositoryMock = (): jest.Mocked => { 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' }), }; }; diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index a94b24a2c8..f612663c09 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -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;', + ], + }, }; diff --git a/server/libs/infra/src/auth/crypto.repository.ts b/server/libs/infra/src/auth/crypto.repository.ts index b628463d89..83d99a3ec8 100644 --- a/server/libs/infra/src/auth/crypto.repository.ts +++ b/server/libs/infra/src/auth/crypto.repository.ts @@ -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(token: string, options?: JwtVerifyOptions): Promise { + return this.jwtService.verifyAsync(token, options); + } +} diff --git a/server/libs/infra/src/infra.module.ts b/server/libs/infra/src/infra.module.ts index f3be0ad5f0..b8371bd67d 100644 --- a/server/libs/infra/src/infra.module.ts +++ b/server/libs/infra/src/infra.module.ts @@ -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 {} diff --git a/server/package.json b/server/package.json index 490526d7a3..b433840221 100644 --- a/server/package.json +++ b/server/package.json @@ -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",