refactor(server): auth/oauth (#3242)
* refactor(server): auth/oauth * fix: show server error message on login failure
This commit is contained in:
parent
9ef41bf1c7
commit
08c7054845
30 changed files with 453 additions and 730 deletions
|
@ -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_ACCESS_COOKIE = 'immich_access_token';
|
||||||
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
|
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
|
||||||
export const IMMICH_API_KEY_NAME = 'api_key';
|
export const IMMICH_API_KEY_NAME = 'api_key';
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { SystemConfig, UserEntity } from '@app/infra/entities';
|
import { UserEntity } from '@app/infra/entities';
|
||||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
authStub,
|
authStub,
|
||||||
|
@ -23,10 +23,10 @@ import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||||
import { ISharedLinkRepository } from '../shared-link';
|
import { ISharedLinkRepository } from '../shared-link';
|
||||||
import { ISystemConfigRepository } from '../system-config';
|
import { ISystemConfigRepository } from '../system-config';
|
||||||
import { IUserRepository } from '../user';
|
import { IUserRepository } from '../user';
|
||||||
import { IUserTokenRepository } from '../user-token';
|
|
||||||
import { AuthType } from './auth.constant';
|
import { AuthType } from './auth.constant';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AuthUserDto, SignUpDto } from './dto';
|
import { AuthUserDto, SignUpDto } from './dto';
|
||||||
|
import { IUserTokenRepository } from './user-token.repository';
|
||||||
|
|
||||||
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
||||||
|
|
||||||
|
@ -55,7 +55,6 @@ describe('AuthService', () => {
|
||||||
let shareMock: jest.Mocked<ISharedLinkRepository>;
|
let shareMock: jest.Mocked<ISharedLinkRepository>;
|
||||||
let keyMock: jest.Mocked<IKeyRepository>;
|
let keyMock: jest.Mocked<IKeyRepository>;
|
||||||
let callbackMock: jest.Mock;
|
let callbackMock: jest.Mock;
|
||||||
let create: (config: SystemConfig) => AuthService;
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
|
@ -87,9 +86,7 @@ describe('AuthService', () => {
|
||||||
shareMock = newSharedLinkRepositoryMock();
|
shareMock = newSharedLinkRepositoryMock();
|
||||||
keyMock = newKeyRepositoryMock();
|
keyMock = newKeyRepositoryMock();
|
||||||
|
|
||||||
create = (config) => new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock, config);
|
sut = new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock);
|
||||||
|
|
||||||
sut = create(systemConfigStub.enabled);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
|
@ -98,8 +95,7 @@ describe('AuthService', () => {
|
||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
it('should throw an error if password login is disabled', async () => {
|
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);
|
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -191,8 +187,8 @@ describe('AuthService', () => {
|
||||||
|
|
||||||
describe('logout', () => {
|
describe('logout', () => {
|
||||||
it('should return the end session endpoint', async () => {
|
it('should return the end session endpoint', async () => {
|
||||||
|
configMock.load.mockResolvedValue(systemConfigStub.enabled);
|
||||||
const authUser = { id: '123' } as AuthUserDto;
|
const authUser = { id: '123' } as AuthUserDto;
|
||||||
|
|
||||||
await expect(sut.logout(authUser, AuthType.OAUTH)).resolves.toEqual({
|
await expect(sut.logout(authUser, AuthType.OAUTH)).resolves.toEqual({
|
||||||
successful: true,
|
successful: true,
|
||||||
redirectUri: 'http://end-session-endpoint',
|
redirectUri: 'http://end-session-endpoint',
|
||||||
|
@ -385,4 +381,132 @@ describe('AuthService', () => {
|
||||||
expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'token-1');
|
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: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { SystemConfig } from '@app/infra/entities';
|
import { SystemConfig, UserEntity } from '@app/infra/entities';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Inject,
|
Inject,
|
||||||
|
@ -9,99 +9,112 @@ import {
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import cookieParser from 'cookie';
|
import cookieParser from 'cookie';
|
||||||
import { IncomingHttpHeaders } from 'http';
|
import { IncomingHttpHeaders } from 'http';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
|
||||||
import { IKeyRepository } from '../api-key';
|
import { IKeyRepository } from '../api-key';
|
||||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||||
import { OAuthCore } from '../oauth/oauth.core';
|
|
||||||
import { ISharedLinkRepository } from '../shared-link';
|
import { ISharedLinkRepository } from '../shared-link';
|
||||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
import { ISystemConfigRepository } from '../system-config';
|
||||||
import { IUserRepository, UserCore } from '../user';
|
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||||
import { IUserTokenRepository, UserTokenCore } from '../user-token';
|
import { IUserRepository, UserCore, UserResponseDto } from '../user';
|
||||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER } from './auth.constant';
|
import {
|
||||||
import { AuthCore, LoginDetails } from './auth.core';
|
AuthType,
|
||||||
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
|
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 {
|
import {
|
||||||
AdminSignupResponseDto,
|
AdminSignupResponseDto,
|
||||||
AuthDeviceResponseDto,
|
AuthDeviceResponseDto,
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
LogoutResponseDto,
|
LogoutResponseDto,
|
||||||
mapAdminSignupResponse,
|
mapAdminSignupResponse,
|
||||||
|
mapLoginResponse,
|
||||||
mapUserToken,
|
mapUserToken,
|
||||||
|
OAuthConfigResponseDto,
|
||||||
} from './response-dto';
|
} 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()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private userTokenCore: UserTokenCore;
|
|
||||||
private authCore: AuthCore;
|
|
||||||
private oauthCore: OAuthCore;
|
|
||||||
private userCore: UserCore;
|
private userCore: UserCore;
|
||||||
|
private configCore: SystemConfigCore;
|
||||||
private logger = new Logger(AuthService.name);
|
private logger = new Logger(AuthService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||||
@Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
|
@Inject(IUserTokenRepository) private userTokenRepository: IUserTokenRepository,
|
||||||
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
|
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
|
||||||
@Inject(IKeyRepository) private keyRepository: IKeyRepository,
|
@Inject(IKeyRepository) private keyRepository: IKeyRepository,
|
||||||
@Inject(INITIAL_SYSTEM_CONFIG)
|
|
||||||
initialConfig: SystemConfig,
|
|
||||||
) {
|
) {
|
||||||
this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository);
|
this.configCore = new SystemConfigCore(configRepository);
|
||||||
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
|
|
||||||
this.oauthCore = new OAuthCore(configRepository, initialConfig);
|
|
||||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||||
|
|
||||||
|
custom.setHttpOptionsDefaults({ timeout: 30000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async login(
|
async login(dto: LoginCredentialDto, details: LoginDetails): Promise<LoginResponse> {
|
||||||
loginCredential: LoginCredentialDto,
|
const config = await this.configCore.getConfig();
|
||||||
loginDetails: LoginDetails,
|
if (!config.passwordLogin.enabled) {
|
||||||
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
|
|
||||||
if (!this.authCore.isPasswordLoginEnabled()) {
|
|
||||||
throw new UnauthorizedException('Password login has been disabled');
|
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) {
|
if (user) {
|
||||||
const isAuthenticated = this.authCore.validatePassword(loginCredential.password, user);
|
const isAuthenticated = this.validatePassword(dto.password, user);
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
user = null;
|
user = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
this.logger.warn(
|
this.logger.warn(`Failed login attempt for user ${dto.email} from ip address ${details.clientIp}`);
|
||||||
`Failed login attempt for user ${loginCredential.email} from ip address ${loginDetails.clientIp}`,
|
|
||||||
);
|
|
||||||
throw new BadRequestException('Incorrect email or password');
|
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<LogoutResponseDto> {
|
async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
|
||||||
if (authUser.accessTokenId) {
|
if (authUser.accessTokenId) {
|
||||||
await this.userTokenCore.delete(authUser.id, authUser.accessTokenId);
|
await this.userTokenRepository.delete(authUser.id, authUser.accessTokenId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authType === AuthType.OAUTH) {
|
return {
|
||||||
const url = await this.oauthCore.getLogoutEndpoint();
|
successful: true,
|
||||||
if (url) {
|
redirectUri: await this.getLogoutEndpoint(authType),
|
||||||
return { successful: true, redirectUri: url };
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { successful: true, redirectUri: '/auth/login?autoLaunch=0' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
|
async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
|
||||||
const { password, newPassword } = dto;
|
const { password, newPassword } = dto;
|
||||||
const user = await this.userCore.getByEmail(authUser.email, true);
|
const user = await this.userCore.getByEmail(authUser.email, true);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = this.authCore.validatePassword(password, user);
|
const valid = this.validatePassword(password, user);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new BadRequestException('Wrong password');
|
throw new BadRequestException('Wrong password');
|
||||||
}
|
}
|
||||||
|
@ -109,7 +122,7 @@ export class AuthService {
|
||||||
return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
|
return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
|
async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
|
||||||
const adminUser = await this.userCore.getAdmin();
|
const adminUser = await this.userCore.getAdmin();
|
||||||
|
|
||||||
if (adminUser) {
|
if (adminUser) {
|
||||||
|
@ -133,7 +146,7 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto | null> {
|
async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto | null> {
|
||||||
const shareKey = (headers['x-immich-share-key'] || params.key) as string;
|
const shareKey = (headers['x-immich-share-key'] || params.key) as string;
|
||||||
const userToken = (headers['x-immich-user-token'] ||
|
const userToken = (headers['x-immich-user-token'] ||
|
||||||
params.userToken ||
|
params.userToken ||
|
||||||
|
@ -146,7 +159,7 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userToken) {
|
if (userToken) {
|
||||||
return this.userTokenCore.validate(userToken);
|
return this.validateUserToken(userToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
|
@ -157,24 +170,155 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDevices(authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
|
async getDevices(authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
|
||||||
const userTokens = await this.userTokenCore.getAll(authUser.id);
|
const userTokens = await this.userTokenRepository.getAll(authUser.id);
|
||||||
return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
|
return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise<void> {
|
async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise<void> {
|
||||||
await this.userTokenCore.delete(authUser.id, deviceId);
|
await this.userTokenRepository.delete(authUser.id, deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async logoutDevices(authUser: AuthUserDto): Promise<void> {
|
async logoutDevices(authUser: AuthUserDto): Promise<void> {
|
||||||
const devices = await this.userTokenCore.getAll(authUser.id);
|
const devices = await this.userTokenRepository.getAll(authUser.id);
|
||||||
for (const device of devices) {
|
for (const device of devices) {
|
||||||
if (device.id === authUser.accessTokenId) {
|
if (device.id === authUser.accessTokenId) {
|
||||||
continue;
|
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<OAuthConfigResponseDto> {
|
||||||
|
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<UserResponseDto> {
|
||||||
|
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<UserResponseDto> {
|
||||||
|
return this.userCore.updateUser(user, user.id, { oauthId: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLogoutEndpoint(authType: AuthType): Promise<string> {
|
||||||
|
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<OAuthProfile> {
|
||||||
|
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<OAuthProfile>(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 {
|
private getBearerToken(headers: IncomingHttpHeaders): string | null {
|
||||||
const [type, token] = (headers.authorization || '').split(' ');
|
const [type, token] = (headers.authorization || '').split(' ');
|
||||||
if (type.toLowerCase() === 'bearer') {
|
if (type.toLowerCase() === 'bearer') {
|
||||||
|
@ -232,4 +376,68 @@ export class AuthService {
|
||||||
|
|
||||||
throw new UnauthorizedException('Invalid API key');
|
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<AuthUserDto> {
|
||||||
|
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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
export * from './auth-user.dto';
|
export * from './auth-user.dto';
|
||||||
export * from './change-password.dto';
|
export * from './change-password.dto';
|
||||||
export * from './login-credential.dto';
|
export * from './login-credential.dto';
|
||||||
|
export * from './oauth-auth-code.dto';
|
||||||
|
export * from './oauth-config.dto';
|
||||||
export * from './sign-up.dto';
|
export * from './sign-up.dto';
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
export * from './auth.constant';
|
export * from './auth.constant';
|
||||||
export * from './auth.core';
|
|
||||||
export * from './auth.service';
|
export * from './auth.service';
|
||||||
export * from './dto';
|
export * from './dto';
|
||||||
export * from './response-dto';
|
export * from './response-dto';
|
||||||
|
export * from './user-token.repository';
|
||||||
|
|
|
@ -2,4 +2,5 @@ export * from './admin-signup-response.dto';
|
||||||
export * from './auth-device-response.dto';
|
export * from './auth-device-response.dto';
|
||||||
export * from './login-response.dto';
|
export * from './login-response.dto';
|
||||||
export * from './logout-response.dto';
|
export * from './logout-response.dto';
|
||||||
|
export * from './oauth-config-response.dto';
|
||||||
export * from './validate-asset-token-response.dto';
|
export * from './validate-asset-token-response.dto';
|
||||||
|
|
|
@ -7,7 +7,6 @@ import { FacialRecognitionService } from './facial-recognition';
|
||||||
import { JobService } from './job';
|
import { JobService } from './job';
|
||||||
import { MediaService } from './media';
|
import { MediaService } from './media';
|
||||||
import { MetadataService } from './metadata';
|
import { MetadataService } from './metadata';
|
||||||
import { OAuthService } from './oauth';
|
|
||||||
import { PartnerService } from './partner';
|
import { PartnerService } from './partner';
|
||||||
import { PersonService } from './person';
|
import { PersonService } from './person';
|
||||||
import { SearchService } from './search';
|
import { SearchService } from './search';
|
||||||
|
@ -29,7 +28,6 @@ const providers: Provider[] = [
|
||||||
JobService,
|
JobService,
|
||||||
MediaService,
|
MediaService,
|
||||||
MetadataService,
|
MetadataService,
|
||||||
OAuthService,
|
|
||||||
PersonService,
|
PersonService,
|
||||||
PartnerService,
|
PartnerService,
|
||||||
SearchService,
|
SearchService,
|
||||||
|
|
|
@ -13,7 +13,6 @@ export * from './facial-recognition';
|
||||||
export * from './job';
|
export * from './job';
|
||||||
export * from './media';
|
export * from './media';
|
||||||
export * from './metadata';
|
export * from './metadata';
|
||||||
export * from './oauth';
|
|
||||||
export * from './partner';
|
export * from './partner';
|
||||||
export * from './person';
|
export * from './person';
|
||||||
export * from './search';
|
export * from './search';
|
||||||
|
@ -25,4 +24,3 @@ export * from './storage-template';
|
||||||
export * from './system-config';
|
export * from './system-config';
|
||||||
export * from './tag';
|
export * from './tag';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
export * from './user-token';
|
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
export * from './oauth-auth-code.dto';
|
|
||||||
export * from './oauth-config.dto';
|
|
|
@ -1,4 +0,0 @@
|
||||||
export * from './dto';
|
|
||||||
export * from './oauth.constants';
|
|
||||||
export * from './oauth.service';
|
|
||||||
export * from './response-dto';
|
|
|
@ -1 +0,0 @@
|
||||||
export const MOBILE_REDIRECT = 'app.immich:/';
|
|
|
@ -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<OAuthConfigResponseDto> {
|
|
||||||
const response = {
|
|
||||||
enabled: this.config.oauth.enabled,
|
|
||||||
passwordLoginEnabled: this.config.passwordLogin.enabled,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!response.enabled) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { scope, buttonText, autoLaunch } = this.config.oauth;
|
|
||||||
const url = (await this.getClient()).authorizationUrl({
|
|
||||||
redirect_uri: this.normalize(dto.redirectUri),
|
|
||||||
scope,
|
|
||||||
state: generators.state(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ...response, buttonText, url, autoLaunch };
|
|
||||||
}
|
|
||||||
|
|
||||||
async callback(url: string): Promise<OAuthProfile> {
|
|
||||||
const redirectUri = this.normalize(url.split('?')[0]);
|
|
||||||
const client = await this.getClient();
|
|
||||||
const params = client.callbackParams(url);
|
|
||||||
const tokens = await client.callback(redirectUri, params, { state: params.state });
|
|
||||||
return await client.userinfo<OAuthProfile>(tokens.access_token || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
isAutoRegisterEnabled() {
|
|
||||||
return this.config.oauth.autoRegister;
|
|
||||||
}
|
|
||||||
|
|
||||||
asUser(profile: OAuthProfile) {
|
|
||||||
return {
|
|
||||||
firstName: profile.given_name || '',
|
|
||||||
lastName: profile.family_name || '',
|
|
||||||
email: profile.email,
|
|
||||||
oauthId: profile.sub,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLogoutEndpoint(): Promise<string | null> {
|
|
||||||
if (!this.config.oauth.enabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getClient() {
|
|
||||||
const { enabled, clientId, clientSecret, issuerUrl } = this.config.oauth;
|
|
||||||
|
|
||||||
if (!enabled) {
|
|
||||||
throw new BadRequestException('OAuth2 is not enabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata: ClientMetadata = {
|
|
||||||
client_id: clientId,
|
|
||||||
client_secret: clientSecret,
|
|
||||||
response_types: ['code'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const issuer = await Issuer.discover(issuerUrl);
|
|
||||||
const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
|
|
||||||
if (algorithms[0] === 'HS256') {
|
|
||||||
metadata.id_token_signed_response_alg = algorithms[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return new issuer.Client(metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalize(redirectUri: string) {
|
|
||||||
const isMobile = redirectUri.startsWith(MOBILE_REDIRECT);
|
|
||||||
const { mobileRedirectUri, mobileOverrideEnabled } = this.config.oauth;
|
|
||||||
if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
|
|
||||||
return mobileRedirectUri;
|
|
||||||
}
|
|
||||||
return redirectUri;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<IUserRepository>;
|
|
||||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
|
||||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
|
||||||
let userTokenMock: jest.Mocked<IUserTokenRepository>;
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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<OAuthConfigResponseDto> {
|
|
||||||
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<UserResponseDto> {
|
|
||||||
const { sub: oauthId } = await this.oauthCore.callback(dto.url);
|
|
||||||
const duplicate = await this.userCore.getByOAuthId(oauthId);
|
|
||||||
if (duplicate && duplicate.id !== user.id) {
|
|
||||||
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
|
|
||||||
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
|
||||||
}
|
|
||||||
return this.userCore.updateUser(user, user.id, { oauthId });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async unlink(user: AuthUserDto): Promise<UserResponseDto> {
|
|
||||||
return this.userCore.updateUser(user, user.id, { oauthId: '' });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getLogoutEndpoint(): Promise<string | null> {
|
|
||||||
return this.oauthCore.getLogoutEndpoint();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
export * from './oauth-config-response.dto';
|
|
|
@ -4,7 +4,6 @@ import {
|
||||||
newStorageRepositoryMock,
|
newStorageRepositoryMock,
|
||||||
newSystemConfigRepositoryMock,
|
newSystemConfigRepositoryMock,
|
||||||
newUserRepositoryMock,
|
newUserRepositoryMock,
|
||||||
systemConfigStub,
|
|
||||||
userEntityStub,
|
userEntityStub,
|
||||||
} from '@test';
|
} from '@test';
|
||||||
import { when } from 'jest-when';
|
import { when } from 'jest-when';
|
||||||
|
@ -12,6 +11,7 @@ import { StorageTemplateService } from '.';
|
||||||
import { IAssetRepository } from '../asset';
|
import { IAssetRepository } from '../asset';
|
||||||
import { IStorageRepository } from '../storage/storage.repository';
|
import { IStorageRepository } from '../storage/storage.repository';
|
||||||
import { ISystemConfigRepository } from '../system-config';
|
import { ISystemConfigRepository } from '../system-config';
|
||||||
|
import { defaults } from '../system-config/system-config.core';
|
||||||
import { IUserRepository } from '../user';
|
import { IUserRepository } from '../user';
|
||||||
|
|
||||||
describe(StorageTemplateService.name, () => {
|
describe(StorageTemplateService.name, () => {
|
||||||
|
@ -31,7 +31,7 @@ describe(StorageTemplateService.name, () => {
|
||||||
storageMock = newStorageRepositoryMock();
|
storageMock = newStorageRepositoryMock();
|
||||||
userMock = newUserRepositoryMock();
|
userMock = newUserRepositoryMock();
|
||||||
|
|
||||||
sut = new StorageTemplateService(assetMock, configMock, systemConfigStub.defaults, storageMock, userMock);
|
sut = new StorageTemplateService(assetMock, configMock, defaults, storageMock, userMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handle template migration', () => {
|
describe('handle template migration', () => {
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { ISystemConfigRepository } from './system-config.repository';
|
||||||
|
|
||||||
export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
|
export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
|
||||||
|
|
||||||
const defaults = Object.freeze<SystemConfig>({
|
export const defaults = Object.freeze<SystemConfig>({
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
crf: 23,
|
crf: 23,
|
||||||
threads: 0,
|
threads: 0,
|
||||||
|
|
|
@ -7,9 +7,9 @@ import {
|
||||||
VideoCodec,
|
VideoCodec,
|
||||||
} from '@app/infra/entities';
|
} from '@app/infra/entities';
|
||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '@test';
|
import { newJobRepositoryMock, newSystemConfigRepositoryMock } from '@test';
|
||||||
import { IJobRepository, JobName, QueueName } from '../job';
|
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 { ISystemConfigRepository } from './system-config.repository';
|
||||||
import { SystemConfigService } from './system-config.service';
|
import { SystemConfigService } from './system-config.service';
|
||||||
|
|
||||||
|
@ -81,7 +81,7 @@ describe(SystemConfigService.name, () => {
|
||||||
it('should return the default config', () => {
|
it('should return the default config', () => {
|
||||||
configMock.load.mockResolvedValue(updates);
|
configMock.load.mockResolvedValue(updates);
|
||||||
|
|
||||||
expect(sut.getDefaults()).toEqual(systemConfigStub.defaults);
|
expect(sut.getDefaults()).toEqual(defaults);
|
||||||
expect(configMock.load).not.toHaveBeenCalled();
|
expect(configMock.load).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -89,12 +89,9 @@ describe(SystemConfigService.name, () => {
|
||||||
describe('addValidator', () => {
|
describe('addValidator', () => {
|
||||||
it('should call the validator on config changes', async () => {
|
it('should call the validator on config changes', async () => {
|
||||||
const validator: SystemConfigValidator = jest.fn();
|
const validator: SystemConfigValidator = jest.fn();
|
||||||
|
|
||||||
sut.addValidator(validator);
|
sut.addValidator(validator);
|
||||||
|
await sut.updateConfig(defaults);
|
||||||
await sut.updateConfig(systemConfigStub.defaults);
|
expect(validator).toHaveBeenCalledWith(defaults);
|
||||||
|
|
||||||
expect(validator).toHaveBeenCalledWith(systemConfigStub.defaults);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -102,7 +99,7 @@ describe(SystemConfigService.name, () => {
|
||||||
it('should return the default config', async () => {
|
it('should return the default config', async () => {
|
||||||
configMock.load.mockResolvedValue([]);
|
configMock.load.mockResolvedValue([]);
|
||||||
|
|
||||||
await expect(sut.getConfig()).resolves.toEqual(systemConfigStub.defaults);
|
await expect(sut.getConfig()).resolves.toEqual(defaults);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should merge the overrides', async () => {
|
it('should merge the overrides', async () => {
|
||||||
|
@ -172,7 +169,7 @@ describe(SystemConfigService.name, () => {
|
||||||
|
|
||||||
await sut.refreshConfig();
|
await sut.refreshConfig();
|
||||||
|
|
||||||
expect(changeMock).toHaveBeenCalledWith(systemConfigStub.defaults);
|
expect(changeMock).toHaveBeenCalledWith(defaults);
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
export * from './user-token.core';
|
|
||||||
export * from './user-token.repository';
|
|
|
@ -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<string> {
|
|
||||||
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<void> {
|
|
||||||
await this.repository.delete(userId, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAll(userId: string): Promise<UserTokenEntity[]> {
|
|
||||||
return this.repository.getAll(userId);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +1,11 @@
|
||||||
import {
|
import {
|
||||||
|
AuthService,
|
||||||
AuthUserDto,
|
AuthUserDto,
|
||||||
LoginDetails,
|
LoginDetails,
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
OAuthCallbackDto,
|
OAuthCallbackDto,
|
||||||
OAuthConfigDto,
|
OAuthConfigDto,
|
||||||
OAuthConfigResponseDto,
|
OAuthConfigResponseDto,
|
||||||
OAuthService,
|
|
||||||
UserResponseDto,
|
UserResponseDto,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
|
import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
|
||||||
|
@ -19,7 +19,7 @@ import { UseValidation } from '../app.utils';
|
||||||
@Authenticated()
|
@Authenticated()
|
||||||
@UseValidation()
|
@UseValidation()
|
||||||
export class OAuthController {
|
export class OAuthController {
|
||||||
constructor(private service: OAuthService) {}
|
constructor(private service: AuthService) {}
|
||||||
|
|
||||||
@PublicRoute()
|
@PublicRoute()
|
||||||
@Get('mobile-redirect')
|
@Get('mobile-redirect')
|
||||||
|
@ -44,7 +44,7 @@ export class OAuthController {
|
||||||
@Body() dto: OAuthCallbackDto,
|
@Body() dto: OAuthCallbackDto,
|
||||||
@GetLoginDetails() loginDetails: LoginDetails,
|
@GetLoginDetails() loginDetails: LoginDetails,
|
||||||
): Promise<LoginResponseDto> {
|
): Promise<LoginResponseDto> {
|
||||||
const { response, cookie } = await this.service.login(dto, loginDetails);
|
const { response, cookie } = await this.service.callback(dto, loginDetails);
|
||||||
res.header('Set-Cookie', cookie);
|
res.header('Set-Cookie', cookie);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { IUserTokenRepository } from '@app/domain/user-token';
|
import { IUserTokenRepository } from '@app/domain';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
|
@ -5,7 +5,6 @@ import {
|
||||||
AuthUserDto,
|
AuthUserDto,
|
||||||
ExifResponseDto,
|
ExifResponseDto,
|
||||||
mapUser,
|
mapUser,
|
||||||
QueueName,
|
|
||||||
SearchResult,
|
SearchResult,
|
||||||
SharedLinkResponseDto,
|
SharedLinkResponseDto,
|
||||||
TagResponseDto,
|
TagResponseDto,
|
||||||
|
@ -19,19 +18,17 @@ import {
|
||||||
AssetEntity,
|
AssetEntity,
|
||||||
AssetFaceEntity,
|
AssetFaceEntity,
|
||||||
AssetType,
|
AssetType,
|
||||||
AudioCodec,
|
|
||||||
ExifEntity,
|
ExifEntity,
|
||||||
PartnerEntity,
|
PartnerEntity,
|
||||||
PersonEntity,
|
PersonEntity,
|
||||||
SharedLinkEntity,
|
SharedLinkEntity,
|
||||||
SharedLinkType,
|
SharedLinkType,
|
||||||
SystemConfig,
|
SystemConfigEntity,
|
||||||
|
SystemConfigKey,
|
||||||
TagEntity,
|
TagEntity,
|
||||||
TagType,
|
TagType,
|
||||||
TranscodePolicy,
|
|
||||||
UserEntity,
|
UserEntity,
|
||||||
UserTokenEntity,
|
UserTokenEntity,
|
||||||
VideoCodec,
|
|
||||||
} from '@app/infra/entities';
|
} from '@app/infra/entities';
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
@ -704,91 +701,28 @@ export const keyStub = {
|
||||||
} as APIKeyEntity),
|
} as APIKeyEntity),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const systemConfigStub = {
|
export const systemConfigStub: Record<string, SystemConfigEntity[]> = {
|
||||||
defaults: Object.freeze({
|
defaults: [],
|
||||||
ffmpeg: {
|
enabled: [
|
||||||
crf: 23,
|
{ key: SystemConfigKey.OAUTH_ENABLED, value: true },
|
||||||
threads: 0,
|
{ key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
|
||||||
preset: 'ultrafast',
|
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false },
|
||||||
targetAudioCodec: AudioCodec.AAC,
|
{ key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
|
||||||
targetResolution: '720',
|
],
|
||||||
targetVideoCodec: VideoCodec.H264,
|
disabled: [{ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED, value: false }],
|
||||||
maxBitrate: '0',
|
noAutoRegister: [
|
||||||
twoPass: false,
|
{ key: SystemConfigKey.OAUTH_ENABLED, value: true },
|
||||||
transcode: TranscodePolicy.REQUIRED,
|
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false },
|
||||||
},
|
{ key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: false },
|
||||||
job: {
|
{ key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
|
||||||
[QueueName.BACKGROUND_TASK]: { concurrency: 5 },
|
],
|
||||||
[QueueName.CLIP_ENCODING]: { concurrency: 2 },
|
override: [
|
||||||
[QueueName.METADATA_EXTRACTION]: { concurrency: 5 },
|
{ key: SystemConfigKey.OAUTH_ENABLED, value: true },
|
||||||
[QueueName.OBJECT_TAGGING]: { concurrency: 2 },
|
{ key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
|
||||||
[QueueName.RECOGNIZE_FACES]: { concurrency: 2 },
|
{ key: SystemConfigKey.OAUTH_MOBILE_OVERRIDE_ENABLED, value: true },
|
||||||
[QueueName.SEARCH]: { concurrency: 5 },
|
{ key: SystemConfigKey.OAUTH_MOBILE_REDIRECT_URI, value: 'http://mobile-redirect' },
|
||||||
[QueueName.SIDECAR]: { concurrency: 5 },
|
{ key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
|
||||||
[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 loginResponseStub = {
|
export const loginResponseStub = {
|
||||||
|
|
|
@ -2,13 +2,13 @@
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
|
||||||
import { api, oauth, OAuthConfigResponseDto } from '@api';
|
import { OAuthConfigResponseDto, api, oauth } from '@api';
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import Button from '../elements/buttons/button.svelte';
|
import Button from '../elements/buttons/button.svelte';
|
||||||
|
|
||||||
let error: string;
|
let errorMessage: string;
|
||||||
let email = '';
|
let email = '';
|
||||||
let password = '';
|
let password = '';
|
||||||
let oauthError: string;
|
let oauthError: string;
|
||||||
|
@ -53,7 +53,7 @@
|
||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
try {
|
try {
|
||||||
error = '';
|
errorMessage = '';
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
||||||
const { data } = await api.authenticationApi.login({
|
const { data } = await api.authenticationApi.login({
|
||||||
|
@ -70,8 +70,8 @@
|
||||||
|
|
||||||
dispatch('success');
|
dispatch('success');
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
error = 'Incorrect email or password';
|
errorMessage = (await getServerErrorMessage(error)) || 'Incorrect email or password';
|
||||||
loading = false;
|
loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -80,9 +80,9 @@
|
||||||
|
|
||||||
{#if authConfig.passwordLoginEnabled}
|
{#if authConfig.passwordLoginEnabled}
|
||||||
<form on:submit|preventDefault={login} class="flex flex-col gap-5 mt-5">
|
<form on:submit|preventDefault={login} class="flex flex-col gap-5 mt-5">
|
||||||
{#if error}
|
{#if errorMessage}
|
||||||
<p class="text-red-400" transition:fade>
|
<p class="text-red-400" transition:fade>
|
||||||
{error}
|
{errorMessage}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
|
@ -2,13 +2,7 @@ import type { ApiError } from '@api';
|
||||||
import { CanceledError } from 'axios';
|
import { CanceledError } from 'axios';
|
||||||
import { notificationController, NotificationType } from '../components/shared-components/notification/notification';
|
import { notificationController, NotificationType } from '../components/shared-components/notification/notification';
|
||||||
|
|
||||||
export async function handleError(error: unknown, message: string) {
|
export async function getServerErrorMessage(error: unknown) {
|
||||||
if (error instanceof CanceledError) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(`[handleError]: ${message}`, error);
|
|
||||||
|
|
||||||
let data = (error as ApiError)?.response?.data;
|
let data = (error as ApiError)?.response?.data;
|
||||||
if (data instanceof Blob) {
|
if (data instanceof Blob) {
|
||||||
const response = await data.text();
|
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) {
|
if (serverMessage) {
|
||||||
serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
|
serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue