mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-12-08 04:09:07 +00:00
refactor(server): auth service (#1383)
* refactor: auth * chore: tests * Remove await on non-async method * refactor: constants * chore: remove extra async Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
7
server/libs/domain/src/auth/auth.config.ts
Normal file
7
server/libs/domain/src/auth/auth.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { JwtModuleOptions } from '@nestjs/jwt';
|
||||
import { jwtSecret } from './auth.constant';
|
||||
|
||||
export const jwtConfig: JwtModuleOptions = {
|
||||
secret: jwtSecret,
|
||||
signOptions: { expiresIn: '30d' },
|
||||
};
|
||||
7
server/libs/domain/src/auth/auth.constant.ts
Normal file
7
server/libs/domain/src/auth/auth.constant.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const jwtSecret = process.env.JWT_SECRET;
|
||||
export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
|
||||
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
|
||||
export enum AuthType {
|
||||
PASSWORD = 'password',
|
||||
OAUTH = 'oauth',
|
||||
}
|
||||
80
server/libs/domain/src/auth/auth.core.ts
Normal file
80
server/libs/domain/src/auth/auth.core.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { SystemConfig, UserEntity } from '@app/infra/db/entities';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { JwtPayloadDto } from './dto/jwt-payload.dto';
|
||||
import { LoginResponseDto, mapLoginResponse } from './response-dto';
|
||||
|
||||
export type JwtValidationResult = {
|
||||
status: boolean;
|
||||
userId: string | null;
|
||||
};
|
||||
|
||||
export class AuthCore {
|
||||
constructor(
|
||||
private cryptoRepository: ICryptoRepository,
|
||||
configRepository: ISystemConfigRepository,
|
||||
private config: SystemConfig,
|
||||
) {
|
||||
const configCore = new SystemConfigCore(configRepository);
|
||||
configCore.config$.subscribe((config) => (this.config = config));
|
||||
}
|
||||
|
||||
isPasswordLoginEnabled() {
|
||||
return this.config.passwordLogin.enabled;
|
||||
}
|
||||
|
||||
public getCookies(loginResponse: LoginResponseDto, authType: AuthType, isSecure: boolean) {
|
||||
const maxAge = 7 * 24 * 3600; // 7 days
|
||||
|
||||
let authTypeCookie = '';
|
||||
let accessTokenCookie = '';
|
||||
|
||||
if (isSecure) {
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
|
||||
} else {
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
|
||||
}
|
||||
return [accessTokenCookie, authTypeCookie];
|
||||
}
|
||||
|
||||
public createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) {
|
||||
const payload: JwtPayloadDto = { userId: user.id, email: user.email };
|
||||
const accessToken = this.generateToken(payload);
|
||||
const response = mapLoginResponse(user, accessToken);
|
||||
const cookie = this.getCookies(response, authType, isSecure);
|
||||
return { response, cookie };
|
||||
}
|
||||
|
||||
validatePassword(inputPassword: string, user: UserEntity): boolean {
|
||||
if (!user || !user.password) {
|
||||
return false;
|
||||
}
|
||||
return this.cryptoRepository.compareSync(inputPassword, user.password);
|
||||
}
|
||||
|
||||
extractJwtFromHeader(headers: IncomingHttpHeaders) {
|
||||
if (!headers.authorization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [type, accessToken] = headers.authorization.split(' ');
|
||||
if (type.toLowerCase() !== 'bearer') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
extractJwtFromCookie(cookies: Record<string, string>) {
|
||||
return cookies?.[IMMICH_ACCESS_COOKIE] || null;
|
||||
}
|
||||
|
||||
private generateToken(payload: JwtPayloadDto) {
|
||||
return this.cryptoRepository.signJwt({ ...payload });
|
||||
}
|
||||
}
|
||||
263
server/libs/domain/src/auth/auth.service.spec.ts
Normal file
263
server/libs/domain/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { SystemConfig, UserEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { generators, Issuer } from 'openid-client';
|
||||
import { Socket } from 'socket.io';
|
||||
import {
|
||||
authStub,
|
||||
entityStub,
|
||||
loginResponseStub,
|
||||
newCryptoRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
systemConfigStub,
|
||||
} from '../../test';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { SignUpDto } from './dto';
|
||||
|
||||
const email = 'test@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
|
||||
const fixtures = {
|
||||
login: {
|
||||
email,
|
||||
password: 'password',
|
||||
},
|
||||
};
|
||||
|
||||
const CLIENT_IP = '127.0.0.1';
|
||||
|
||||
jest.mock('@nestjs/common', () => ({
|
||||
...jest.requireActual('@nestjs/common'),
|
||||
Logger: jest.fn().mockReturnValue({
|
||||
verbose: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
log: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('AuthService', () => {
|
||||
let sut: AuthService;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let callbackMock: jest.Mock;
|
||||
let create: (config: SystemConfig) => AuthService;
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
callbackMock = jest.fn().mockReturnValue({ access_token: 'access-token' });
|
||||
|
||||
jest.spyOn(generators, 'state').mockReturnValue('state');
|
||||
jest.spyOn(Issuer, 'discover').mockResolvedValue({
|
||||
id_token_signing_alg_values_supported: ['HS256'],
|
||||
Client: jest.fn().mockResolvedValue({
|
||||
issuer: {
|
||||
metadata: {
|
||||
end_session_endpoint: 'http://end-session-endpoint',
|
||||
},
|
||||
},
|
||||
authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'),
|
||||
callbackParams: jest.fn().mockReturnValue({ state: 'state' }),
|
||||
callback: callbackMock,
|
||||
userinfo: jest.fn().mockResolvedValue({ sub, email }),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
|
||||
create = (config) => new AuthService(cryptoMock, configMock, userMock, config);
|
||||
|
||||
sut = create(systemConfigStub.enabled);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should throw an error if password login is disabled', async () => {
|
||||
sut = create(systemConfigStub.disabled);
|
||||
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should check the user exists', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(null);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should check the user has a password', async () => {
|
||||
userMock.getByEmail.mockResolvedValue({} as UserEntity);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should successfully log the user in', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(entityStub.user1);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).resolves.toEqual(loginResponseStub.user1password);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should generate the cookie headers (insecure)', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(entityStub.user1);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, false)).resolves.toEqual(loginResponseStub.user1insecure);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changePassword', () => {
|
||||
it('should change the password', async () => {
|
||||
const authUser = { email: 'test@imimch.com' } as UserEntity;
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
userMock.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
password: 'hash-password',
|
||||
} as UserEntity);
|
||||
|
||||
await sut.changePassword(authUser, dto);
|
||||
|
||||
expect(userMock.getByEmail).toHaveBeenCalledWith(authUser.email, true);
|
||||
expect(cryptoMock.compareSync).toHaveBeenCalledWith('old-password', 'hash-password');
|
||||
});
|
||||
|
||||
it('should throw when auth user email is not found', async () => {
|
||||
const authUser = { email: 'test@imimch.com' } as UserEntity;
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
userMock.getByEmail.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should throw when password does not match existing password', async () => {
|
||||
const authUser = { email: 'test@imimch.com' } as UserEntity;
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
cryptoMock.compareSync.mockReturnValue(false);
|
||||
|
||||
userMock.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
password: 'hash-password',
|
||||
} as UserEntity);
|
||||
|
||||
await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw when user does not have a password', async () => {
|
||||
const authUser = { email: 'test@imimch.com' } as UserEntity;
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
cryptoMock.compareSync.mockReturnValue(false);
|
||||
|
||||
userMock.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
password: '',
|
||||
} as UserEntity);
|
||||
|
||||
await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should return the end session endpoint', async () => {
|
||||
await expect(sut.logout(AuthType.OAUTH)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: 'http://end-session-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the default redirect', async () => {
|
||||
await expect(sut.logout(AuthType.PASSWORD)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: '/auth/login?autoLaunch=0',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('adminSignUp', () => {
|
||||
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', firstName: 'immich', lastName: 'admin' };
|
||||
|
||||
it('should only allow one admin', async () => {
|
||||
userMock.getAdmin.mockResolvedValue({} as UserEntity);
|
||||
await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userMock.getAdmin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sign up the admin', async () => {
|
||||
userMock.getAdmin.mockResolvedValue(null);
|
||||
userMock.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: 'today' } as UserEntity);
|
||||
await expect(sut.adminSignUp(dto)).resolves.toEqual({
|
||||
id: 'admin',
|
||||
createdAt: 'today',
|
||||
email: 'test@immich.com',
|
||||
firstName: 'immich',
|
||||
lastName: 'admin',
|
||||
});
|
||||
expect(userMock.getAdmin).toHaveBeenCalled();
|
||||
expect(userMock.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSocket', () => {
|
||||
it('should validate using authorization header', async () => {
|
||||
userMock.get.mockResolvedValue(entityStub.user1);
|
||||
const client = { handshake: { headers: { authorization: 'Bearer jwt-token' } } };
|
||||
await expect(sut.validateSocket(client as Socket)).resolves.toEqual(entityStub.user1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePayload', () => {
|
||||
it('should throw if no user is found', async () => {
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(sut.validatePayload({ email: 'a', userId: 'test' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
userMock.get.mockResolvedValue(entityStub.admin);
|
||||
await expect(sut.validatePayload({ email: 'a', userId: 'test' })).resolves.toEqual(authStub.admin);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractJwtFromCookie', () => {
|
||||
it('should extract the access token', () => {
|
||||
const cookie = { [IMMICH_ACCESS_COOKIE]: 'signed-jwt', [IMMICH_AUTH_TYPE_COOKIE]: 'password' };
|
||||
expect(sut.extractJwtFromCookie(cookie)).toEqual('signed-jwt');
|
||||
});
|
||||
|
||||
it('should work with no cookies', () => {
|
||||
expect(sut.extractJwtFromCookie(undefined as any)).toBeNull();
|
||||
});
|
||||
|
||||
it('should work on empty cookies', () => {
|
||||
expect(sut.extractJwtFromCookie({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractJwtFromHeader', () => {
|
||||
it('should extract the access token', () => {
|
||||
expect(sut.extractJwtFromHeader({ authorization: `Bearer signed-jwt` })).toEqual('signed-jwt');
|
||||
});
|
||||
|
||||
it('should work without the auth header', () => {
|
||||
expect(sut.extractJwtFromHeader({})).toBeNull();
|
||||
});
|
||||
|
||||
it('should ignore basic auth', () => {
|
||||
expect(sut.extractJwtFromHeader({ authorization: `Basic stuff` })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
160
server/libs/domain/src/auth/auth.service.ts
Normal file
160
server/libs/domain/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { SystemConfig } from '@app/infra/db/entities';
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import * as cookieParser from 'cookie';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
import { Socket } from 'socket.io';
|
||||
import { OAuthCore } from '../oauth/oauth.core';
|
||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository, UserCore, UserResponseDto } from '../user';
|
||||
import { AuthType, jwtSecret } from './auth.constant';
|
||||
import { AuthCore } from './auth.core';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { AuthUserDto, ChangePasswordDto, JwtPayloadDto, LoginCredentialDto, SignUpDto } from './dto';
|
||||
import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private authCore: AuthCore;
|
||||
private oauthCore: OAuthCore;
|
||||
private userCore: UserCore;
|
||||
|
||||
private logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
@Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig,
|
||||
) {
|
||||
this.authCore = new AuthCore(cryptoRepository, configRepository, initialConfig);
|
||||
this.oauthCore = new OAuthCore(configRepository, initialConfig);
|
||||
this.userCore = new UserCore(userRepository);
|
||||
}
|
||||
|
||||
public async login(
|
||||
loginCredential: LoginCredentialDto,
|
||||
clientIp: string,
|
||||
isSecure: boolean,
|
||||
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
|
||||
if (!this.authCore.isPasswordLoginEnabled()) {
|
||||
throw new UnauthorizedException('Password login has been disabled');
|
||||
}
|
||||
|
||||
let user = await this.userCore.getByEmail(loginCredential.email, true);
|
||||
if (user) {
|
||||
const isAuthenticated = await this.authCore.validatePassword(loginCredential.password, user);
|
||||
if (!isAuthenticated) {
|
||||
user = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
this.logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`);
|
||||
throw new BadRequestException('Incorrect email or password');
|
||||
}
|
||||
|
||||
return this.authCore.createLoginResponse(user, AuthType.PASSWORD, isSecure);
|
||||
}
|
||||
|
||||
public async logout(authType: AuthType): Promise<LogoutResponseDto> {
|
||||
if (authType === AuthType.OAUTH) {
|
||||
const url = await this.oauthCore.getLogoutEndpoint();
|
||||
if (url) {
|
||||
return { successful: true, redirectUri: url };
|
||||
}
|
||||
}
|
||||
|
||||
return { successful: true, redirectUri: '/auth/login?autoLaunch=0' };
|
||||
}
|
||||
|
||||
public async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
|
||||
const { password, newPassword } = dto;
|
||||
const user = await this.userCore.getByEmail(authUser.email, true);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const valid = await this.authCore.validatePassword(password, user);
|
||||
if (!valid) {
|
||||
throw new BadRequestException('Wrong password');
|
||||
}
|
||||
|
||||
return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
|
||||
}
|
||||
|
||||
public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
|
||||
const adminUser = await this.userCore.getAdmin();
|
||||
|
||||
if (adminUser) {
|
||||
throw new BadRequestException('The server already has an admin');
|
||||
}
|
||||
|
||||
try {
|
||||
const admin = await this.userCore.createUser({
|
||||
isAdmin: true,
|
||||
email: dto.email,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
password: dto.password,
|
||||
});
|
||||
|
||||
return mapAdminSignupResponse(admin);
|
||||
} catch (error) {
|
||||
this.logger.error(`Unable to register admin user: ${error}`, (error as Error).stack);
|
||||
throw new InternalServerErrorException('Failed to register new admin user');
|
||||
}
|
||||
}
|
||||
|
||||
async validateSocket(client: Socket): Promise<UserResponseDto | null> {
|
||||
try {
|
||||
const headers = client.handshake.headers;
|
||||
const accessToken =
|
||||
this.extractJwtFromCookie(cookieParser.parse(headers.cookie || '')) || this.extractJwtFromHeader(headers);
|
||||
|
||||
if (accessToken) {
|
||||
const payload = await this.cryptoRepository.verifyJwtAsync<JwtPayloadDto>(accessToken, { secret: jwtSecret });
|
||||
if (payload?.userId && payload?.email) {
|
||||
const user = await this.userCore.get(payload.userId);
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async validatePayload(payload: JwtPayloadDto) {
|
||||
const { userId } = payload;
|
||||
const user = await this.userCore.get(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Failure to validate JWT payload');
|
||||
}
|
||||
|
||||
const authUser = new AuthUserDto();
|
||||
authUser.id = user.id;
|
||||
authUser.email = user.email;
|
||||
authUser.isAdmin = user.isAdmin;
|
||||
authUser.isPublicUser = false;
|
||||
authUser.isAllowUpload = true;
|
||||
|
||||
return authUser;
|
||||
}
|
||||
|
||||
extractJwtFromCookie(cookies: Record<string, string>) {
|
||||
return this.authCore.extractJwtFromCookie(cookies);
|
||||
}
|
||||
|
||||
extractJwtFromHeader(headers: IncomingHttpHeaders) {
|
||||
return this.authCore.extractJwtFromHeader(headers);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
|
||||
|
||||
export const ICryptoRepository = 'ICryptoRepository';
|
||||
|
||||
export interface ICryptoRepository {
|
||||
randomBytes(size: number): Buffer;
|
||||
hash(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
|
||||
compareSync(data: Buffer | string, encrypted: string): boolean;
|
||||
signJwt(payload: string | Buffer | object, options?: JwtSignOptions): string;
|
||||
verifyJwtAsync<T extends object = any>(token: string, options?: JwtVerifyOptions): Promise<T>;
|
||||
}
|
||||
|
||||
15
server/libs/domain/src/auth/dto/change-password.dto.ts
Normal file
15
server/libs/domain/src/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'password' })
|
||||
password!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
@ApiProperty({ example: 'password' })
|
||||
newPassword!: string;
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
export * from './auth-user.dto';
|
||||
export * from './change-password.dto';
|
||||
export * from './jwt-payload.dto';
|
||||
export * from './login-credential.dto';
|
||||
export * from './sign-up.dto';
|
||||
|
||||
4
server/libs/domain/src/auth/dto/jwt-payload.dto.ts
Normal file
4
server/libs/domain/src/auth/dto/jwt-payload.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export class JwtPayloadDto {
|
||||
userId!: string;
|
||||
email!: string;
|
||||
}
|
||||
33
server/libs/domain/src/auth/dto/login-credential.dto.spec.ts
Normal file
33
server/libs/domain/src/auth/dto/login-credential.dto.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { LoginCredentialDto } from './login-credential.dto';
|
||||
|
||||
describe('LoginCredentialDto', () => {
|
||||
it('should fail without an email', () => {
|
||||
const dto = plainToInstance(LoginCredentialDto, { password: 'password' });
|
||||
const errors = validateSync(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toEqual('email');
|
||||
});
|
||||
|
||||
it('should fail with an invalid email', () => {
|
||||
const dto = plainToInstance(LoginCredentialDto, { email: 'invalid.com', password: 'password' });
|
||||
const errors = validateSync(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toEqual('email');
|
||||
});
|
||||
|
||||
it('should make the email all lowercase', () => {
|
||||
const dto = plainToInstance(LoginCredentialDto, { email: 'TeSt@ImMiCh.com', password: 'password' });
|
||||
const errors = validateSync(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(dto.email).toEqual('test@immich.com');
|
||||
});
|
||||
|
||||
it('should fail without a password', () => {
|
||||
const dto = plainToInstance(LoginCredentialDto, { email: 'test@immich.com', password: '' });
|
||||
const errors = validateSync(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toEqual('password');
|
||||
});
|
||||
});
|
||||
15
server/libs/domain/src/auth/dto/login-credential.dto.ts
Normal file
15
server/libs/domain/src/auth/dto/login-credential.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class LoginCredentialDto {
|
||||
@IsEmail()
|
||||
@ApiProperty({ example: 'testuser@email.com' })
|
||||
@Transform(({ value }) => value.toLowerCase())
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'password' })
|
||||
password!: string;
|
||||
}
|
||||
44
server/libs/domain/src/auth/dto/sign-up.dto.spec.ts
Normal file
44
server/libs/domain/src/auth/dto/sign-up.dto.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { SignUpDto } from './sign-up.dto';
|
||||
|
||||
describe('SignUpDto', () => {
|
||||
it('should require all fields', () => {
|
||||
const dto = plainToInstance(SignUpDto, {
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
});
|
||||
const errors = validateSync(dto);
|
||||
expect(errors).toHaveLength(4);
|
||||
expect(errors[0].property).toEqual('email');
|
||||
expect(errors[1].property).toEqual('password');
|
||||
expect(errors[2].property).toEqual('firstName');
|
||||
expect(errors[3].property).toEqual('lastName');
|
||||
});
|
||||
|
||||
it('should require a valid email', () => {
|
||||
const dto = plainToInstance(SignUpDto, {
|
||||
email: 'immich.com',
|
||||
password: 'password',
|
||||
firstName: 'first name',
|
||||
lastName: 'last name',
|
||||
});
|
||||
const errors = validateSync(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toEqual('email');
|
||||
});
|
||||
|
||||
it('should make the email all lowercase', () => {
|
||||
const dto = plainToInstance(SignUpDto, {
|
||||
email: 'TeSt@ImMiCh.com',
|
||||
password: 'password',
|
||||
firstName: 'first name',
|
||||
lastName: 'last name',
|
||||
});
|
||||
const errors = validateSync(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(dto.email).toEqual('test@immich.com');
|
||||
});
|
||||
});
|
||||
25
server/libs/domain/src/auth/dto/sign-up.dto.ts
Normal file
25
server/libs/domain/src/auth/dto/sign-up.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class SignUpDto {
|
||||
@IsEmail()
|
||||
@ApiProperty({ example: 'testuser@email.com' })
|
||||
@Transform(({ value }) => value.toLowerCase())
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'password' })
|
||||
password!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'Admin' })
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'Doe' })
|
||||
lastName!: string;
|
||||
}
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './auth.config';
|
||||
export * from './auth.constant';
|
||||
export * from './auth.service';
|
||||
export * from './crypto.repository';
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { UserEntity } from '@app/infra/db/entities';
|
||||
|
||||
export class AdminSignupResponseDto {
|
||||
id!: string;
|
||||
email!: string;
|
||||
firstName!: string;
|
||||
lastName!: string;
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
export function mapAdminSignupResponse(entity: UserEntity): AdminSignupResponseDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
email: entity.email,
|
||||
firstName: entity.firstName,
|
||||
lastName: entity.lastName,
|
||||
createdAt: entity.createdAt,
|
||||
};
|
||||
}
|
||||
4
server/libs/domain/src/auth/response-dto/index.ts
Normal file
4
server/libs/domain/src/auth/response-dto/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './admin-signup-response.dto';
|
||||
export * from './login-response.dto';
|
||||
export * from './logout-response.dto';
|
||||
export * from './validate-asset-token-response.dto';
|
||||
@@ -0,0 +1,41 @@
|
||||
import { UserEntity } from '@app/infra/db/entities';
|
||||
import { ApiResponseProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginResponseDto {
|
||||
@ApiResponseProperty()
|
||||
accessToken!: string;
|
||||
|
||||
@ApiResponseProperty()
|
||||
userId!: string;
|
||||
|
||||
@ApiResponseProperty()
|
||||
userEmail!: string;
|
||||
|
||||
@ApiResponseProperty()
|
||||
firstName!: string;
|
||||
|
||||
@ApiResponseProperty()
|
||||
lastName!: string;
|
||||
|
||||
@ApiResponseProperty()
|
||||
profileImagePath!: string;
|
||||
|
||||
@ApiResponseProperty()
|
||||
isAdmin!: boolean;
|
||||
|
||||
@ApiResponseProperty()
|
||||
shouldChangePassword!: boolean;
|
||||
}
|
||||
|
||||
export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto {
|
||||
return {
|
||||
accessToken: accessToken,
|
||||
userId: entity.id,
|
||||
userEmail: entity.email,
|
||||
firstName: entity.firstName,
|
||||
lastName: entity.lastName,
|
||||
isAdmin: entity.isAdmin,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
shouldChangePassword: entity.shouldChangePassword,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiResponseProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LogoutResponseDto {
|
||||
constructor(successful: boolean) {
|
||||
this.successful = successful;
|
||||
}
|
||||
|
||||
@ApiResponseProperty()
|
||||
successful!: boolean;
|
||||
|
||||
@ApiResponseProperty()
|
||||
redirectUri!: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ValidateAccessTokenResponseDto {
|
||||
constructor(authStatus: boolean) {
|
||||
this.authStatus = authStatus;
|
||||
}
|
||||
|
||||
@ApiProperty({ type: 'boolean' })
|
||||
authStatus!: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user