Просмотр исходного кода

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
Jason Rasmussen 2 лет назад
Родитель
Сommit
d2a9363fc5
40 измененных файлов с 327 добавлено и 501 удалено
  1. 1 1
      server/apps/immich/src/api-v1/communication/communication.gateway.ts
  2. 3 4
      server/apps/immich/src/app.module.ts
  3. 12 9
      server/apps/immich/src/decorators/authenticated.decorator.ts
  4. 0 23
      server/apps/immich/src/middlewares/admin-role-guard.middleware.ts
  5. 46 0
      server/apps/immich/src/middlewares/auth.guard.ts
  6. 0 21
      server/apps/immich/src/middlewares/route-not-shared-guard.middleware.ts
  7. 0 8
      server/apps/immich/src/modules/immich-auth/guards/auth.guard.ts
  8. 0 21
      server/apps/immich/src/modules/immich-auth/strategies/api-key.strategy.ts
  9. 0 22
      server/apps/immich/src/modules/immich-auth/strategies/public-share.strategy.ts
  10. 0 18
      server/apps/immich/src/modules/immich-auth/strategies/user-auth.strategy.ts
  11. 3 9
      server/apps/immich/test/album.e2e-spec.ts
  12. 2 2
      server/apps/immich/test/test-utils.ts
  13. 3 11
      server/apps/immich/test/user.e2e-spec.ts
  14. 27 0
      server/libs/domain/src/api-key/api-key.core.ts
  15. 8 39
      server/libs/domain/src/api-key/api-key.service.spec.ts
  16. 2 19
      server/libs/domain/src/api-key/api-key.service.ts
  17. 2 21
      server/libs/domain/src/auth/auth.core.ts
  18. 66 43
      server/libs/domain/src/auth/auth.service.spec.ts
  19. 42 20
      server/libs/domain/src/auth/auth.service.ts
  20. 0 1
      server/libs/domain/src/auth/index.ts
  21. 0 0
      server/libs/domain/src/crypto/crypto.repository.ts
  22. 1 0
      server/libs/domain/src/crypto/index.ts
  23. 1 0
      server/libs/domain/src/index.ts
  24. 2 2
      server/libs/domain/src/oauth/oauth.service.spec.ts
  25. 3 2
      server/libs/domain/src/oauth/oauth.service.ts
  26. 31 6
      server/libs/domain/src/share/share.core.ts
  27. 2 45
      server/libs/domain/src/share/share.service.spec.ts
  28. 2 35
      server/libs/domain/src/share/share.service.ts
  29. 1 1
      server/libs/domain/src/share/shared-link.repository.ts
  30. 18 2
      server/libs/domain/src/user-token/user-token.core.ts
  31. 2 1
      server/libs/domain/src/user/user.core.ts
  32. 2 1
      server/libs/domain/src/user/user.service.spec.ts
  33. 2 1
      server/libs/domain/src/user/user.service.ts
  34. 14 0
      server/libs/domain/test/fixtures.ts
  35. 1 0
      server/libs/domain/test/index.ts
  36. 4 0
      server/libs/infra/src/db/entities/shared-link.entity.ts
  37. 18 0
      server/libs/infra/src/db/migrations/1674939383309-AddSharedLinkUserForeignKeyConstraint.ts
  38. 1 0
      server/libs/infra/src/db/repository/shared-link.repository.ts
  39. 1 105
      server/package-lock.json
  40. 4 8
      server/package.json

+ 1 - 1
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 {

+ 3 - 4
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

+ 12 - 9
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<typeof UseGuards> = [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);
 };

+ 0 - 23
server/apps/immich/src/middlewares/admin-role-guard.middleware.ts

@@ -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<boolean> {
-    const request = context.switchToHttp().getRequest<UserRequest>();
-    const isAdmin = request.user?.isAdmin || false;
-    if (!isAdmin) {
-      this.logger.log(`Denied access to admin only route: ${request.path}`);
-      return false;
-    }
-
-    return true;
-  }
-}

+ 46 - 0
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<boolean> {
+    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<Request>();
+
+    const authDto = await this.authService.validate(req.headers, req.query as Record<string, string>);
+    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;
+  }
+}

+ 0 - 21
server/apps/immich/src/middlewares/route-not-shared-guard.middleware.ts

@@ -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<boolean> {
-    const request = context.switchToHttp().getRequest<Request>();
-    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;
-  }
-}

+ 0 - 8
server/apps/immich/src/modules/immich-auth/guards/auth.guard.ts

@@ -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]) {}

+ 0 - 21
server/apps/immich/src/modules/immich-auth/strategies/api-key.strategy.ts

@@ -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<AuthUserDto | null> {
-    return this.apiKeyService.validate(token);
-  }
-}

+ 0 - 22
server/apps/immich/src/modules/immich-auth/strategies/public-share.strategy.ts

@@ -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<AuthUserDto | null> {
-    return this.shareService.validate(key);
-  }
-}

+ 0 - 18
server/apps/immich/src/modules/immich-auth/strategies/user-auth.strategy.ts

@@ -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<AuthUserDto | null> {
-    return this.authService.validate(request.headers);
-  }
-}

