diff --git a/server/src/domain/auth/auth.constant.ts b/server/src/domain/auth/auth.constant.ts index 4f3106cba..6f63cc1b3 100644 --- a/server/src/domain/auth/auth.constant.ts +++ b/server/src/domain/auth/auth.constant.ts @@ -1,3 +1,5 @@ +export const MOBILE_REDIRECT = 'app.immich:/'; +export const LOGIN_URL = '/auth/login?autoLaunch=0'; export const IMMICH_ACCESS_COOKIE = 'immich_access_token'; export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type'; export const IMMICH_API_KEY_NAME = 'api_key'; diff --git a/server/src/domain/auth/auth.core.ts b/server/src/domain/auth/auth.core.ts deleted file mode 100644 index de75a07eb..000000000 --- a/server/src/domain/auth/auth.core.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { SystemConfig, UserEntity } from '@app/infra/entities'; -import { ICryptoRepository } from '../crypto/crypto.repository'; -import { ISystemConfigRepository } from '../system-config'; -import { SystemConfigCore } from '../system-config/system-config.core'; -import { IUserTokenRepository, UserTokenCore } from '../user-token'; -import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant'; -import { LoginResponseDto, mapLoginResponse } from './response-dto'; - -export interface LoginDetails { - isSecure: boolean; - clientIp: string; - deviceType: string; - deviceOS: string; -} - -export class AuthCore { - private userTokenCore: UserTokenCore; - constructor( - private cryptoRepository: ICryptoRepository, - configRepository: ISystemConfigRepository, - userTokenRepository: IUserTokenRepository, - private config: SystemConfig, - ) { - this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository); - const configCore = new SystemConfigCore(configRepository); - configCore.config$.subscribe((config) => (this.config = config)); - } - - isPasswordLoginEnabled() { - return this.config.passwordLogin.enabled; - } - - getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) { - const maxAge = 400 * 24 * 3600; // 400 days - - let authTypeCookie = ''; - let accessTokenCookie = ''; - - if (isSecure) { - accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - } else { - accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - } - return [accessTokenCookie, authTypeCookie]; - } - - async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) { - const accessToken = await this.userTokenCore.create(user, loginDetails); - const response = mapLoginResponse(user, accessToken); - const cookie = this.getCookies(response, authType, loginDetails); - return { response, cookie }; - } - - validatePassword(inputPassword: string, user: UserEntity): boolean { - if (!user || !user.password) { - return false; - } - return this.cryptoRepository.compareBcrypt(inputPassword, user.password); - } -} diff --git a/server/src/domain/auth/auth.service.spec.ts b/server/src/domain/auth/auth.service.spec.ts index 43d31f15b..c191807f1 100644 --- a/server/src/domain/auth/auth.service.spec.ts +++ b/server/src/domain/auth/auth.service.spec.ts @@ -1,4 +1,4 @@ -import { SystemConfig, UserEntity } from '@app/infra/entities'; +import { UserEntity } from '@app/infra/entities'; import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { authStub, @@ -23,10 +23,10 @@ import { ICryptoRepository } from '../crypto/crypto.repository'; import { ISharedLinkRepository } from '../shared-link'; import { ISystemConfigRepository } from '../system-config'; import { IUserRepository } from '../user'; -import { IUserTokenRepository } from '../user-token'; import { AuthType } from './auth.constant'; import { AuthService } from './auth.service'; import { AuthUserDto, SignUpDto } from './dto'; +import { IUserTokenRepository } from './user-token.repository'; // const token = Buffer.from('my-api-key', 'utf8').toString('base64'); @@ -55,7 +55,6 @@ describe('AuthService', () => { let shareMock: jest.Mocked; let keyMock: jest.Mocked; let callbackMock: jest.Mock; - let create: (config: SystemConfig) => AuthService; afterEach(() => { jest.resetModules(); @@ -87,9 +86,7 @@ describe('AuthService', () => { shareMock = newSharedLinkRepositoryMock(); keyMock = newKeyRepositoryMock(); - create = (config) => new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock, config); - - sut = create(systemConfigStub.enabled); + sut = new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock); }); it('should be defined', () => { @@ -98,8 +95,7 @@ describe('AuthService', () => { describe('login', () => { it('should throw an error if password login is disabled', async () => { - sut = create(systemConfigStub.disabled); - + configMock.load.mockResolvedValue(systemConfigStub.disabled); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); }); @@ -191,8 +187,8 @@ describe('AuthService', () => { describe('logout', () => { it('should return the end session endpoint', async () => { + configMock.load.mockResolvedValue(systemConfigStub.enabled); const authUser = { id: '123' } as AuthUserDto; - await expect(sut.logout(authUser, AuthType.OAUTH)).resolves.toEqual({ successful: true, redirectUri: 'http://end-session-endpoint', @@ -385,4 +381,132 @@ describe('AuthService', () => { expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'token-1'); }); }); + + describe('getMobileRedirect', () => { + it('should pass along the query params', () => { + expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456'); + }); + + it('should work if called without query params', () => { + expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:/?'); + }); + }); + + describe('generateConfig', () => { + it('should work when oauth is not configured', async () => { + configMock.load.mockResolvedValue(systemConfigStub.disabled); + await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({ + enabled: false, + passwordLoginEnabled: false, + }); + }); + + it('should generate the config', async () => { + configMock.load.mockResolvedValue(systemConfigStub.enabled); + await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({ + enabled: true, + buttonText: 'OAuth', + url: 'http://authorization-url', + autoLaunch: false, + passwordLoginEnabled: true, + }); + }); + }); + + describe('callback', () => { + it('should throw an error if OAuth is not enabled', async () => { + await expect(sut.callback({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should not allow auto registering', async () => { + configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister); + userMock.getByEmail.mockResolvedValue(null); + await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(userMock.getByEmail).toHaveBeenCalledTimes(1); + }); + + it('should link an existing user', async () => { + configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister); + userMock.getByEmail.mockResolvedValue(userEntityStub.user1); + userMock.update.mockResolvedValue(userEntityStub.user1); + userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); + + await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( + loginResponseStub.user1oauth, + ); + + expect(userMock.getByEmail).toHaveBeenCalledTimes(1); + expect(userMock.update).toHaveBeenCalledWith(userEntityStub.user1.id, { oauthId: sub }); + }); + + it('should allow auto registering by default', async () => { + configMock.load.mockResolvedValue(systemConfigStub.enabled); + userMock.getByEmail.mockResolvedValue(null); + userMock.getAdmin.mockResolvedValue(userEntityStub.user1); + userMock.create.mockResolvedValue(userEntityStub.user1); + userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); + + await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).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 () => { + configMock.load.mockResolvedValue(systemConfigStub.override); + userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1); + userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); + + await sut.callback({ url: `app.immich:/?code=abc123` }, loginDetails); + + expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' }); + }); + + it('should use the mobile redirect override for ios urls with multiple slashes', async () => { + configMock.load.mockResolvedValue(systemConfigStub.override); + userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1); + userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); + + await sut.callback({ url: `app.immich:///?code=abc123` }, loginDetails); + + expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' }); + }); + }); + + describe('link', () => { + it('should link an account', async () => { + configMock.load.mockResolvedValue(systemConfigStub.enabled); + userMock.update.mockResolvedValue(userEntityStub.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 () => { + configMock.load.mockResolvedValue(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 () => { + configMock.load.mockResolvedValue(systemConfigStub.enabled); + userMock.update.mockResolvedValue(userEntityStub.user1); + + await sut.unlink(authStub.user1); + + expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: '' }); + }); + }); }); diff --git a/server/src/domain/auth/auth.service.ts b/server/src/domain/auth/auth.service.ts index 4f45dc5a2..200ca0a74 100644 --- a/server/src/domain/auth/auth.service.ts +++ b/server/src/domain/auth/auth.service.ts @@ -1,4 +1,4 @@ -import { SystemConfig } from '@app/infra/entities'; +import { SystemConfig, UserEntity } from '@app/infra/entities'; import { BadRequestException, Inject, @@ -9,99 +9,112 @@ import { } from '@nestjs/common'; import cookieParser from 'cookie'; import { IncomingHttpHeaders } from 'http'; +import { DateTime } from 'luxon'; +import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client'; import { IKeyRepository } from '../api-key'; import { ICryptoRepository } from '../crypto/crypto.repository'; -import { OAuthCore } from '../oauth/oauth.core'; import { ISharedLinkRepository } from '../shared-link'; -import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config'; -import { IUserRepository, UserCore } from '../user'; -import { IUserTokenRepository, UserTokenCore } from '../user-token'; -import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER } from './auth.constant'; -import { AuthCore, LoginDetails } from './auth.core'; -import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto'; +import { ISystemConfigRepository } from '../system-config'; +import { SystemConfigCore } from '../system-config/system-config.core'; +import { IUserRepository, UserCore, UserResponseDto } from '../user'; +import { + AuthType, + IMMICH_ACCESS_COOKIE, + IMMICH_API_KEY_HEADER, + IMMICH_AUTH_TYPE_COOKIE, + LOGIN_URL, + MOBILE_REDIRECT, +} from './auth.constant'; +import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, OAuthCallbackDto, OAuthConfigDto, SignUpDto } from './dto'; import { AdminSignupResponseDto, AuthDeviceResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse, + mapLoginResponse, mapUserToken, + OAuthConfigResponseDto, } from './response-dto'; +import { IUserTokenRepository } from './user-token.repository'; + +export interface LoginDetails { + isSecure: boolean; + clientIp: string; + deviceType: string; + deviceOS: string; +} + +interface LoginResponse { + response: LoginResponseDto; + cookie: string[]; +} + +interface OAuthProfile extends UserinfoResponse { + email: string; +} @Injectable() export class AuthService { - private userTokenCore: UserTokenCore; - private authCore: AuthCore; - private oauthCore: OAuthCore; private userCore: UserCore; - + private configCore: SystemConfigCore; private logger = new Logger(AuthService.name); constructor( @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IUserRepository) userRepository: IUserRepository, - @Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository, + @Inject(IUserTokenRepository) private userTokenRepository: IUserTokenRepository, @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository, @Inject(IKeyRepository) private keyRepository: IKeyRepository, - @Inject(INITIAL_SYSTEM_CONFIG) - initialConfig: SystemConfig, ) { - this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository); - this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig); - this.oauthCore = new OAuthCore(configRepository, initialConfig); + this.configCore = new SystemConfigCore(configRepository); this.userCore = new UserCore(userRepository, cryptoRepository); + + custom.setHttpOptionsDefaults({ timeout: 30000 }); } - public async login( - loginCredential: LoginCredentialDto, - loginDetails: LoginDetails, - ): Promise<{ response: LoginResponseDto; cookie: string[] }> { - if (!this.authCore.isPasswordLoginEnabled()) { + async login(dto: LoginCredentialDto, details: LoginDetails): Promise { + const config = await this.configCore.getConfig(); + if (!config.passwordLogin.enabled) { throw new UnauthorizedException('Password login has been disabled'); } - let user = await this.userCore.getByEmail(loginCredential.email, true); + let user = await this.userCore.getByEmail(dto.email, true); if (user) { - const isAuthenticated = this.authCore.validatePassword(loginCredential.password, user); + const isAuthenticated = this.validatePassword(dto.password, user); if (!isAuthenticated) { user = null; } } if (!user) { - this.logger.warn( - `Failed login attempt for user ${loginCredential.email} from ip address ${loginDetails.clientIp}`, - ); + this.logger.warn(`Failed login attempt for user ${dto.email} from ip address ${details.clientIp}`); throw new BadRequestException('Incorrect email or password'); } - return this.authCore.createLoginResponse(user, AuthType.PASSWORD, loginDetails); + return this.createLoginResponse(user, AuthType.PASSWORD, details); } - public async logout(authUser: AuthUserDto, authType: AuthType): Promise { + async logout(authUser: AuthUserDto, authType: AuthType): Promise { if (authUser.accessTokenId) { - await this.userTokenCore.delete(authUser.id, authUser.accessTokenId); + await this.userTokenRepository.delete(authUser.id, authUser.accessTokenId); } - 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' }; + return { + successful: true, + redirectUri: await this.getLogoutEndpoint(authType), + }; } - public async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) { + 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 = this.authCore.validatePassword(password, user); + const valid = this.validatePassword(password, user); if (!valid) { throw new BadRequestException('Wrong password'); } @@ -109,7 +122,7 @@ export class AuthService { return this.userCore.updateUser(authUser, authUser.id, { password: newPassword }); } - public async adminSignUp(dto: SignUpDto): Promise { + async adminSignUp(dto: SignUpDto): Promise { const adminUser = await this.userCore.getAdmin(); if (adminUser) { @@ -133,7 +146,7 @@ export class AuthService { } } - public async validate(headers: IncomingHttpHeaders, params: Record): Promise { + async validate(headers: IncomingHttpHeaders, params: Record): Promise { const shareKey = (headers['x-immich-share-key'] || params.key) as string; const userToken = (headers['x-immich-user-token'] || params.userToken || @@ -146,7 +159,7 @@ export class AuthService { } if (userToken) { - return this.userTokenCore.validate(userToken); + return this.validateUserToken(userToken); } if (apiKey) { @@ -157,24 +170,155 @@ export class AuthService { } async getDevices(authUser: AuthUserDto): Promise { - const userTokens = await this.userTokenCore.getAll(authUser.id); + const userTokens = await this.userTokenRepository.getAll(authUser.id); return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId)); } async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise { - await this.userTokenCore.delete(authUser.id, deviceId); + await this.userTokenRepository.delete(authUser.id, deviceId); } async logoutDevices(authUser: AuthUserDto): Promise { - const devices = await this.userTokenCore.getAll(authUser.id); + const devices = await this.userTokenRepository.getAll(authUser.id); for (const device of devices) { if (device.id === authUser.accessTokenId) { continue; } - await this.userTokenCore.delete(authUser.id, device.id); + await this.userTokenRepository.delete(authUser.id, device.id); } } + getMobileRedirect(url: string) { + return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`; + } + + async generateConfig(dto: OAuthConfigDto): Promise { + const config = await this.configCore.getConfig(); + const response = { + enabled: config.oauth.enabled, + passwordLoginEnabled: config.passwordLogin.enabled, + }; + + if (!response.enabled) { + return response; + } + + const { scope, buttonText, autoLaunch } = config.oauth; + const url = (await this.getOAuthClient(config)).authorizationUrl({ + redirect_uri: this.normalize(config, dto.redirectUri), + scope, + state: generators.state(), + }); + + return { ...response, buttonText, url, autoLaunch }; + } + + async callback( + dto: OAuthCallbackDto, + loginDetails: LoginDetails, + ): Promise<{ response: LoginResponseDto; cookie: string[] }> { + const config = await this.configCore.getConfig(); + const profile = await this.getOAuthProfile(config, 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 (!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.createLoginResponse(user, AuthType.OAUTH, loginDetails); + } + + async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise { + const config = await this.configCore.getConfig(); + const { sub: oauthId } = await this.getOAuthProfile(config, 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 }); + } + + async unlink(user: AuthUserDto): Promise { + return this.userCore.updateUser(user, user.id, { oauthId: '' }); + } + + private async getLogoutEndpoint(authType: AuthType): Promise { + if (authType !== AuthType.OAUTH) { + return LOGIN_URL; + } + + const config = await this.configCore.getConfig(); + if (!config.oauth.enabled) { + return LOGIN_URL; + } + + const client = await this.getOAuthClient(config); + return client.issuer.metadata.end_session_endpoint || LOGIN_URL; + } + + private async getOAuthProfile(config: SystemConfig, url: string): Promise { + const redirectUri = this.normalize(config, url.split('?')[0]); + const client = await this.getOAuthClient(config); + const params = client.callbackParams(url); + const tokens = await client.callback(redirectUri, params, { state: params.state }); + return client.userinfo(tokens.access_token || ''); + } + + private async getOAuthClient(config: SystemConfig) { + const { enabled, clientId, clientSecret, issuerUrl } = 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(config: SystemConfig, redirectUri: string) { + const isMobile = redirectUri.startsWith(MOBILE_REDIRECT); + const { mobileRedirectUri, mobileOverrideEnabled } = config.oauth; + if (isMobile && mobileOverrideEnabled && mobileRedirectUri) { + return mobileRedirectUri; + } + return redirectUri; + } + private getBearerToken(headers: IncomingHttpHeaders): string | null { const [type, token] = (headers.authorization || '').split(' '); if (type.toLowerCase() === 'bearer') { @@ -232,4 +376,68 @@ export class AuthService { throw new UnauthorizedException('Invalid API key'); } + + private validatePassword(inputPassword: string, user: UserEntity): boolean { + if (!user || !user.password) { + return false; + } + return this.cryptoRepository.compareBcrypt(inputPassword, user.password); + } + + private async validateUserToken(tokenValue: string): Promise { + const hashedToken = this.cryptoRepository.hashSha256(tokenValue); + let token = await this.userTokenRepository.getByToken(hashedToken); + + if (token?.user) { + const now = DateTime.now(); + const updatedAt = DateTime.fromJSDate(token.updatedAt); + const diff = now.diff(updatedAt, ['hours']); + if (diff.hours > 1) { + token = await this.userTokenRepository.save({ ...token, updatedAt: new Date() }); + } + + return { + ...token.user, + isPublicUser: false, + isAllowUpload: true, + isAllowDownload: true, + isShowExif: true, + accessTokenId: token.id, + }; + } + + throw new UnauthorizedException('Invalid user token'); + } + + private async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) { + const key = this.cryptoRepository.randomBytes(32).toString('base64').replace(/\W/g, ''); + const token = this.cryptoRepository.hashSha256(key); + + await this.userTokenRepository.create({ + token, + user, + deviceOS: loginDetails.deviceOS, + deviceType: loginDetails.deviceType, + }); + + const response = mapLoginResponse(user, key); + const cookie = this.getCookies(response, authType, loginDetails); + return { response, cookie }; + } + + private getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) { + const maxAge = 400 * 24 * 3600; // 400 days + + let authTypeCookie = ''; + let accessTokenCookie = ''; + + if (isSecure) { + accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; + authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; + } else { + accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; + authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; + } + return [accessTokenCookie, authTypeCookie]; + } } diff --git a/server/src/domain/auth/dto/index.ts b/server/src/domain/auth/dto/index.ts index 323d12f8f..59a65770a 100644 --- a/server/src/domain/auth/dto/index.ts +++ b/server/src/domain/auth/dto/index.ts @@ -1,4 +1,6 @@ export * from './auth-user.dto'; export * from './change-password.dto'; export * from './login-credential.dto'; +export * from './oauth-auth-code.dto'; +export * from './oauth-config.dto'; export * from './sign-up.dto'; diff --git a/server/src/domain/oauth/dto/oauth-auth-code.dto.ts b/server/src/domain/auth/dto/oauth-auth-code.dto.ts similarity index 100% rename from server/src/domain/oauth/dto/oauth-auth-code.dto.ts rename to server/src/domain/auth/dto/oauth-auth-code.dto.ts diff --git a/server/src/domain/oauth/dto/oauth-config.dto.ts b/server/src/domain/auth/dto/oauth-config.dto.ts similarity index 100% rename from server/src/domain/oauth/dto/oauth-config.dto.ts rename to server/src/domain/auth/dto/oauth-config.dto.ts diff --git a/server/src/domain/auth/index.ts b/server/src/domain/auth/index.ts index 24cc5a995..2f868537c 100644 --- a/server/src/domain/auth/index.ts +++ b/server/src/domain/auth/index.ts @@ -1,5 +1,5 @@ export * from './auth.constant'; -export * from './auth.core'; export * from './auth.service'; export * from './dto'; export * from './response-dto'; +export * from './user-token.repository'; diff --git a/server/src/domain/auth/response-dto/index.ts b/server/src/domain/auth/response-dto/index.ts index 4bbfc7139..491c957fd 100644 --- a/server/src/domain/auth/response-dto/index.ts +++ b/server/src/domain/auth/response-dto/index.ts @@ -2,4 +2,5 @@ export * from './admin-signup-response.dto'; export * from './auth-device-response.dto'; export * from './login-response.dto'; export * from './logout-response.dto'; +export * from './oauth-config-response.dto'; export * from './validate-asset-token-response.dto'; diff --git a/server/src/domain/oauth/response-dto/oauth-config-response.dto.ts b/server/src/domain/auth/response-dto/oauth-config-response.dto.ts similarity index 100% rename from server/src/domain/oauth/response-dto/oauth-config-response.dto.ts rename to server/src/domain/auth/response-dto/oauth-config-response.dto.ts diff --git a/server/src/domain/user-token/user-token.repository.ts b/server/src/domain/auth/user-token.repository.ts similarity index 100% rename from server/src/domain/user-token/user-token.repository.ts rename to server/src/domain/auth/user-token.repository.ts diff --git a/server/src/domain/domain.module.ts b/server/src/domain/domain.module.ts index 7950fc0c7..72d530083 100644 --- a/server/src/domain/domain.module.ts +++ b/server/src/domain/domain.module.ts @@ -7,7 +7,6 @@ import { FacialRecognitionService } from './facial-recognition'; import { JobService } from './job'; import { MediaService } from './media'; import { MetadataService } from './metadata'; -import { OAuthService } from './oauth'; import { PartnerService } from './partner'; import { PersonService } from './person'; import { SearchService } from './search'; @@ -29,7 +28,6 @@ const providers: Provider[] = [ JobService, MediaService, MetadataService, - OAuthService, PersonService, PartnerService, SearchService, diff --git a/server/src/domain/index.ts b/server/src/domain/index.ts index 81120f6e2..201e31ff3 100644 --- a/server/src/domain/index.ts +++ b/server/src/domain/index.ts @@ -13,7 +13,6 @@ export * from './facial-recognition'; export * from './job'; export * from './media'; export * from './metadata'; -export * from './oauth'; export * from './partner'; export * from './person'; export * from './search'; @@ -25,4 +24,3 @@ export * from './storage-template'; export * from './system-config'; export * from './tag'; export * from './user'; -export * from './user-token'; diff --git a/server/src/domain/oauth/dto/index.ts b/server/src/domain/oauth/dto/index.ts deleted file mode 100644 index 7143c3688..000000000 --- a/server/src/domain/oauth/dto/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './oauth-auth-code.dto'; -export * from './oauth-config.dto'; diff --git a/server/src/domain/oauth/index.ts b/server/src/domain/oauth/index.ts deleted file mode 100644 index 39f22726b..000000000 --- a/server/src/domain/oauth/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './dto'; -export * from './oauth.constants'; -export * from './oauth.service'; -export * from './response-dto'; diff --git a/server/src/domain/oauth/oauth.constants.ts b/server/src/domain/oauth/oauth.constants.ts deleted file mode 100644 index 3a8932880..000000000 --- a/server/src/domain/oauth/oauth.constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const MOBILE_REDIRECT = 'app.immich:/'; diff --git a/server/src/domain/oauth/oauth.core.ts b/server/src/domain/oauth/oauth.core.ts deleted file mode 100644 index 3ebf97439..000000000 --- a/server/src/domain/oauth/oauth.core.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { SystemConfig } from '@app/infra/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.startsWith(MOBILE_REDIRECT); - const { mobileRedirectUri, mobileOverrideEnabled } = this.config.oauth; - if (isMobile && mobileOverrideEnabled && mobileRedirectUri) { - return mobileRedirectUri; - } - return redirectUri; - } -} diff --git a/server/src/domain/oauth/oauth.service.spec.ts b/server/src/domain/oauth/oauth.service.spec.ts deleted file mode 100644 index 8b5d9f7cd..000000000 --- a/server/src/domain/oauth/oauth.service.spec.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { SystemConfig, UserEntity } from '@app/infra/entities'; -import { BadRequestException } from '@nestjs/common'; -import { - authStub, - loginResponseStub, - newCryptoRepositoryMock, - newSystemConfigRepositoryMock, - newUserRepositoryMock, - newUserTokenRepositoryMock, - systemConfigStub, - userEntityStub, - userTokenEntityStub, -} from '@test'; -import { generators, Issuer } from 'openid-client'; -import { OAuthService } from '.'; -import { LoginDetails } from '../auth'; -import { ICryptoRepository } from '../crypto'; -import { ISystemConfigRepository } from '../system-config'; -import { IUserRepository } from '../user'; -import { IUserTokenRepository } from '../user-token'; - -const email = 'user@immich.com'; -const sub = 'my-auth-user-sub'; -const loginDetails: LoginDetails = { - isSecure: true, - clientIp: '127.0.0.1', - deviceOS: '', - deviceType: '', -}; - -describe('OAuthService', () => { - let sut: OAuthService; - let userMock: jest.Mocked; - let cryptoMock: jest.Mocked; - let configMock: jest.Mocked; - let userTokenMock: 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(); - userTokenMock = newUserTokenRepositoryMock(); - - create = (config) => new OAuthService(cryptoMock, configMock, userMock, userTokenMock, config); - - sut = create(systemConfigStub.disabled); - }); - - it('should be defined', () => { - expect(sut).toBeDefined(); - }); - - describe('getMobileRedirect', () => { - it('should pass along the query params', () => { - expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456'); - }); - - it('should work if called without query params', () => { - expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:/?'); - }); - }); - - 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: '' }, loginDetails)).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' }, loginDetails)).rejects.toBeInstanceOf( - BadRequestException, - ); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); - }); - - it('should link an existing user', async () => { - sut = create(systemConfigStub.noAutoRegister); - userMock.getByEmail.mockResolvedValue(userEntityStub.user1); - userMock.update.mockResolvedValue(userEntityStub.user1); - userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); - - await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, - ); - - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); - expect(userMock.update).toHaveBeenCalledWith(userEntityStub.user1.id, { oauthId: sub }); - }); - - it('should allow auto registering by default', async () => { - sut = create(systemConfigStub.enabled); - - userMock.getByEmail.mockResolvedValue(null); - userMock.getAdmin.mockResolvedValue(userEntityStub.user1); - userMock.create.mockResolvedValue(userEntityStub.user1); - userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); - - await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).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(userEntityStub.user1); - userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); - - await sut.login({ url: `app.immich:/?code=abc123` }, loginDetails); - - expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' }); - }); - - it('should use the mobile redirect override for ios urls with multiple slashes', async () => { - sut = create(systemConfigStub.override); - - userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1); - userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); - - await sut.login({ url: `app.immich:///?code=abc123` }, loginDetails); - - 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(userEntityStub.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(userEntityStub.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/src/domain/oauth/oauth.service.ts b/server/src/domain/oauth/oauth.service.ts deleted file mode 100644 index ff95ebac3..000000000 --- a/server/src/domain/oauth/oauth.service.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { SystemConfig } from '@app/infra/entities'; -import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; -import { AuthType, AuthUserDto, LoginResponseDto } from '../auth'; -import { AuthCore, LoginDetails } from '../auth/auth.core'; -import { ICryptoRepository } from '../crypto'; -import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config'; -import { IUserRepository, UserCore, UserResponseDto } from '../user'; -import { IUserTokenRepository } from '../user-token'; -import { OAuthCallbackDto, OAuthConfigDto } from './dto'; -import { MOBILE_REDIRECT } from './oauth.constants'; -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(IUserTokenRepository) userTokenRepository: IUserTokenRepository, - @Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig, - ) { - this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig); - this.userCore = new UserCore(userRepository, cryptoRepository); - this.oauthCore = new OAuthCore(configRepository, initialConfig); - } - - getMobileRedirect(url: string) { - return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`; - } - - generateConfig(dto: OAuthConfigDto): Promise { - return this.oauthCore.generateConfig(dto); - } - - async login( - dto: OAuthCallbackDto, - loginDetails: LoginDetails, - ): 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, loginDetails); - } - - 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/src/domain/oauth/response-dto/index.ts b/server/src/domain/oauth/response-dto/index.ts deleted file mode 100644 index 767bdfcae..000000000 --- a/server/src/domain/oauth/response-dto/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './oauth-config-response.dto'; diff --git a/server/src/domain/storage-template/storage-template.service.spec.ts b/server/src/domain/storage-template/storage-template.service.spec.ts index b34808e40..bef63d85a 100644 --- a/server/src/domain/storage-template/storage-template.service.spec.ts +++ b/server/src/domain/storage-template/storage-template.service.spec.ts @@ -4,7 +4,6 @@ import { newStorageRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock, - systemConfigStub, userEntityStub, } from '@test'; import { when } from 'jest-when'; @@ -12,6 +11,7 @@ import { StorageTemplateService } from '.'; import { IAssetRepository } from '../asset'; import { IStorageRepository } from '../storage/storage.repository'; import { ISystemConfigRepository } from '../system-config'; +import { defaults } from '../system-config/system-config.core'; import { IUserRepository } from '../user'; describe(StorageTemplateService.name, () => { @@ -31,7 +31,7 @@ describe(StorageTemplateService.name, () => { storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); - sut = new StorageTemplateService(assetMock, configMock, systemConfigStub.defaults, storageMock, userMock); + sut = new StorageTemplateService(assetMock, configMock, defaults, storageMock, userMock); }); describe('handle template migration', () => { diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index 0c440835c..771d5d048 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -16,7 +16,7 @@ import { ISystemConfigRepository } from './system-config.repository'; export type SystemConfigValidator = (config: SystemConfig) => void | Promise; -const defaults = Object.freeze({ +export const defaults = Object.freeze({ ffmpeg: { crf: 23, threads: 0, diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index 54018df79..8ac2efea6 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -7,9 +7,9 @@ import { VideoCodec, } from '@app/infra/entities'; import { BadRequestException } from '@nestjs/common'; -import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '@test'; +import { newJobRepositoryMock, newSystemConfigRepositoryMock } from '@test'; import { IJobRepository, JobName, QueueName } from '../job'; -import { SystemConfigValidator } from './system-config.core'; +import { defaults, SystemConfigValidator } from './system-config.core'; import { ISystemConfigRepository } from './system-config.repository'; import { SystemConfigService } from './system-config.service'; @@ -81,7 +81,7 @@ describe(SystemConfigService.name, () => { it('should return the default config', () => { configMock.load.mockResolvedValue(updates); - expect(sut.getDefaults()).toEqual(systemConfigStub.defaults); + expect(sut.getDefaults()).toEqual(defaults); expect(configMock.load).not.toHaveBeenCalled(); }); }); @@ -89,12 +89,9 @@ describe(SystemConfigService.name, () => { describe('addValidator', () => { it('should call the validator on config changes', async () => { const validator: SystemConfigValidator = jest.fn(); - sut.addValidator(validator); - - await sut.updateConfig(systemConfigStub.defaults); - - expect(validator).toHaveBeenCalledWith(systemConfigStub.defaults); + await sut.updateConfig(defaults); + expect(validator).toHaveBeenCalledWith(defaults); }); }); @@ -102,7 +99,7 @@ describe(SystemConfigService.name, () => { it('should return the default config', async () => { configMock.load.mockResolvedValue([]); - await expect(sut.getConfig()).resolves.toEqual(systemConfigStub.defaults); + await expect(sut.getConfig()).resolves.toEqual(defaults); }); it('should merge the overrides', async () => { @@ -172,7 +169,7 @@ describe(SystemConfigService.name, () => { await sut.refreshConfig(); - expect(changeMock).toHaveBeenCalledWith(systemConfigStub.defaults); + expect(changeMock).toHaveBeenCalledWith(defaults); subscription.unsubscribe(); }); diff --git a/server/src/domain/user-token/index.ts b/server/src/domain/user-token/index.ts deleted file mode 100644 index 45d9ea984..000000000 --- a/server/src/domain/user-token/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './user-token.core'; -export * from './user-token.repository'; diff --git a/server/src/domain/user-token/user-token.core.ts b/server/src/domain/user-token/user-token.core.ts deleted file mode 100644 index 061738cd5..000000000 --- a/server/src/domain/user-token/user-token.core.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { UserEntity, UserTokenEntity } from '@app/infra/entities'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { DateTime } from 'luxon'; -import { LoginDetails } from '../auth'; -import { ICryptoRepository } from '../crypto'; -import { IUserTokenRepository } from './user-token.repository'; - -@Injectable() -export class UserTokenCore { - constructor(private crypto: ICryptoRepository, private repository: IUserTokenRepository) {} - - async validate(tokenValue: string) { - const hashedToken = this.crypto.hashSha256(tokenValue); - let token = await this.repository.getByToken(hashedToken); - - if (token?.user) { - const now = DateTime.now(); - const updatedAt = DateTime.fromJSDate(token.updatedAt); - const diff = now.diff(updatedAt, ['hours']); - if (diff.hours > 1) { - token = await this.repository.save({ ...token, updatedAt: new Date() }); - } - - return { - ...token.user, - isPublicUser: false, - isAllowUpload: true, - isAllowDownload: true, - isShowExif: true, - accessTokenId: token.id, - }; - } - - throw new UnauthorizedException('Invalid user token'); - } - - async create(user: UserEntity, loginDetails: LoginDetails): Promise { - const key = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, ''); - const token = this.crypto.hashSha256(key); - await this.repository.create({ - token, - user, - deviceOS: loginDetails.deviceOS, - deviceType: loginDetails.deviceType, - }); - - return key; - } - - async delete(userId: string, id: string): Promise { - await this.repository.delete(userId, id); - } - - getAll(userId: string): Promise { - return this.repository.getAll(userId); - } -} diff --git a/server/src/immich/controllers/oauth.controller.ts b/server/src/immich/controllers/oauth.controller.ts index 8ec739268..24cc8afe4 100644 --- a/server/src/immich/controllers/oauth.controller.ts +++ b/server/src/immich/controllers/oauth.controller.ts @@ -1,11 +1,11 @@ import { + AuthService, AuthUserDto, LoginDetails, LoginResponseDto, OAuthCallbackDto, OAuthConfigDto, OAuthConfigResponseDto, - OAuthService, UserResponseDto, } from '@app/domain'; import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; @@ -19,7 +19,7 @@ import { UseValidation } from '../app.utils'; @Authenticated() @UseValidation() export class OAuthController { - constructor(private service: OAuthService) {} + constructor(private service: AuthService) {} @PublicRoute() @Get('mobile-redirect') @@ -44,7 +44,7 @@ export class OAuthController { @Body() dto: OAuthCallbackDto, @GetLoginDetails() loginDetails: LoginDetails, ): Promise { - const { response, cookie } = await this.service.login(dto, loginDetails); + const { response, cookie } = await this.service.callback(dto, loginDetails); res.header('Set-Cookie', cookie); return response; } diff --git a/server/src/infra/repositories/user-token.repository.ts b/server/src/infra/repositories/user-token.repository.ts index 685a5983b..c67e659ac 100644 --- a/server/src/infra/repositories/user-token.repository.ts +++ b/server/src/infra/repositories/user-token.repository.ts @@ -1,4 +1,4 @@ -import { IUserTokenRepository } from '@app/domain/user-token'; +import { IUserTokenRepository } from '@app/domain'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; diff --git a/server/test/fixtures.ts b/server/test/fixtures.ts index 145229407..6d096772a 100644 --- a/server/test/fixtures.ts +++ b/server/test/fixtures.ts @@ -5,7 +5,6 @@ import { AuthUserDto, ExifResponseDto, mapUser, - QueueName, SearchResult, SharedLinkResponseDto, TagResponseDto, @@ -19,19 +18,17 @@ import { AssetEntity, AssetFaceEntity, AssetType, - AudioCodec, ExifEntity, PartnerEntity, PersonEntity, SharedLinkEntity, SharedLinkType, - SystemConfig, + SystemConfigEntity, + SystemConfigKey, TagEntity, TagType, - TranscodePolicy, UserEntity, UserTokenEntity, - VideoCodec, } from '@app/infra/entities'; const today = new Date(); @@ -704,91 +701,28 @@ export const keyStub = { } as APIKeyEntity), }; -export const systemConfigStub = { - defaults: Object.freeze({ - ffmpeg: { - crf: 23, - threads: 0, - preset: 'ultrafast', - targetAudioCodec: AudioCodec.AAC, - targetResolution: '720', - targetVideoCodec: VideoCodec.H264, - maxBitrate: '0', - twoPass: false, - transcode: TranscodePolicy.REQUIRED, - }, - job: { - [QueueName.BACKGROUND_TASK]: { concurrency: 5 }, - [QueueName.CLIP_ENCODING]: { concurrency: 2 }, - [QueueName.METADATA_EXTRACTION]: { concurrency: 5 }, - [QueueName.OBJECT_TAGGING]: { concurrency: 2 }, - [QueueName.RECOGNIZE_FACES]: { concurrency: 2 }, - [QueueName.SEARCH]: { concurrency: 5 }, - [QueueName.SIDECAR]: { concurrency: 5 }, - [QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 }, - [QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 }, - [QueueName.VIDEO_CONVERSION]: { concurrency: 1 }, - }, - oauth: { - autoLaunch: false, - autoRegister: true, - buttonText: 'Login with OAuth', - clientId: '', - clientSecret: '', - enabled: false, - issuerUrl: '', - mobileOverrideEnabled: false, - mobileRedirectUri: '', - scope: 'openid email profile', - }, - passwordLogin: { - enabled: true, - }, - storageTemplate: { - 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 systemConfigStub: Record = { + defaults: [], + enabled: [ + { key: SystemConfigKey.OAUTH_ENABLED, value: true }, + { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true }, + { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false }, + { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' }, + ], + disabled: [{ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED, value: false }], + noAutoRegister: [ + { key: SystemConfigKey.OAUTH_ENABLED, value: true }, + { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false }, + { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: false }, + { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' }, + ], + override: [ + { key: SystemConfigKey.OAUTH_ENABLED, value: true }, + { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true }, + { key: SystemConfigKey.OAUTH_MOBILE_OVERRIDE_ENABLED, value: true }, + { key: SystemConfigKey.OAUTH_MOBILE_REDIRECT_URI, value: 'http://mobile-redirect' }, + { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' }, + ], }; export const loginResponseStub = { diff --git a/web/src/lib/components/forms/login-form.svelte b/web/src/lib/components/forms/login-form.svelte index ed680b342..863f0030c 100644 --- a/web/src/lib/components/forms/login-form.svelte +++ b/web/src/lib/components/forms/login-form.svelte @@ -2,13 +2,13 @@ import { goto } from '$app/navigation'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { AppRoute } from '$lib/constants'; - import { handleError } from '$lib/utils/handle-error'; - import { api, oauth, OAuthConfigResponseDto } from '@api'; + import { getServerErrorMessage, handleError } from '$lib/utils/handle-error'; + import { OAuthConfigResponseDto, api, oauth } from '@api'; import { createEventDispatcher, onMount } from 'svelte'; import { fade } from 'svelte/transition'; import Button from '../elements/buttons/button.svelte'; - let error: string; + let errorMessage: string; let email = ''; let password = ''; let oauthError: string; @@ -53,7 +53,7 @@ const login = async () => { try { - error = ''; + errorMessage = ''; loading = true; const { data } = await api.authenticationApi.login({ @@ -70,8 +70,8 @@ dispatch('success'); return; - } catch (e) { - error = 'Incorrect email or password'; + } catch (error) { + errorMessage = (await getServerErrorMessage(error)) || 'Incorrect email or password'; loading = false; return; } @@ -80,9 +80,9 @@ {#if authConfig.passwordLoginEnabled}
- {#if error} + {#if errorMessage}

- {error} + {errorMessage}

{/if} diff --git a/web/src/lib/utils/handle-error.ts b/web/src/lib/utils/handle-error.ts index 32a73f03d..59c2b20ec 100644 --- a/web/src/lib/utils/handle-error.ts +++ b/web/src/lib/utils/handle-error.ts @@ -2,13 +2,7 @@ import type { ApiError } from '@api'; import { CanceledError } from 'axios'; import { notificationController, NotificationType } from '../components/shared-components/notification/notification'; -export async function handleError(error: unknown, message: string) { - if (error instanceof CanceledError) { - return; - } - - console.error(`[handleError]: ${message}`, error); - +export async function getServerErrorMessage(error: unknown) { let data = (error as ApiError)?.response?.data; if (data instanceof Blob) { const response = await data.text(); @@ -19,7 +13,17 @@ export async function handleError(error: unknown, message: string) { } } - let serverMessage = data?.message; + return data?.message || null; +} + +export async function handleError(error: unknown, message: string) { + if (error instanceof CanceledError) { + return; + } + + console.error(`[handleError]: ${message}`, error); + + let serverMessage = await getServerErrorMessage(error); if (serverMessage) { serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`; }