diff --git a/server/apps/immich/src/api-v1/communication/communication.gateway.ts b/server/apps/immich/src/api-v1/communication/communication.gateway.ts index 11eccab01d..758b1a8b71 100644 --- a/server/apps/immich/src/api-v1/communication/communication.gateway.ts +++ b/server/apps/immich/src/api-v1/communication/communication.gateway.ts @@ -19,7 +19,7 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco async handleConnection(client: Socket) { try { this.logger.log(`New websocket connection: ${client.id}`); - const user = await this.authService.validate(client.request.headers); + const user = await this.authService.validate(client.request.headers, {}); if (user) { client.join(user.id); } else { diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index 586b29f100..7b75fb546c 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -21,9 +21,8 @@ import { SystemConfigController, UserController, } from './controllers'; -import { PublicShareStrategy } from './modules/immich-auth/strategies/public-share.strategy'; -import { APIKeyStrategy } from './modules/immich-auth/strategies/api-key.strategy'; -import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.strategy'; +import { APP_GUARD } from '@nestjs/core'; +import { AuthGuard } from './middlewares/auth.guard'; @Module({ imports: [ @@ -61,7 +60,7 @@ import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.str SystemConfigController, UserController, ], - providers: [UserAuthStrategy, APIKeyStrategy, PublicShareStrategy], + providers: [{ provide: APP_GUARD, useExisting: AuthGuard }, AuthGuard], }) export class AppModule implements NestModule { // TODO: check if consumer is needed or remove diff --git a/server/apps/immich/src/decorators/authenticated.decorator.ts b/server/apps/immich/src/decorators/authenticated.decorator.ts index 6e3009e2f5..212b65bdbb 100644 --- a/server/apps/immich/src/decorators/authenticated.decorator.ts +++ b/server/apps/immich/src/decorators/authenticated.decorator.ts @@ -1,25 +1,28 @@ -import { UseGuards } from '@nestjs/common'; -import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware'; -import { RouteNotSharedGuard } from '../middlewares/route-not-shared-guard.middleware'; -import { AuthGuard } from '../modules/immich-auth/guards/auth.guard'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; interface AuthenticatedOptions { admin?: boolean; isShared?: boolean; } +export enum Metadata { + AUTH_ROUTE = 'auth_route', + ADMIN_ROUTE = 'admin_route', + SHARED_ROUTE = 'shared_route', +} + export const Authenticated = (options?: AuthenticatedOptions) => { - const guards: Parameters = [AuthGuard]; + const decorators = [SetMetadata(Metadata.AUTH_ROUTE, true)]; options = options || {}; if (options.admin) { - guards.push(AdminRolesGuard); + decorators.push(SetMetadata(Metadata.ADMIN_ROUTE, true)); } - if (!options.isShared) { - guards.push(RouteNotSharedGuard); + if (options.isShared) { + decorators.push(SetMetadata(Metadata.SHARED_ROUTE, true)); } - return UseGuards(...guards); + return applyDecorators(...decorators); }; diff --git a/server/apps/immich/src/middlewares/admin-role-guard.middleware.ts b/server/apps/immich/src/middlewares/admin-role-guard.middleware.ts deleted file mode 100644 index 37a3be1e4b..0000000000 --- a/server/apps/immich/src/middlewares/admin-role-guard.middleware.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common'; -import { Request } from 'express'; -import { UserResponseDto } from '@app/domain'; - -interface UserRequest extends Request { - user: UserResponseDto; -} - -@Injectable() -export class AdminRolesGuard implements CanActivate { - logger = new Logger(AdminRolesGuard.name); - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const isAdmin = request.user?.isAdmin || false; - if (!isAdmin) { - this.logger.log(`Denied access to admin only route: ${request.path}`); - return false; - } - - return true; - } -} diff --git a/server/apps/immich/src/middlewares/auth.guard.ts b/server/apps/immich/src/middlewares/auth.guard.ts new file mode 100644 index 0000000000..37b0f6ba96 --- /dev/null +++ b/server/apps/immich/src/middlewares/auth.guard.ts @@ -0,0 +1,46 @@ +import { AuthService } from '@app/domain'; +import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { Metadata } from '../decorators/authenticated.decorator'; + +@Injectable() +export class AuthGuard implements CanActivate { + private logger = new Logger(AuthGuard.name); + + constructor(private reflector: Reflector, private authService: AuthService) {} + + async canActivate(context: ExecutionContext): Promise { + const targets = [context.getHandler(), context.getClass()]; + + const isAuthRoute = this.reflector.getAllAndOverride(Metadata.AUTH_ROUTE, targets); + const isAdminRoute = this.reflector.getAllAndOverride(Metadata.ADMIN_ROUTE, targets); + const isSharedRoute = this.reflector.getAllAndOverride(Metadata.SHARED_ROUTE, targets); + + if (!isAuthRoute) { + return true; + } + + const req = context.switchToHttp().getRequest(); + + const authDto = await this.authService.validate(req.headers, req.query as Record); + if (!authDto) { + this.logger.warn(`Denied access to authenticated route: ${req.path}`); + return false; + } + + if (authDto.isPublicUser && !isSharedRoute) { + this.logger.warn(`Denied access to non-shared route: ${req.path}`); + return false; + } + + if (isAdminRoute && !authDto.isAdmin) { + this.logger.warn(`Denied access to admin only route: ${req.path}`); + return false; + } + + req.user = authDto; + + return true; + } +} diff --git a/server/apps/immich/src/middlewares/route-not-shared-guard.middleware.ts b/server/apps/immich/src/middlewares/route-not-shared-guard.middleware.ts deleted file mode 100644 index bb90c607d8..0000000000 --- a/server/apps/immich/src/middlewares/route-not-shared-guard.middleware.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common'; -import { Request } from 'express'; -import { AuthUserDto } from '../decorators/auth-user.decorator'; - -@Injectable() -export class RouteNotSharedGuard implements CanActivate { - logger = new Logger(RouteNotSharedGuard.name); - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const user = request.user as AuthUserDto; - - // Inverse logic - I know it is weird - if (user.isPublicUser) { - this.logger.warn(`Denied attempt to access non-shared route: ${request.path}`); - return false; - } - - return true; - } -} diff --git a/server/apps/immich/src/modules/immich-auth/guards/auth.guard.ts b/server/apps/immich/src/modules/immich-auth/guards/auth.guard.ts deleted file mode 100644 index 9babdf8175..0000000000 --- a/server/apps/immich/src/modules/immich-auth/guards/auth.guard.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard as PassportAuthGuard } from '@nestjs/passport'; -import { API_KEY_STRATEGY } from '../strategies/api-key.strategy'; -import { AUTH_COOKIE_STRATEGY } from '../strategies/user-auth.strategy'; -import { PUBLIC_SHARE_STRATEGY } from '../strategies/public-share.strategy'; - -@Injectable() -export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, AUTH_COOKIE_STRATEGY, API_KEY_STRATEGY]) {} diff --git a/server/apps/immich/src/modules/immich-auth/strategies/api-key.strategy.ts b/server/apps/immich/src/modules/immich-auth/strategies/api-key.strategy.ts deleted file mode 100644 index a6e306cd73..0000000000 --- a/server/apps/immich/src/modules/immich-auth/strategies/api-key.strategy.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { APIKeyService, AuthUserDto } from '@app/domain'; -import { Injectable } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { IStrategyOptions, Strategy } from 'passport-http-header-strategy'; - -export const API_KEY_STRATEGY = 'api-key'; - -const options: IStrategyOptions = { - header: 'x-api-key', -}; - -@Injectable() -export class APIKeyStrategy extends PassportStrategy(Strategy, API_KEY_STRATEGY) { - constructor(private apiKeyService: APIKeyService) { - super(options); - } - - validate(token: string): Promise { - return this.apiKeyService.validate(token); - } -} diff --git a/server/apps/immich/src/modules/immich-auth/strategies/public-share.strategy.ts b/server/apps/immich/src/modules/immich-auth/strategies/public-share.strategy.ts deleted file mode 100644 index e42575cda5..0000000000 --- a/server/apps/immich/src/modules/immich-auth/strategies/public-share.strategy.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { IStrategyOptions, Strategy } from 'passport-http-header-strategy'; -import { AuthUserDto, ShareService } from '@app/domain'; - -export const PUBLIC_SHARE_STRATEGY = 'public-share'; - -const options: IStrategyOptions = { - header: 'x-immich-share-key', - param: 'key', -}; - -@Injectable() -export class PublicShareStrategy extends PassportStrategy(Strategy, PUBLIC_SHARE_STRATEGY) { - constructor(private shareService: ShareService) { - super(options); - } - - validate(key: string): Promise { - return this.shareService.validate(key); - } -} diff --git a/server/apps/immich/src/modules/immich-auth/strategies/user-auth.strategy.ts b/server/apps/immich/src/modules/immich-auth/strategies/user-auth.strategy.ts deleted file mode 100644 index 717482e3dd..0000000000 --- a/server/apps/immich/src/modules/immich-auth/strategies/user-auth.strategy.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { AuthService, AuthUserDto } from '@app/domain'; -import { Injectable } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { Request } from 'express'; -import { Strategy } from 'passport-custom'; - -export const AUTH_COOKIE_STRATEGY = 'auth-cookie'; - -@Injectable() -export class UserAuthStrategy extends PassportStrategy(Strategy, AUTH_COOKIE_STRATEGY) { - constructor(private authService: AuthService) { - super(); - } - - validate(request: Request): Promise { - return this.authService.validate(request.headers); - } -} diff --git a/server/apps/immich/test/album.e2e-spec.ts b/server/apps/immich/test/album.e2e-spec.ts index 17a2bd23aa..351f3f062b 100644 --- a/server/apps/immich/test/album.e2e-spec.ts +++ b/server/apps/immich/test/album.e2e-spec.ts @@ -2,11 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import { clearDb, getAuthUser, authCustom } from './test-utils'; -import { InfraModule } from '@app/infra'; -import { AlbumModule } from '../src/api-v1/album/album.module'; import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto'; import { AuthUserDto } from '../src/decorators/auth-user.decorator'; -import { AuthService, DomainModule, UserService } from '@app/domain'; +import { AuthService, UserService } from '@app/domain'; import { DataSource } from 'typeorm'; import { AppModule } from '../src/app.module'; @@ -20,9 +18,7 @@ describe('Album', () => { describe('without auth', () => { beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [DomainModule.register({ imports: [InfraModule] }), AppModule], - }).compile(); + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); app = moduleFixture.createNestApplication(); database = app.get(DataSource); @@ -46,9 +42,7 @@ describe('Album', () => { let authService: AuthService; beforeAll(async () => { - const builder = Test.createTestingModule({ - imports: [DomainModule.register({ imports: [InfraModule] }), AlbumModule], - }); + const builder = Test.createTestingModule({ imports: [AppModule] }); authUser = getAuthUser(); // set default auth user const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile(); diff --git a/server/apps/immich/test/test-utils.ts b/server/apps/immich/test/test-utils.ts index 67de60d19f..12de38a646 100644 --- a/server/apps/immich/test/test-utils.ts +++ b/server/apps/immich/test/test-utils.ts @@ -2,7 +2,7 @@ import { CanActivate, ExecutionContext } from '@nestjs/common'; import { TestingModuleBuilder } from '@nestjs/testing'; import { DataSource } from 'typeorm'; import { AuthUserDto } from '../src/decorators/auth-user.decorator'; -import { AuthGuard } from '../src/modules/immich-auth/guards/auth.guard'; +import { AuthGuard } from '../src/middlewares/auth.guard'; type CustomAuthCallback = () => AuthUserDto; @@ -34,5 +34,5 @@ export function authCustom(builder: TestingModuleBuilder, callback: CustomAuthCa return true; }, }; - return builder.overrideGuard(AuthGuard).useValue(canActivate); + return builder.overrideProvider(AuthGuard).useValue(canActivate); } diff --git a/server/apps/immich/test/user.e2e-spec.ts b/server/apps/immich/test/user.e2e-spec.ts index 173f2e357d..7446e3f373 100644 --- a/server/apps/immich/test/user.e2e-spec.ts +++ b/server/apps/immich/test/user.e2e-spec.ts @@ -2,10 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import { clearDb, authCustom } from './test-utils'; -import { InfraModule } from '@app/infra'; -import { DomainModule, CreateUserDto, UserService, AuthUserDto } from '@app/domain'; +import { CreateUserDto, UserService, AuthUserDto } from '@app/domain'; import { DataSource } from 'typeorm'; -import { UserController } from '../src/controllers'; import { AuthService } from '@app/domain'; import { AppModule } from '../src/app.module'; @@ -24,10 +22,7 @@ describe('User', () => { describe('without auth', () => { beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [DomainModule.register({ imports: [InfraModule] }), AppModule], - controllers: [UserController], - }).compile(); + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); app = moduleFixture.createNestApplication(); database = app.get(DataSource); @@ -50,10 +45,7 @@ describe('User', () => { let authUser: AuthUserDto; beforeAll(async () => { - const builder = Test.createTestingModule({ - imports: [DomainModule.register({ imports: [InfraModule] })], - controllers: [UserController], - }); + const builder = Test.createTestingModule({ imports: [AppModule] }); const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile(); app = moduleFixture.createNestApplication(); diff --git a/server/libs/domain/src/api-key/api-key.core.ts b/server/libs/domain/src/api-key/api-key.core.ts new file mode 100644 index 0000000000..f70b3fee57 --- /dev/null +++ b/server/libs/domain/src/api-key/api-key.core.ts @@ -0,0 +1,27 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthUserDto } from '../auth'; +import { ICryptoRepository } from '../crypto'; +import { IKeyRepository } from './api-key.repository'; + +@Injectable() +export class APIKeyCore { + constructor(private crypto: ICryptoRepository, private repository: IKeyRepository) {} + + async validate(token: string): Promise { + const hashedToken = this.crypto.hashSha256(token); + const keyEntity = await this.repository.getKey(hashedToken); + if (keyEntity?.user) { + const user = keyEntity.user; + + return { + id: user.id, + email: user.email, + isAdmin: user.isAdmin, + isPublicUser: false, + isAllowUpload: true, + }; + } + + throw new UnauthorizedException('Invalid API key'); + } +} diff --git a/server/libs/domain/src/api-key/api-key.service.spec.ts b/server/libs/domain/src/api-key/api-key.service.spec.ts index 2788ba7098..89d4c1ea8b 100644 --- a/server/libs/domain/src/api-key/api-key.service.spec.ts +++ b/server/libs/domain/src/api-key/api-key.service.spec.ts @@ -1,20 +1,9 @@ -import { APIKeyEntity } from '@app/infra/db/entities'; import { BadRequestException } from '@nestjs/common'; -import { authStub, userEntityStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test'; -import { ICryptoRepository } from '../auth'; +import { authStub, keyStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test'; +import { ICryptoRepository } from '../crypto'; import { IKeyRepository } from './api-key.repository'; import { APIKeyService } from './api-key.service'; -const adminKey = Object.freeze({ - id: 1, - name: 'My Key', - key: 'my-api-key (hashed)', - userId: authStub.admin.id, - user: userEntityStub.admin, -} as APIKeyEntity); - -const token = Buffer.from('my-api-key', 'utf8').toString('base64'); - describe(APIKeyService.name, () => { let sut: APIKeyService; let keyMock: jest.Mocked; @@ -28,10 +17,8 @@ describe(APIKeyService.name, () => { describe('create', () => { it('should create a new key', async () => { - keyMock.create.mockResolvedValue(adminKey); - + keyMock.create.mockResolvedValue(keyStub.admin); await sut.create(authStub.admin, { name: 'Test Key' }); - expect(keyMock.create).toHaveBeenCalledWith({ key: 'cmFuZG9tLWJ5dGVz (hashed)', name: 'Test Key', @@ -42,7 +29,7 @@ describe(APIKeyService.name, () => { }); it('should not require a name', async () => { - keyMock.create.mockResolvedValue(adminKey); + keyMock.create.mockResolvedValue(keyStub.admin); await sut.create(authStub.admin, {}); @@ -66,7 +53,7 @@ describe(APIKeyService.name, () => { }); it('should update a key', async () => { - keyMock.getById.mockResolvedValue(adminKey); + keyMock.getById.mockResolvedValue(keyStub.admin); await sut.update(authStub.admin, 1, { name: 'New Name' }); @@ -84,7 +71,7 @@ describe(APIKeyService.name, () => { }); it('should delete a key', async () => { - keyMock.getById.mockResolvedValue(adminKey); + keyMock.getById.mockResolvedValue(keyStub.admin); await sut.delete(authStub.admin, 1); @@ -102,7 +89,7 @@ describe(APIKeyService.name, () => { }); it('should get a key by id', async () => { - keyMock.getById.mockResolvedValue(adminKey); + keyMock.getById.mockResolvedValue(keyStub.admin); await sut.getById(authStub.admin, 1); @@ -112,29 +99,11 @@ describe(APIKeyService.name, () => { describe('getAll', () => { it('should return all the keys for a user', async () => { - keyMock.getByUserId.mockResolvedValue([adminKey]); + keyMock.getByUserId.mockResolvedValue([keyStub.admin]); await expect(sut.getAll(authStub.admin)).resolves.toHaveLength(1); expect(keyMock.getByUserId).toHaveBeenCalledWith(authStub.admin.id); }); }); - - describe('validate', () => { - it('should throw an error for an invalid id', async () => { - keyMock.getKey.mockResolvedValue(null); - - await expect(sut.validate(token)).resolves.toBeNull(); - - expect(keyMock.getKey).toHaveBeenCalledWith('bXktYXBpLWtleQ== (hashed)'); - }); - - it('should validate the token', async () => { - keyMock.getKey.mockResolvedValue(adminKey); - - await expect(sut.validate(token)).resolves.toEqual(authStub.admin); - - expect(keyMock.getKey).toHaveBeenCalledWith('bXktYXBpLWtleQ== (hashed)'); - }); - }); }); diff --git a/server/libs/domain/src/api-key/api-key.service.ts b/server/libs/domain/src/api-key/api-key.service.ts index 218f57559d..cc08e1fe98 100644 --- a/server/libs/domain/src/api-key/api-key.service.ts +++ b/server/libs/domain/src/api-key/api-key.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AuthUserDto, ICryptoRepository } from '../auth'; +import { AuthUserDto } from '../auth'; +import { ICryptoRepository } from '../crypto'; import { IKeyRepository } from './api-key.repository'; import { APIKeyCreateDto } from './dto/api-key-create.dto'; import { APIKeyCreateResponseDto } from './response-dto/api-key-create-response.dto'; @@ -55,22 +56,4 @@ export class APIKeyService { const keys = await this.repository.getByUserId(authUser.id); return keys.map(mapKey); } - - async validate(token: string): Promise { - const hashedToken = this.crypto.hashSha256(token); - const keyEntity = await this.repository.getKey(hashedToken); - if (keyEntity?.user) { - const user = keyEntity.user; - - return { - id: user.id, - email: user.email, - isAdmin: user.isAdmin, - isPublicUser: false, - isAllowUpload: true, - }; - } - - return null; - } } diff --git a/server/libs/domain/src/auth/auth.core.ts b/server/libs/domain/src/auth/auth.core.ts index f7f749f06f..9b4d54ac53 100644 --- a/server/libs/domain/src/auth/auth.core.ts +++ b/server/libs/domain/src/auth/auth.core.ts @@ -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) { - return cookies?.[IMMICH_ACCESS_COOKIE] || null; - } } diff --git a/server/libs/domain/src/auth/auth.service.spec.ts b/server/libs/domain/src/auth/auth.service.spec.ts index 6cb59a7f24..b4268d4eab 100644 --- a/server/libs/domain/src/auth/auth.service.spec.ts +++ b/server/libs/domain/src/auth/auth.service.spec.ts @@ -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; let configMock: jest.Mocked; let userTokenMock: jest.Mocked; + let shareMock: jest.Mocked; + let keyMock: jest.Mocked; 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)'); }); }); }); diff --git a/server/libs/domain/src/auth/auth.service.ts b/server/libs/domain/src/auth/auth.service.ts index 7b15522dc6..5cd7e13032 100644 --- a/server/libs/domain/src/auth/auth.service.ts +++ b/server/libs/domain/src/auth/auth.service.ts @@ -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 { - const tokenValue = this.extractTokenFromHeader(headers); - if (!tokenValue) { - return null; + public async validate(headers: IncomingHttpHeaders, params: Record): Promise { + const shareKey = (headers['x-immich-share-key'] || params.key) as string; + const userToken = (headers['x-immich-user-token'] || + params.userToken || + 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; } } diff --git a/server/libs/domain/src/auth/index.ts b/server/libs/domain/src/auth/index.ts index 118de239ea..d3aa704ba1 100644 --- a/server/libs/domain/src/auth/index.ts +++ b/server/libs/domain/src/auth/index.ts @@ -1,5 +1,4 @@ export * from './auth.constant'; export * from './auth.service'; -export * from './crypto.repository'; export * from './dto'; export * from './response-dto'; diff --git a/server/libs/domain/src/auth/crypto.repository.ts b/server/libs/domain/src/crypto/crypto.repository.ts similarity index 100% rename from server/libs/domain/src/auth/crypto.repository.ts rename to server/libs/domain/src/crypto/crypto.repository.ts diff --git a/server/libs/domain/src/crypto/index.ts b/server/libs/domain/src/crypto/index.ts new file mode 100644 index 0000000000..93bf8a30c2 --- /dev/null +++ b/server/libs/domain/src/crypto/index.ts @@ -0,0 +1 @@ +export * from './crypto.repository'; diff --git a/server/libs/domain/src/index.ts b/server/libs/domain/src/index.ts index 38b491751d..5ed33f1956 100644 --- a/server/libs/domain/src/index.ts +++ b/server/libs/domain/src/index.ts @@ -2,6 +2,7 @@ export * from './album'; export * from './api-key'; export * from './asset'; export * from './auth'; +export * from './crypto'; export * from './domain.module'; export * from './job'; export * from './oauth'; diff --git a/server/libs/domain/src/oauth/oauth.service.spec.ts b/server/libs/domain/src/oauth/oauth.service.spec.ts index 0cf18587d1..36e6a86584 100644 --- a/server/libs/domain/src/oauth/oauth.service.spec.ts +++ b/server/libs/domain/src/oauth/oauth.service.spec.ts @@ -11,11 +11,11 @@ import { systemConfigStub, userTokenEntityStub, } from '../../test'; -import { ICryptoRepository } from '../auth'; +import { ICryptoRepository } from '../crypto'; import { OAuthService } from '../oauth'; import { ISystemConfigRepository } from '../system-config'; import { IUserRepository } from '../user'; -import { IUserTokenRepository } from '@app/domain'; +import { IUserTokenRepository } from '../user-token'; import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock'; const email = 'user@immich.com'; diff --git a/server/libs/domain/src/oauth/oauth.service.ts b/server/libs/domain/src/oauth/oauth.service.ts index 7d919d75ad..df8affc287 100644 --- a/server/libs/domain/src/oauth/oauth.service.ts +++ b/server/libs/domain/src/oauth/oauth.service.ts @@ -1,13 +1,14 @@ import { SystemConfig } from '@app/infra/db/entities'; import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; -import { AuthType, AuthUserDto, ICryptoRepository, LoginResponseDto } from '../auth'; +import { AuthType, AuthUserDto, LoginResponseDto } from '../auth'; +import { ICryptoRepository } from '../crypto'; import { AuthCore } from '../auth/auth.core'; import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config'; 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'; +import { IUserTokenRepository } from '../user-token'; @Injectable() export class OAuthService { diff --git a/server/libs/domain/src/share/share.core.ts b/server/libs/domain/src/share/share.core.ts index ffa8f1e20d..d244c8da9a 100644 --- a/server/libs/domain/src/share/share.core.ts +++ b/server/libs/domain/src/share/share.core.ts @@ -1,6 +1,13 @@ import { AssetEntity, SharedLinkEntity } from '@app/infra/db/entities'; -import { BadRequestException, ForbiddenException, InternalServerErrorException, Logger } from '@nestjs/common'; -import { AuthUserDto, ICryptoRepository } from '../auth'; +import { + BadRequestException, + ForbiddenException, + InternalServerErrorException, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthUserDto } from '../auth'; +import { ICryptoRepository } from '../crypto'; import { CreateSharedLinkDto } from './dto'; import { ISharedLinkRepository } from './shared-link.repository'; @@ -17,10 +24,6 @@ export class ShareCore { return this.repository.get(userId, id); } - getByKey(key: string): Promise { - return this.repository.getByKey(key); - } - create(userId: string, dto: CreateSharedLinkDto): Promise { try { return this.repository.create({ @@ -78,4 +81,26 @@ export class ShareCore { throw new ForbiddenException(); } } + + async validate(key: string): Promise { + const link = await this.repository.getByKey(key); + if (link) { + if (!link.expiresAt || new Date(link.expiresAt) > new Date()) { + const user = link.user; + if (user) { + return { + id: user.id, + email: user.email, + isAdmin: user.isAdmin, + isPublicUser: true, + sharedLinkId: link.id, + isAllowUpload: link.allowUpload, + isAllowDownload: link.allowDownload, + isShowExif: link.showExif, + }; + } + } + } + throw new UnauthorizedException('Invalid share key'); + } } diff --git a/server/libs/domain/src/share/share.service.spec.ts b/server/libs/domain/src/share/share.service.spec.ts index bba5b6fe6b..8d6645a1e1 100644 --- a/server/libs/domain/src/share/share.service.spec.ts +++ b/server/libs/domain/src/share/share.service.spec.ts @@ -1,15 +1,12 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { authStub, - userEntityStub, newCryptoRepositoryMock, newSharedLinkRepositoryMock, - newUserRepositoryMock, sharedLinkResponseStub, sharedLinkStub, } from '../../test'; -import { ICryptoRepository } from '../auth'; -import { IUserRepository } from '../user'; +import { ICryptoRepository } from '../crypto'; import { ShareService } from './share.service'; import { ISharedLinkRepository } from './shared-link.repository'; @@ -17,44 +14,18 @@ describe(ShareService.name, () => { let sut: ShareService; let cryptoMock: jest.Mocked; let shareMock: jest.Mocked; - let userMock: jest.Mocked; beforeEach(async () => { cryptoMock = newCryptoRepositoryMock(); shareMock = newSharedLinkRepositoryMock(); - userMock = newUserRepositoryMock(); - sut = new ShareService(cryptoMock, shareMock, userMock); + sut = new ShareService(cryptoMock, shareMock); }); it('should work', () => { expect(sut).toBeDefined(); }); - describe('validate', () => { - it('should not accept a non-existant key', async () => { - shareMock.getByKey.mockResolvedValue(null); - await expect(sut.validate('key')).resolves.toBeNull(); - }); - - it('should not accept an expired key', async () => { - shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired); - await expect(sut.validate('key')).resolves.toBeNull(); - }); - - it('should not accept a key without a user', async () => { - shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired); - userMock.get.mockResolvedValue(null); - await expect(sut.validate('key')).resolves.toBeNull(); - }); - - it('should accept a valid key', async () => { - shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid); - userMock.get.mockResolvedValue(userEntityStub.admin); - await expect(sut.validate('key')).resolves.toEqual(authStub.adminSharedLink); - }); - }); - describe('getAll', () => { it('should return all keys for a user', async () => { shareMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); @@ -131,20 +102,6 @@ describe(ShareService.name, () => { }); }); - describe('getByKey', () => { - it('should not work on a missing key', async () => { - shareMock.getByKey.mockResolvedValue(null); - await expect(sut.getByKey('secret-key')).rejects.toBeInstanceOf(BadRequestException); - expect(shareMock.getByKey).toHaveBeenCalledWith('secret-key'); - }); - - it('should find a key', async () => { - shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid); - await expect(sut.getByKey('secret-key')).resolves.toEqual(sharedLinkResponseStub.valid); - expect(shareMock.getByKey).toHaveBeenCalledWith('secret-key'); - }); - }); - describe('edit', () => { it('should not work on a missing key', async () => { shareMock.get.mockResolvedValue(null); diff --git a/server/libs/domain/src/share/share.service.ts b/server/libs/domain/src/share/share.service.ts index 6b1f3c5ed3..04deaee681 100644 --- a/server/libs/domain/src/share/share.service.ts +++ b/server/libs/domain/src/share/share.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common'; -import { AuthUserDto, ICryptoRepository } from '../auth'; -import { IUserRepository, UserCore } from '../user'; +import { AuthUserDto } from '../auth'; +import { ICryptoRepository } from '../crypto'; import { EditSharedLinkDto } from './dto'; import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './response-dto'; import { ShareCore } from './share.core'; @@ -10,37 +10,12 @@ import { ISharedLinkRepository } from './shared-link.repository'; export class ShareService { readonly logger = new Logger(ShareService.name); private shareCore: ShareCore; - private userCore: UserCore; constructor( @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository, - @Inject(IUserRepository) userRepository: IUserRepository, ) { this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository); - this.userCore = new UserCore(userRepository, cryptoRepository); - } - - async validate(key: string): Promise { - const link = await this.shareCore.getByKey(key); - if (link) { - if (!link.expiresAt || new Date(link.expiresAt) > new Date()) { - const user = await this.userCore.get(link.userId); - if (user) { - return { - id: user.id, - email: user.email, - isAdmin: user.isAdmin, - isPublicUser: true, - sharedLinkId: link.id, - isAllowUpload: link.allowUpload, - isAllowDownload: link.allowDownload, - isShowExif: link.showExif, - }; - } - } - } - return null; } async getAll(authUser: AuthUserDto): Promise { @@ -74,14 +49,6 @@ export class ShareService { } } - async getByKey(key: string): Promise { - const link = await this.shareCore.getByKey(key); - if (!link) { - throw new BadRequestException('Shared link not found'); - } - return mapSharedLink(link); - } - async remove(authUser: AuthUserDto, id: string): Promise { await this.shareCore.remove(authUser.id, id); } diff --git a/server/libs/domain/src/share/shared-link.repository.ts b/server/libs/domain/src/share/shared-link.repository.ts index 3fd1374664..50741f9d97 100644 --- a/server/libs/domain/src/share/shared-link.repository.ts +++ b/server/libs/domain/src/share/shared-link.repository.ts @@ -6,7 +6,7 @@ export interface ISharedLinkRepository { getAll(userId: string): Promise; get(userId: string, id: string): Promise; getByKey(key: string): Promise; - create(entity: Omit): Promise; + create(entity: Omit): Promise; remove(entity: SharedLinkEntity): Promise; save(entity: Partial): Promise; hasAssetAccess(id: string, assetId: string): Promise; diff --git a/server/libs/domain/src/user-token/user-token.core.ts b/server/libs/domain/src/user-token/user-token.core.ts index 7ae3b27835..4bc2cddb49 100644 --- a/server/libs/domain/src/user-token/user-token.core.ts +++ b/server/libs/domain/src/user-token/user-token.core.ts @@ -1,12 +1,28 @@ import { UserEntity } from '@app/infra/db/entities'; -import { Injectable } from '@nestjs/common'; -import { ICryptoRepository } from '../auth'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +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); + const user = await this.getUserByToken(hashedToken); + if (user) { + return { + ...user, + isPublicUser: false, + isAllowUpload: true, + isAllowDownload: true, + isShowExif: true, + }; + } + + throw new UnauthorizedException('Invalid user token'); + } + public async getUserByToken(tokenValue: string): Promise { const token = await this.repository.get(tokenValue); if (token?.user) { diff --git a/server/libs/domain/src/user/user.core.ts b/server/libs/domain/src/user/user.core.ts index 30edc160bf..5536fea0d6 100644 --- a/server/libs/domain/src/user/user.core.ts +++ b/server/libs/domain/src/user/user.core.ts @@ -10,7 +10,8 @@ import { import { hash } from 'bcrypt'; import { constants, createReadStream, ReadStream } from 'fs'; import fs from 'fs/promises'; -import { AuthUserDto, ICryptoRepository } from '../auth'; +import { AuthUserDto } from '../auth'; +import { ICryptoRepository } from '../crypto'; import { CreateAdminDto, CreateUserDto, CreateUserOAuthDto } from './dto/create-user.dto'; import { IUserRepository, UserListFilter } from './user.repository'; diff --git a/server/libs/domain/src/user/user.service.spec.ts b/server/libs/domain/src/user/user.service.spec.ts index 62df6b8ce5..9235c32580 100644 --- a/server/libs/domain/src/user/user.service.spec.ts +++ b/server/libs/domain/src/user/user.service.spec.ts @@ -3,7 +3,8 @@ import { UserEntity } from '@app/infra/db/entities'; import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { when } from 'jest-when'; import { newCryptoRepositoryMock, newUserRepositoryMock } from '../../test'; -import { AuthUserDto, ICryptoRepository } from '../auth'; +import { AuthUserDto } from '../auth'; +import { ICryptoRepository } from '../crypto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UserService } from './user.service'; diff --git a/server/libs/domain/src/user/user.service.ts b/server/libs/domain/src/user/user.service.ts index 74e669fcef..dc804c6444 100644 --- a/server/libs/domain/src/user/user.service.ts +++ b/server/libs/domain/src/user/user.service.ts @@ -1,7 +1,8 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { randomBytes } from 'crypto'; import { ReadStream } from 'fs'; -import { AuthUserDto, ICryptoRepository } from '../auth'; +import { AuthUserDto } from '../auth'; +import { ICryptoRepository } from '../crypto'; import { IUserRepository } from '../user'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index 981fce67fd..7a2b078b22 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -1,4 +1,5 @@ import { + APIKeyEntity, AssetType, SharedLinkEntity, SharedLinkType, @@ -148,6 +149,16 @@ export const userTokenEntityStub = { }), }; +export const keyStub = { + admin: Object.freeze({ + id: 1, + name: 'My Key', + key: 'my-api-key (hashed)', + userId: authStub.admin.id, + user: userEntityStub.admin, + } as APIKeyEntity), +}; + export const systemConfigStub = { defaults: Object.freeze({ ffmpeg: { @@ -275,6 +286,7 @@ export const sharedLinkStub = { valid: Object.freeze({ id: '123', userId: authStub.admin.id, + user: userEntityStub.admin, key: Buffer.from('secret-key', 'utf8'), type: SharedLinkType.ALBUM, createdAt: today.toISOString(), @@ -288,6 +300,7 @@ export const sharedLinkStub = { expired: Object.freeze({ id: '123', userId: authStub.admin.id, + user: userEntityStub.admin, key: Buffer.from('secret-key', 'utf8'), type: SharedLinkType.ALBUM, createdAt: today.toISOString(), @@ -300,6 +313,7 @@ export const sharedLinkStub = { readonly: Object.freeze({ id: '123', userId: authStub.admin.id, + user: userEntityStub.admin, key: Buffer.from('secret-key', 'utf8'), type: SharedLinkType.ALBUM, createdAt: today.toISOString(), diff --git a/server/libs/domain/test/index.ts b/server/libs/domain/test/index.ts index 85c949d5c4..b6a3ad7f24 100644 --- a/server/libs/domain/test/index.ts +++ b/server/libs/domain/test/index.ts @@ -4,4 +4,5 @@ export * from './fixtures'; export * from './job.repository.mock'; export * from './shared-link.repository.mock'; export * from './system-config.repository.mock'; +export * from './user-token.repository.mock'; export * from './user.repository.mock'; diff --git a/server/libs/infra/src/db/entities/shared-link.entity.ts b/server/libs/infra/src/db/entities/shared-link.entity.ts index 27030758a2..22e06c143f 100644 --- a/server/libs/infra/src/db/entities/shared-link.entity.ts +++ b/server/libs/infra/src/db/entities/shared-link.entity.ts @@ -1,6 +1,7 @@ import { Column, Entity, Index, ManyToMany, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; import { AlbumEntity } from './album.entity'; import { AssetEntity } from './asset.entity'; +import { UserEntity } from './user.entity'; @Entity('shared_links') @Unique('UQ_sharedlink_key', ['key']) @@ -14,6 +15,9 @@ export class SharedLinkEntity { @Column() userId!: string; + @ManyToOne(() => UserEntity) + user!: UserEntity; + @Index('IDX_sharedlink_key') @Column({ type: 'bytea' }) key!: Buffer; // use to access the inidividual asset diff --git a/server/libs/infra/src/db/migrations/1674939383309-AddSharedLinkUserForeignKeyConstraint.ts b/server/libs/infra/src/db/migrations/1674939383309-AddSharedLinkUserForeignKeyConstraint.ts new file mode 100644 index 0000000000..9119c57065 --- /dev/null +++ b/server/libs/infra/src/db/migrations/1674939383309-AddSharedLinkUserForeignKeyConstraint.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSharedLinkUserForeignKeyConstraint1674939383309 implements MigrationInterface { + name = 'AddSharedLinkUserForeignKeyConstraint1674939383309'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shared_links" ALTER COLUMN "userId" TYPE varchar(36)`); + await queryRunner.query(`ALTER TABLE "shared_links" ALTER COLUMN "userId" TYPE uuid using "userId"::uuid`); + await queryRunner.query( + `ALTER TABLE "shared_links" ADD CONSTRAINT "FK_66fe3837414c5a9f1c33ca49340" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shared_links" DROP CONSTRAINT "FK_66fe3837414c5a9f1c33ca49340"`); + await queryRunner.query(`ALTER TABLE "shared_links" ALTER COLUMN "userId" TYPE character varying`); + } +} diff --git a/server/libs/infra/src/db/repository/shared-link.repository.ts b/server/libs/infra/src/db/repository/shared-link.repository.ts index 9f372a3e96..bc54ae7e7c 100644 --- a/server/libs/infra/src/db/repository/shared-link.repository.ts +++ b/server/libs/infra/src/db/repository/shared-link.repository.ts @@ -73,6 +73,7 @@ export class SharedLinkRepository implements ISharedLinkRepository { assetInfo: true, }, }, + user: true, }, order: { createdAt: 'DESC', diff --git a/server/package-lock.json b/server/package-lock.json index f044413501..677e4b4909 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "immich", - "version": "1.42.0", + "version": "1.43.1", "license": "UNLICENSED", "dependencies": { "@nestjs/bull": "^0.6.2", @@ -14,7 +14,6 @@ "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.2.1", "@nestjs/mapped-types": "1.2.0", - "@nestjs/passport": "^9.0.0", "@nestjs/platform-express": "^9.2.1", "@nestjs/platform-socket.io": "^9.2.1", "@nestjs/schedule": "^2.1.0", @@ -46,9 +45,6 @@ "mv": "^2.1.1", "nest-commander": "^3.3.0", "openid-client": "^5.2.1", - "passport": "^0.6.0", - "passport-custom": "^1.1.1", - "passport-http-header-strategy": "^1.1.0", "pg": "^8.8.0", "redis": "^4.5.1", "reflect-metadata": "^0.1.13", @@ -1537,15 +1533,6 @@ } } }, - "node_modules/@nestjs/passport": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-9.0.0.tgz", - "integrity": "sha512-Gnh8n1wzFPOLSS/94X1sUP4IRAoXTgG4odl7/AO5h+uwscEGXxJFercrZfqdAwkWhqkKWbsntM3j5mRy/6ZQDA==", - "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0", - "passport": "^0.4.0 || ^0.5.0 || ^0.6.0" - } - }, "node_modules/@nestjs/platform-express": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-9.2.1.tgz", @@ -8869,50 +8856,6 @@ "node": ">= 0.8" } }, - "node_modules/passport": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz", - "integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==", - "dependencies": { - "passport-strategy": "1.x.x", - "pause": "0.0.1", - "utils-merge": "^1.0.1" - }, - "engines": { - "node": ">= 0.4.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/jaredhanson" - } - }, - "node_modules/passport-custom": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz", - "integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==", - "dependencies": { - "passport-strategy": "1.x.x" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/passport-http-header-strategy": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz", - "integrity": "sha512-Gn60rR55UE1wXbVhnnfG3yyeRSz5pzz3n6rppxa6xiOo4gGPh/onuw29HuGjpk9DSzXRFkJn95+8RT11kXHeWA==", - "dependencies": { - "passport-strategy": "^1.0.0" - } - }, - "node_modules/passport-strategy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", - "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8964,11 +8907,6 @@ "node": ">=8" } }, - "node_modules/pause": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", - "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" - }, "node_modules/pbf": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.2.1.tgz", @@ -12666,12 +12604,6 @@ "integrity": "sha512-NTFwPZkQWsArQH8QSyFWGZvJ08gR+R4TofglqZoihn/vU+ktHEJjMqsIsADwb7XD97DhiD+TVv5ac+jG33BHrg==", "requires": {} }, - "@nestjs/passport": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-9.0.0.tgz", - "integrity": "sha512-Gnh8n1wzFPOLSS/94X1sUP4IRAoXTgG4odl7/AO5h+uwscEGXxJFercrZfqdAwkWhqkKWbsntM3j5mRy/6ZQDA==", - "requires": {} - }, "@nestjs/platform-express": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-9.2.1.tgz", @@ -18330,37 +18262,6 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, - "passport": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz", - "integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==", - "requires": { - "passport-strategy": "1.x.x", - "pause": "0.0.1", - "utils-merge": "^1.0.1" - } - }, - "passport-custom": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz", - "integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==", - "requires": { - "passport-strategy": "1.x.x" - } - }, - "passport-http-header-strategy": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz", - "integrity": "sha512-Gn60rR55UE1wXbVhnnfG3yyeRSz5pzz3n6rppxa6xiOo4gGPh/onuw29HuGjpk9DSzXRFkJn95+8RT11kXHeWA==", - "requires": { - "passport-strategy": "^1.0.0" - } - }, - "passport-strategy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", - "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" - }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -18400,11 +18301,6 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" }, - "pause": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", - "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" - }, "pbf": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.2.1.tgz", diff --git a/server/package.json b/server/package.json index cb47dcd05a..053898c266 100644 --- a/server/package.json +++ b/server/package.json @@ -43,7 +43,6 @@ "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.2.1", "@nestjs/mapped-types": "1.2.0", - "@nestjs/passport": "^9.0.0", "@nestjs/platform-express": "^9.2.1", "@nestjs/platform-socket.io": "^9.2.1", "@nestjs/schedule": "^2.1.0", @@ -75,9 +74,6 @@ "mv": "^2.1.1", "nest-commander": "^3.3.0", "openid-client": "^5.2.1", - "passport": "^0.6.0", - "passport-custom": "^1.1.1", - "passport-http-header-strategy": "^1.1.0", "pg": "^8.8.0", "redis": "^4.5.1", "reflect-metadata": "^0.1.13", @@ -147,10 +143,10 @@ "statements": 20 }, "./libs/domain/": { - "branches": 75, - "functions": 85, - "lines": 90, - "statements": 90 + "branches": 80, + "functions": 90, + "lines": 95, + "statements": 95 } }, "testEnvironment": "node",