+ 3 - 9
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();
 

+ 2 - 2
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);
 }

+ 3 - 11
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();

+ 27 - 0
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<AuthUserDto | null> {
+    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');
+  }
+}

+ 8 - 39
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<IKeyRepository>;
@@ -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)');
-    });
-  });
 });

+ 2 - 19
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<AuthUserDto | null> {
-    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;
-  }
 }

+ 2 - 21
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<string, string>) {
-    return cookies?.[IMMICH_ACCESS_COOKIE] || null;
-  }
 }

+ 66 - 43
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<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 () => {
-      userMock.get.mockResolvedValue(null);
-      await expect(sut.validate({ email: 'a', userId: 'test' })).resolves.toBeNull();
+  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 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);
+    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);
     });
-  });
 
-  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');
+    it('should not accept a key without a user', async () => {
+      shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
+      userMock.get.mockResolvedValue(null);
+      const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' };
+      await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
     });
 
-    it('should work with no cookies', () => {
-      const cookie: IncomingHttpHeaders = {
-        cookie: undefined,
-      };
-      expect(sut.extractTokenFromHeader(cookie)).toBeNull();
+    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);
     });
+  });
 
-    it('should work on empty cookies', () => {
-      const cookie: IncomingHttpHeaders = {
-        cookie: '',
-      };
-      expect(sut.extractTokenFromHeader(cookie)).toBeNull();
+  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);
     });
-  });
 
-  describe('extractTokenFromHeader - Bearer Auth', () => {
-    it('should extract the access token', () => {
-      expect(sut.extractTokenFromHeader({ authorization: `Bearer signed-jwt` })).toEqual('signed-jwt');
+    it('should return an auth dto', async () => {
+      userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken);
+      const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
+      await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
     });
+  });
 
-    it('should work without the auth header', () => {
-      expect(sut.extractTokenFromHeader({})).toBeNull();
+  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 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)');
     });
   });
 });

+ 42 - 20
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<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;
   }
 }

+ 0 - 1
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';

+ 0 - 0
server/libs/domain/src/auth/crypto.repository.ts → server/libs/domain/src/crypto/crypto.repository.ts


+ 1 - 0
server/libs/domain/src/crypto/index.ts

@@ -0,0 +1 @@
+export * from './crypto.repository';

+ 1 - 0
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';

+ 2 - 2
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';

+ 3 - 2
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 {

+ 31 - 6
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<SharedLinkEntity | null> {
-    return this.repository.getByKey(key);
-  }
-
   create(userId: string, dto: CreateSharedLinkDto): Promise<SharedLinkEntity> {
     try {
       return this.repository.create({
@@ -78,4 +81,26 @@ export class ShareCore {
       throw new ForbiddenException();
     }
   }
+
+  async validate(key: string): Promise<AuthUserDto | null> {
+    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');
+  }
 }

+ 2 - 45
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<ICryptoRepository>;
   let shareMock: jest.Mocked<ISharedLinkRepository>;
-  let userMock: jest.Mocked<IUserRepository>;
 
   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);

+ 2 - 35
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<AuthUserDto | null> {
-    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<SharedLinkResponseDto[]> {
@@ -74,14 +49,6 @@ export class ShareService {
     }
   }
 
-  async getByKey(key: string): Promise<SharedLinkResponseDto> {
-    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<void> {
     await this.shareCore.remove(authUser.id, id);
   }

+ 1 - 1
server/libs/domain/src/share/shared-link.repository.ts

@@ -6,7 +6,7 @@ export interface ISharedLinkRepository {
   getAll(userId: string): Promise<SharedLinkEntity[]>;
   get(userId: string, id: string): Promise<SharedLinkEntity | null>;
   getByKey(key: string): Promise<SharedLinkEntity | null>;
-  create(entity: Omit<SharedLinkEntity, 'id'>): Promise<SharedLinkEntity>;
+  create(entity: Omit<SharedLinkEntity, 'id' | 'user'>): Promise<SharedLinkEntity>;
   remove(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
   save(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
   hasAssetAccess(id: string, assetId: string): Promise<boolean>;

+ 18 - 2
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<UserEntity | null> {
     const token = await this.repository.get(tokenValue);
     if (token?.user) {

+ 2 - 1
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';
 

+ 2 - 1
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';
 

+ 2 - 1
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';

+ 14 - 0
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<SharedLinkEntity>({
     id: '123',
     userId: authStub.admin.id,
+    user: userEntityStub.admin,
     key: Buffer.from('secret-key', 'utf8'),
     type: SharedLinkType.ALBUM,
     createdAt: today.toISOString(),

+ 1 - 0
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';

+ 4 - 0
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

+ 18 - 0
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<void> {
+    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<void> {
+    await queryRunner.query(`ALTER TABLE "shared_links" DROP CONSTRAINT "FK_66fe3837414c5a9f1c33ca49340"`);
+    await queryRunner.query(`ALTER TABLE "shared_links" ALTER COLUMN "userId" TYPE character varying`);
+  }
+}

+ 1 - 0
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',

+ 1 - 105
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",

+ 4 - 8
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",