mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-12-08 04:09:07 +00:00
refactor(server): auth guard (#1472)
* refactor: auth guard * chore: move auth guard to middleware * chore: tests * chore: remove unused code * fix: migration to uuid without dataloss * chore: e2e tests * chore: removed unused guards
This commit is contained in:
@@ -1,12 +1,10 @@
|
||||
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 { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { LoginResponseDto, mapLoginResponse } from './response-dto';
|
||||
import { IUserTokenRepository, UserTokenCore } from '@app/domain';
|
||||
import cookieParser from 'cookie';
|
||||
import { IUserTokenRepository, UserTokenCore } from '../user-token';
|
||||
|
||||
export type JwtValidationResult = {
|
||||
status: boolean;
|
||||
@@ -59,21 +57,4 @@ export class AuthCore {
|
||||
}
|
||||
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
|
||||
}
|
||||
|
||||
extractTokenFromHeader(headers: IncomingHttpHeaders) {
|
||||
if (!headers.authorization) {
|
||||
return this.extractTokenFromCookie(cookieParser.parse(headers.cookie || ''));
|
||||
}
|
||||
|
||||
const [type, accessToken] = headers.authorization.split(' ');
|
||||
if (type.toLowerCase() !== 'bearer') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
extractTokenFromCookie(cookies: Record<string, string>) {
|
||||
return cookies?.[IMMICH_ACCESS_COOKIE] || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import { SystemConfig, UserEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
import { generators, Issuer } from 'openid-client';
|
||||
import { Socket } from 'socket.io';
|
||||
import {
|
||||
userEntityStub,
|
||||
authStub,
|
||||
keyStub,
|
||||
loginResponseStub,
|
||||
newCryptoRepositoryMock,
|
||||
newKeyRepositoryMock,
|
||||
newSharedLinkRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
newUserTokenRepositoryMock,
|
||||
sharedLinkStub,
|
||||
systemConfigStub,
|
||||
userEntityStub,
|
||||
userTokenEntityStub,
|
||||
} from '../../test';
|
||||
import { IKeyRepository } from '../api-key';
|
||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { ISharedLinkRepository } from '../share';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
|
||||
import { IUserTokenRepository } from '../user-token';
|
||||
import { AuthType } from './auth.constant';
|
||||
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 token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
||||
|
||||
const email = 'test@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
@@ -51,6 +60,8 @@ describe('AuthService', () => {
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let userTokenMock: jest.Mocked<IUserTokenRepository>;
|
||||
let shareMock: jest.Mocked<ISharedLinkRepository>;
|
||||
let keyMock: jest.Mocked<IKeyRepository>;
|
||||
let callbackMock: jest.Mock;
|
||||
let create: (config: SystemConfig) => AuthService;
|
||||
|
||||
@@ -81,8 +92,10 @@ describe('AuthService', () => {
|
||||
userMock = newUserRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
userTokenMock = newUserTokenRepositoryMock();
|
||||
shareMock = newSharedLinkRepositoryMock();
|
||||
keyMock = newKeyRepositoryMock();
|
||||
|
||||
create = (config) => new AuthService(cryptoMock, configMock, userMock, userTokenMock, config);
|
||||
create = (config) => new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock, config);
|
||||
|
||||
sut = create(systemConfigStub.enabled);
|
||||
});
|
||||
@@ -218,63 +231,73 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
describe('validate - socket connections', () => {
|
||||
it('should throw token is not provided', async () => {
|
||||
await expect(sut.validate({}, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should validate using authorization header', async () => {
|
||||
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);
|
||||
await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual(userEntityStub.user1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate - api request', () => {
|
||||
it('should throw if no user is found', async () => {
|
||||
describe('validate - shared key', () => {
|
||||
it('should not accept a non-existent key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(null);
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should not accept an expired key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should not accept a key without a user', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(sut.validate({ email: 'a', userId: 'test' })).resolves.toBeNull();
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should accept a valid key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
userMock.get.mockResolvedValue(userEntityStub.admin);
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' };
|
||||
await expect(sut.validate(headers, {})).resolves.toEqual(authStub.adminSharedLink);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate - user token', () => {
|
||||
it('should throw if no token is found', async () => {
|
||||
userTokenMock.get.mockResolvedValue(null);
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
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);
|
||||
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
|
||||
await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTokenFromHeader - Cookie', () => {
|
||||
it('should extract the access token', () => {
|
||||
const cookie: IncomingHttpHeaders = {
|
||||
cookie: `${IMMICH_ACCESS_COOKIE}=signed-jwt;${IMMICH_AUTH_TYPE_COOKIE}=password`,
|
||||
};
|
||||
expect(sut.extractTokenFromHeader(cookie)).toEqual('signed-jwt');
|
||||
describe('validate - api key', () => {
|
||||
it('should throw an error if no api key is found', async () => {
|
||||
keyMock.getKey.mockResolvedValue(null);
|
||||
const headers: IncomingHttpHeaders = { 'x-api-key': 'auth_token' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
|
||||
});
|
||||
|
||||
it('should work with no cookies', () => {
|
||||
const cookie: IncomingHttpHeaders = {
|
||||
cookie: undefined,
|
||||
};
|
||||
expect(sut.extractTokenFromHeader(cookie)).toBeNull();
|
||||
});
|
||||
|
||||
it('should work on empty cookies', () => {
|
||||
const cookie: IncomingHttpHeaders = {
|
||||
cookie: '',
|
||||
};
|
||||
expect(sut.extractTokenFromHeader(cookie)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTokenFromHeader - Bearer Auth', () => {
|
||||
it('should extract the access token', () => {
|
||||
expect(sut.extractTokenFromHeader({ authorization: `Bearer signed-jwt` })).toEqual('signed-jwt');
|
||||
});
|
||||
|
||||
it('should work without the auth header', () => {
|
||||
expect(sut.extractTokenFromHeader({})).toBeNull();
|
||||
});
|
||||
|
||||
it('should ignore basic auth', () => {
|
||||
expect(sut.extractTokenFromHeader({ authorization: `Basic stuff` })).toBeNull();
|
||||
it('should return an auth dto', async () => {
|
||||
keyMock.getKey.mockResolvedValue(keyStub.admin);
|
||||
const headers: IncomingHttpHeaders = { 'x-api-key': 'auth_token' };
|
||||
await expect(sut.validate(headers, {})).resolves.toEqual(authStub.admin);
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,16 @@ import { IncomingHttpHeaders } from 'http';
|
||||
import { OAuthCore } from '../oauth/oauth.core';
|
||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository, UserCore } from '../user';
|
||||
import { AuthType } from './auth.constant';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE } from './auth.constant';
|
||||
import { AuthCore } from './auth.core';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
|
||||
import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto';
|
||||
import { IUserTokenRepository, UserTokenCore } from '@app/domain/user-token';
|
||||
import { IUserTokenRepository, UserTokenCore } from '../user-token';
|
||||
import cookieParser from 'cookie';
|
||||
import { ISharedLinkRepository, ShareCore } from '../share';
|
||||
import { APIKeyCore } from '../api-key/api-key.core';
|
||||
import { IKeyRepository } from '../api-key';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -24,14 +28,18 @@ export class AuthService {
|
||||
private authCore: AuthCore;
|
||||
private oauthCore: OAuthCore;
|
||||
private userCore: UserCore;
|
||||
private shareCore: ShareCore;
|
||||
private keyCore: APIKeyCore;
|
||||
|
||||
private logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
@Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
|
||||
@Inject(ISharedLinkRepository) shareRepository: ISharedLinkRepository,
|
||||
@Inject(IKeyRepository) keyRepository: IKeyRepository,
|
||||
@Inject(INITIAL_SYSTEM_CONFIG)
|
||||
initialConfig: SystemConfig,
|
||||
) {
|
||||
@@ -39,6 +47,8 @@ export class AuthService {
|
||||
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
|
||||
this.oauthCore = new OAuthCore(configRepository, initialConfig);
|
||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||
this.shareCore = new ShareCore(shareRepository, cryptoRepository);
|
||||
this.keyCore = new APIKeyCore(cryptoRepository, keyRepository);
|
||||
}
|
||||
|
||||
public async login(
|
||||
@@ -115,28 +125,40 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
public async validate(headers: IncomingHttpHeaders): Promise<AuthUserDto | null> {
|
||||
const tokenValue = this.extractTokenFromHeader(headers);
|
||||
if (!tokenValue) {
|
||||
return null;
|
||||
public async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto | null> {
|
||||
const shareKey = (headers['x-immich-share-key'] || params.key) as string;
|
||||
const userToken = (headers['x-immich-user-token'] ||
|
||||
params.userToken ||
|
||||
this.getBearerToken(headers) ||
|
||||
this.getCookieToken(headers)) as string;
|
||||
const apiKey = (headers['x-api-key'] || params.apiKey) as string;
|
||||
|
||||
if (shareKey) {
|
||||
return this.shareCore.validate(shareKey);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
if (userToken) {
|
||||
return this.userTokenCore.validate(userToken);
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
return this.keyCore.validate(apiKey);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
private getBearerToken(headers: IncomingHttpHeaders): string | null {
|
||||
const [type, token] = (headers.authorization || '').split(' ');
|
||||
if (type.toLowerCase() === 'bearer') {
|
||||
return token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
extractTokenFromHeader(headers: IncomingHttpHeaders) {
|
||||
return this.authCore.extractTokenFromHeader(headers);
|
||||
private getCookieToken(headers: IncomingHttpHeaders): string | null {
|
||||
const cookies = cookieParser.parse(headers.cookie || '');
|
||||
return cookies[IMMICH_ACCESS_COOKIE] || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export const ICryptoRepository = 'ICryptoRepository';
|
||||
|
||||
export interface ICryptoRepository {
|
||||
randomBytes(size: number): Buffer;
|
||||
hashSha256(data: string): string;
|
||||
hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
|
||||
compareBcrypt(data: string | Buffer, encrypted: string): boolean;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from './auth.constant';
|
||||
export * from './auth.service';
|
||||
export * from './crypto.repository';
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
|
||||
Reference in New Issue
Block a user