mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
feat(server): move authentication to tokens stored in the database (#1381)
* chore: add typeorm commands to npm and set default database config values * feat: move to server side authentication tokens * fix: websocket should emit error and disconnect on error thrown by the server * refactor: rename cookie-auth-strategy to user-auth-strategy * feat: user tokens and API keys now use SHA256 hash for performance improvements * test: album e2e test remove unneeded module import * infra: truncate api key table as old keys will no longer work with new hash algorithm * fix(server): e2e tests (#1435) * fix: root module paths * chore: linting * chore: rename user-auth to strategy.ts and make validate return AuthUserDto * fix: we should always send HttpOnly for our auth cookies * chore: remove now unused crypto functions and jwt dependencies * fix: return the extra fields for AuthUserDto in auth service validate --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
@@ -10,7 +10,7 @@ export interface IKeyRepository {
|
||||
* Includes the hashed `key` for verification
|
||||
* @param id
|
||||
*/
|
||||
getKey(id: number): Promise<APIKeyEntity | null>;
|
||||
getKey(hashedToken: string): Promise<APIKeyEntity | null>;
|
||||
getById(userId: string, id: number): Promise<APIKeyEntity | null>;
|
||||
getByUserId(userId: string): Promise<APIKeyEntity[]>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { APIKeyEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { authStub, entityStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test';
|
||||
import { authStub, userEntityStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test';
|
||||
import { ICryptoRepository } from '../auth';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
import { APIKeyService } from './api-key.service';
|
||||
@@ -10,10 +10,10 @@ const adminKey = Object.freeze({
|
||||
name: 'My Key',
|
||||
key: 'my-api-key (hashed)',
|
||||
userId: authStub.admin.id,
|
||||
user: entityStub.admin,
|
||||
user: userEntityStub.admin,
|
||||
} as APIKeyEntity);
|
||||
|
||||
const token = Buffer.from('1:my-api-key', 'utf8').toString('base64');
|
||||
const token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
||||
|
||||
describe(APIKeyService.name, () => {
|
||||
let sut: APIKeyService;
|
||||
@@ -38,7 +38,7 @@ describe(APIKeyService.name, () => {
|
||||
userId: authStub.admin.id,
|
||||
});
|
||||
expect(cryptoMock.randomBytes).toHaveBeenCalled();
|
||||
expect(cryptoMock.hash).toHaveBeenCalled();
|
||||
expect(cryptoMock.hashSha256).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not require a name', async () => {
|
||||
@@ -52,7 +52,7 @@ describe(APIKeyService.name, () => {
|
||||
userId: authStub.admin.id,
|
||||
});
|
||||
expect(cryptoMock.randomBytes).toHaveBeenCalled();
|
||||
expect(cryptoMock.hash).toHaveBeenCalled();
|
||||
expect(cryptoMock.hashSha256).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,8 +126,7 @@ describe(APIKeyService.name, () => {
|
||||
|
||||
await expect(sut.validate(token)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith(1);
|
||||
expect(cryptoMock.compareSync).not.toHaveBeenCalled();
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('bXktYXBpLWtleQ== (hashed)');
|
||||
});
|
||||
|
||||
it('should validate the token', async () => {
|
||||
@@ -135,8 +134,7 @@ describe(APIKeyService.name, () => {
|
||||
|
||||
await expect(sut.validate(token)).resolves.toEqual(authStub.admin);
|
||||
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith(1);
|
||||
expect(cryptoMock.compareSync).toHaveBeenCalledWith('my-api-key', 'my-api-key (hashed)');
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('bXktYXBpLWtleQ== (hashed)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { UserEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
@@ -14,15 +13,13 @@ export class APIKeyService {
|
||||
) {}
|
||||
|
||||
async create(authUser: AuthUserDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
|
||||
const key = this.crypto.randomBytes(24).toString('base64').replace(/\W/g, '');
|
||||
const secret = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, '');
|
||||
const entity = await this.repository.create({
|
||||
key: await this.crypto.hash(key, 10),
|
||||
key: this.crypto.hashSha256(secret),
|
||||
name: dto.name || 'API Key',
|
||||
userId: authUser.id,
|
||||
});
|
||||
|
||||
const secret = Buffer.from(`${entity.id}:${key}`, 'utf8').toString('base64');
|
||||
|
||||
return { secret, apiKey: mapKey(entity) };
|
||||
}
|
||||
|
||||
@@ -60,22 +57,18 @@ export class APIKeyService {
|
||||
}
|
||||
|
||||
async validate(token: string): Promise<AuthUserDto> {
|
||||
const [_id, key] = Buffer.from(token, 'base64').toString('utf8').split(':');
|
||||
const id = Number(_id);
|
||||
const hashedToken = this.crypto.hashSha256(token);
|
||||
const keyEntity = await this.repository.getKey(hashedToken);
|
||||
if (keyEntity?.user) {
|
||||
const user = keyEntity.user;
|
||||
|
||||
if (id && key) {
|
||||
const entity = await this.repository.getKey(id);
|
||||
if (entity?.user && entity?.key && this.crypto.compareSync(key, entity.key)) {
|
||||
const user = entity.user as UserEntity;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
};
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Invalid API Key');
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { JwtModuleOptions } from '@nestjs/jwt';
|
||||
import { jwtSecret } from './auth.constant';
|
||||
|
||||
export const jwtConfig: JwtModuleOptions = {
|
||||
secret: jwtSecret,
|
||||
signOptions: { expiresIn: '30d' },
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
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 {
|
||||
|
||||
@@ -4,8 +4,9 @@ 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';
|
||||
import { IUserTokenRepository, UserTokenCore } from '@app/domain';
|
||||
import cookieParser from 'cookie';
|
||||
|
||||
export type JwtValidationResult = {
|
||||
status: boolean;
|
||||
@@ -13,11 +14,14 @@ export type JwtValidationResult = {
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -33,8 +37,8 @@ export class AuthCore {
|
||||
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;`;
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; 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;`;
|
||||
@@ -42,9 +46,8 @@ export class AuthCore {
|
||||
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);
|
||||
public async createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) {
|
||||
const accessToken = await this.userTokenCore.createToken(user);
|
||||
const response = mapLoginResponse(user, accessToken);
|
||||
const cookie = this.getCookies(response, authType, isSecure);
|
||||
return { response, cookie };
|
||||
@@ -54,12 +57,12 @@ export class AuthCore {
|
||||
if (!user || !user.password) {
|
||||
return false;
|
||||
}
|
||||
return this.cryptoRepository.compareSync(inputPassword, user.password);
|
||||
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
|
||||
}
|
||||
|
||||
extractJwtFromHeader(headers: IncomingHttpHeaders) {
|
||||
extractTokenFromHeader(headers: IncomingHttpHeaders) {
|
||||
if (!headers.authorization) {
|
||||
return null;
|
||||
return this.extractTokenFromCookie(cookieParser.parse(headers.cookie || ''));
|
||||
}
|
||||
|
||||
const [type, accessToken] = headers.authorization.split(' ');
|
||||
@@ -70,11 +73,7 @@ export class AuthCore {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
extractJwtFromCookie(cookies: Record<string, string>) {
|
||||
extractTokenFromCookie(cookies: Record<string, string>) {
|
||||
return cookies?.[IMMICH_ACCESS_COOKIE] || null;
|
||||
}
|
||||
|
||||
private generateToken(payload: JwtPayloadDto) {
|
||||
return this.cryptoRepository.signJwt({ ...payload });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { generators, Issuer } from 'openid-client';
|
||||
import { Socket } from 'socket.io';
|
||||
import {
|
||||
authStub,
|
||||
entityStub,
|
||||
userEntityStub,
|
||||
loginResponseStub,
|
||||
newCryptoRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
systemConfigStub,
|
||||
userTokenEntityStub,
|
||||
} from '../../test';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user';
|
||||
@@ -17,6 +17,9 @@ import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.
|
||||
import { AuthService } from './auth.service';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { SignUpDto } from './dto';
|
||||
import { IUserTokenRepository } from '@app/domain';
|
||||
import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
|
||||
const email = 'test@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
@@ -47,6 +50,7 @@ describe('AuthService', () => {
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let userTokenMock: jest.Mocked<IUserTokenRepository>;
|
||||
let callbackMock: jest.Mock;
|
||||
let create: (config: SystemConfig) => AuthService;
|
||||
|
||||
@@ -76,8 +80,9 @@ describe('AuthService', () => {
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
userTokenMock = newUserTokenRepositoryMock();
|
||||
|
||||
create = (config) => new AuthService(cryptoMock, configMock, userMock, config);
|
||||
create = (config) => new AuthService(cryptoMock, configMock, userMock, userTokenMock, config);
|
||||
|
||||
sut = create(systemConfigStub.enabled);
|
||||
});
|
||||
@@ -106,13 +111,15 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should successfully log the user in', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(entityStub.user1);
|
||||
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
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);
|
||||
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, false)).resolves.toEqual(loginResponseStub.user1insecure);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -131,7 +138,7 @@ describe('AuthService', () => {
|
||||
await sut.changePassword(authUser, dto);
|
||||
|
||||
expect(userMock.getByEmail).toHaveBeenCalledWith(authUser.email, true);
|
||||
expect(cryptoMock.compareSync).toHaveBeenCalledWith('old-password', 'hash-password');
|
||||
expect(cryptoMock.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password');
|
||||
});
|
||||
|
||||
it('should throw when auth user email is not found', async () => {
|
||||
@@ -147,7 +154,7 @@ describe('AuthService', () => {
|
||||
const authUser = { email: 'test@imimch.com' } as UserEntity;
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
cryptoMock.compareSync.mockReturnValue(false);
|
||||
cryptoMock.compareBcrypt.mockReturnValue(false);
|
||||
|
||||
userMock.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
@@ -161,8 +168,6 @@ describe('AuthService', () => {
|
||||
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: '',
|
||||
@@ -212,52 +217,64 @@ describe('AuthService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSocket', () => {
|
||||
describe('validate - socket connections', () => {
|
||||
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);
|
||||
userMock.get.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
const client = { request: { headers: { authorization: 'Bearer auth_token' } } };
|
||||
await expect(sut.validate((client as Socket).request.headers)).resolves.toEqual(userEntityStub.user1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePayload', () => {
|
||||
describe('validate - api request', () => {
|
||||
it('should throw if no user is found', async () => {
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(sut.validatePayload({ email: 'a', userId: 'test' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.validate({ 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);
|
||||
userMock.get.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
await expect(
|
||||
sut.validate({ cookie: 'immich_access_token=auth_token', email: 'a', userId: 'test' }),
|
||||
).resolves.toEqual(userEntityStub.user1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractJwtFromCookie', () => {
|
||||
describe('extractTokenFromHeader - Cookie', () => {
|
||||
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');
|
||||
const cookie: IncomingHttpHeaders = {
|
||||
cookie: `${IMMICH_ACCESS_COOKIE}=signed-jwt;${IMMICH_AUTH_TYPE_COOKIE}=password`,
|
||||
};
|
||||
expect(sut.extractTokenFromHeader(cookie)).toEqual('signed-jwt');
|
||||
});
|
||||
|
||||
it('should work with no cookies', () => {
|
||||
expect(sut.extractJwtFromCookie(undefined as any)).toBeNull();
|
||||
const cookie: IncomingHttpHeaders = {
|
||||
cookie: undefined,
|
||||
};
|
||||
expect(sut.extractTokenFromHeader(cookie)).toBeNull();
|
||||
});
|
||||
|
||||
it('should work on empty cookies', () => {
|
||||
expect(sut.extractJwtFromCookie({})).toBeNull();
|
||||
const cookie: IncomingHttpHeaders = {
|
||||
cookie: '',
|
||||
};
|
||||
expect(sut.extractTokenFromHeader(cookie)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractJwtFromHeader', () => {
|
||||
describe('extractTokenFromHeader - Bearer Auth', () => {
|
||||
it('should extract the access token', () => {
|
||||
expect(sut.extractJwtFromHeader({ authorization: `Bearer signed-jwt` })).toEqual('signed-jwt');
|
||||
expect(sut.extractTokenFromHeader({ authorization: `Bearer signed-jwt` })).toEqual('signed-jwt');
|
||||
});
|
||||
|
||||
it('should work without the auth header', () => {
|
||||
expect(sut.extractJwtFromHeader({})).toBeNull();
|
||||
expect(sut.extractTokenFromHeader({})).toBeNull();
|
||||
});
|
||||
|
||||
it('should ignore basic auth', () => {
|
||||
expect(sut.extractJwtFromHeader({ authorization: `Basic stuff` })).toBeNull();
|
||||
expect(sut.extractTokenFromHeader({ authorization: `Basic stuff` })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,20 +7,20 @@ import {
|
||||
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 { IUserRepository, UserCore } from '../user';
|
||||
import { AuthType } from './auth.constant';
|
||||
import { AuthCore } from './auth.core';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { AuthUserDto, ChangePasswordDto, JwtPayloadDto, LoginCredentialDto, SignUpDto } from './dto';
|
||||
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
|
||||
import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto';
|
||||
import { IUserTokenRepository, UserTokenCore } from '@app/domain/user-token';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private userTokenCore: UserTokenCore;
|
||||
private authCore: AuthCore;
|
||||
private oauthCore: OAuthCore;
|
||||
private userCore: UserCore;
|
||||
@@ -31,11 +31,14 @@ export class AuthService {
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
@Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig,
|
||||
@Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
|
||||
@Inject(INITIAL_SYSTEM_CONFIG)
|
||||
initialConfig: SystemConfig,
|
||||
) {
|
||||
this.authCore = new AuthCore(cryptoRepository, configRepository, initialConfig);
|
||||
this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository);
|
||||
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
|
||||
this.oauthCore = new OAuthCore(configRepository, initialConfig);
|
||||
this.userCore = new UserCore(userRepository);
|
||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||
}
|
||||
|
||||
public async login(
|
||||
@@ -49,7 +52,7 @@ export class AuthService {
|
||||
|
||||
let user = await this.userCore.getByEmail(loginCredential.email, true);
|
||||
if (user) {
|
||||
const isAuthenticated = await this.authCore.validatePassword(loginCredential.password, user);
|
||||
const isAuthenticated = this.authCore.validatePassword(loginCredential.password, user);
|
||||
if (!isAuthenticated) {
|
||||
user = null;
|
||||
}
|
||||
@@ -81,7 +84,7 @@ export class AuthService {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const valid = await this.authCore.validatePassword(password, user);
|
||||
const valid = this.authCore.validatePassword(password, user);
|
||||
if (!valid) {
|
||||
throw new BadRequestException('Wrong password');
|
||||
}
|
||||
@@ -112,49 +115,28 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
public async validate(headers: IncomingHttpHeaders): Promise<AuthUserDto> {
|
||||
const tokenValue = this.extractTokenFromHeader(headers);
|
||||
if (!tokenValue) {
|
||||
throw new UnauthorizedException('No access token provided in request');
|
||||
}
|
||||
|
||||
const authUser = new AuthUserDto();
|
||||
authUser.id = user.id;
|
||||
authUser.email = user.email;
|
||||
authUser.isAdmin = user.isAdmin;
|
||||
authUser.isPublicUser = false;
|
||||
authUser.isAllowUpload = true;
|
||||
const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
|
||||
const user = await this.userTokenCore.getUserByToken(hashedToken);
|
||||
if (user) {
|
||||
return {
|
||||
...user,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
isAllowDownload: true,
|
||||
isShowExif: true,
|
||||
};
|
||||
}
|
||||
|
||||
return authUser;
|
||||
throw new UnauthorizedException('Invalid access token provided');
|
||||
}
|
||||
|
||||
extractJwtFromCookie(cookies: Record<string, string>) {
|
||||
return this.authCore.extractJwtFromCookie(cookies);
|
||||
}
|
||||
|
||||
extractJwtFromHeader(headers: IncomingHttpHeaders) {
|
||||
return this.authCore.extractJwtFromHeader(headers);
|
||||
extractTokenFromHeader(headers: IncomingHttpHeaders) {
|
||||
return this.authCore.extractTokenFromHeader(headers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
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>;
|
||||
hashSha256(data: string): string;
|
||||
hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
|
||||
compareBcrypt(data: string | Buffer, encrypted: string): boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './auth.config';
|
||||
export * from './auth.constant';
|
||||
export * from './auth.service';
|
||||
export * from './crypto.repository';
|
||||
|
||||
@@ -13,7 +13,6 @@ const providers: Provider[] = [
|
||||
SystemConfigService,
|
||||
UserService,
|
||||
ShareService,
|
||||
|
||||
{
|
||||
provide: INITIAL_SYSTEM_CONFIG,
|
||||
inject: [SystemConfigService],
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from './share';
|
||||
export * from './system-config';
|
||||
export * from './tag';
|
||||
export * from './user';
|
||||
export * from './user-token';
|
||||
|
||||
@@ -3,17 +3,20 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { generators, Issuer } from 'openid-client';
|
||||
import {
|
||||
authStub,
|
||||
entityStub,
|
||||
userEntityStub,
|
||||
loginResponseStub,
|
||||
newCryptoRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
systemConfigStub,
|
||||
userTokenEntityStub,
|
||||
} from '../../test';
|
||||
import { ICryptoRepository } from '../auth';
|
||||
import { OAuthService } from '../oauth';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user';
|
||||
import { IUserTokenRepository } from '@app/domain';
|
||||
import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock';
|
||||
|
||||
const email = 'user@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
@@ -35,6 +38,7 @@ describe('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;
|
||||
|
||||
@@ -60,8 +64,9 @@ describe('OAuthService', () => {
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
userTokenMock = newUserTokenRepositoryMock();
|
||||
|
||||
create = (config) => new OAuthService(cryptoMock, configMock, userMock, config);
|
||||
create = (config) => new OAuthService(cryptoMock, configMock, userMock, userTokenMock, config);
|
||||
|
||||
sut = create(systemConfigStub.disabled);
|
||||
});
|
||||
@@ -106,23 +111,25 @@ describe('OAuthService', () => {
|
||||
|
||||
it('should link an existing user', async () => {
|
||||
sut = create(systemConfigStub.noAutoRegister);
|
||||
userMock.getByEmail.mockResolvedValue(entityStub.user1);
|
||||
userMock.update.mockResolvedValue(entityStub.user1);
|
||||
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' }, true)).resolves.toEqual(
|
||||
loginResponseStub.user1oauth,
|
||||
);
|
||||
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(userMock.update).toHaveBeenCalledWith(entityStub.user1.id, { oauthId: sub });
|
||||
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(entityStub.user1);
|
||||
userMock.create.mockResolvedValue(entityStub.user1);
|
||||
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' }, true)).resolves.toEqual(
|
||||
loginResponseStub.user1oauth,
|
||||
@@ -135,7 +142,8 @@ describe('OAuthService', () => {
|
||||
it('should use the mobile redirect override', async () => {
|
||||
sut = create(systemConfigStub.override);
|
||||
|
||||
userMock.getByOAuthId.mockResolvedValue(entityStub.user1);
|
||||
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
|
||||
await sut.login({ url: `app.immich:/?code=abc123` }, true);
|
||||
|
||||
@@ -147,7 +155,7 @@ describe('OAuthService', () => {
|
||||
it('should link an account', async () => {
|
||||
sut = create(systemConfigStub.enabled);
|
||||
|
||||
userMock.update.mockResolvedValue(entityStub.user1);
|
||||
userMock.update.mockResolvedValue(userEntityStub.user1);
|
||||
|
||||
await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
|
||||
|
||||
@@ -171,7 +179,7 @@ describe('OAuthService', () => {
|
||||
it('should unlink an account', async () => {
|
||||
sut = create(systemConfigStub.enabled);
|
||||
|
||||
userMock.update.mockResolvedValue(entityStub.user1);
|
||||
userMock.update.mockResolvedValue(userEntityStub.user1);
|
||||
|
||||
await sut.unlink(authStub.user1);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IUserRepository, UserCore, UserResponseDto } from '../user';
|
||||
import { OAuthCallbackDto, OAuthConfigDto } from './dto';
|
||||
import { OAuthCore } from './oauth.core';
|
||||
import { OAuthConfigResponseDto } from './response-dto';
|
||||
import { IUserTokenRepository } from '@app/domain/user-token';
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
@@ -20,10 +21,11 @@ export class OAuthService {
|
||||
@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, initialConfig);
|
||||
this.userCore = new UserCore(userRepository);
|
||||
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
|
||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||
this.oauthCore = new OAuthCore(configRepository, initialConfig);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import {
|
||||
authStub,
|
||||
entityStub,
|
||||
userEntityStub,
|
||||
newCryptoRepositoryMock,
|
||||
newSharedLinkRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
@@ -50,7 +50,7 @@ describe(ShareService.name, () => {
|
||||
|
||||
it('should accept a valid key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
userMock.get.mockResolvedValue(entityStub.admin);
|
||||
userMock.get.mockResolvedValue(userEntityStub.admin);
|
||||
await expect(sut.validate('key')).resolves.toEqual(authStub.adminSharedLink);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ export class ShareService {
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
) {
|
||||
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
|
||||
this.userCore = new UserCore(userRepository);
|
||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||
}
|
||||
|
||||
async validate(key: string): Promise<AuthUserDto> {
|
||||
|
||||
2
server/libs/domain/src/user-token/index.ts
Normal file
2
server/libs/domain/src/user-token/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './user-token.repository';
|
||||
export * from './user-token.core';
|
||||
28
server/libs/domain/src/user-token/user-token.core.ts
Normal file
28
server/libs/domain/src/user-token/user-token.core.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { UserEntity } from '@app/infra/db/entities';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ICryptoRepository } from '../auth';
|
||||
import { IUserTokenRepository } from './user-token.repository';
|
||||
|
||||
@Injectable()
|
||||
export class UserTokenCore {
|
||||
constructor(private crypto: ICryptoRepository, private repository: IUserTokenRepository) {}
|
||||
|
||||
public async getUserByToken(tokenValue: string): Promise<UserEntity | null> {
|
||||
const token = await this.repository.get(tokenValue);
|
||||
if (token?.user) {
|
||||
return token.user;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async createToken(user: UserEntity): 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,
|
||||
});
|
||||
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { UserTokenEntity } from '@app/infra/db/entities';
|
||||
|
||||
export const IUserTokenRepository = 'IUserTokenRepository';
|
||||
|
||||
export interface IUserTokenRepository {
|
||||
create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
|
||||
delete(userToken: string): Promise<void>;
|
||||
get(userToken: string): Promise<UserTokenEntity | null>;
|
||||
}
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
import { hash } from 'bcrypt';
|
||||
import { constants, createReadStream, ReadStream } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { CreateAdminDto, CreateUserDto, CreateUserOAuthDto } from './dto/create-user.dto';
|
||||
import { IUserRepository, UserListFilter } from './user.repository';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
export class UserCore {
|
||||
constructor(private userRepository: IUserRepository) {}
|
||||
constructor(private userRepository: IUserRepository, private cryptoRepository: ICryptoRepository) {}
|
||||
|
||||
async updateUser(authUser: AuthUserDto, id: string, dto: Partial<UserEntity>): Promise<UserEntity> {
|
||||
if (!(authUser.isAdmin || authUser.id === id)) {
|
||||
@@ -37,7 +37,7 @@ export class UserCore {
|
||||
|
||||
try {
|
||||
if (dto.password) {
|
||||
dto.password = await hash(dto.password, SALT_ROUNDS);
|
||||
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
return this.userRepository.update(id, dto);
|
||||
|
||||
@@ -2,8 +2,8 @@ import { IUserRepository } from './user.repository';
|
||||
import { UserEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { when } from 'jest-when';
|
||||
import { newUserRepositoryMock } from '../../test';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { newCryptoRepositoryMock, newUserRepositoryMock } from '../../test';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@@ -77,10 +77,12 @@ const adminUserResponse = Object.freeze({
|
||||
describe(UserService.name, () => {
|
||||
let sut: UserService;
|
||||
let userRepositoryMock: jest.Mocked<IUserRepository>;
|
||||
let cryptoRepositoryMock: jest.Mocked<ICryptoRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
userRepositoryMock = newUserRepositoryMock();
|
||||
sut = new UserService(userRepositoryMock);
|
||||
cryptoRepositoryMock = newCryptoRepositoryMock();
|
||||
sut = new UserService(userRepositoryMock, cryptoRepositoryMock);
|
||||
|
||||
when(userRepositoryMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser);
|
||||
when(userRepositoryMock.get).calledWith(adminUser.id, undefined).mockResolvedValue(adminUser);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { ReadStream } from 'fs';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { IUserRepository } from '../user';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
@@ -17,8 +17,11 @@ import { UserCore } from './user.core';
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private userCore: UserCore;
|
||||
constructor(@Inject(IUserRepository) userRepository: IUserRepository) {
|
||||
this.userCore = new UserCore(userRepository);
|
||||
constructor(
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||
) {
|
||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||
}
|
||||
|
||||
async getAllUsers(authUser: AuthUserDto, isAll: boolean): Promise<UserResponseDto[]> {
|
||||
|
||||
Reference in New Issue
Block a user