mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(server): move authentication to tokens stored in the database (#1381)
* chore: add typeorm commands to npm and set default database config values * feat: move to server side authentication tokens * fix: websocket should emit error and disconnect on error thrown by the server * refactor: rename cookie-auth-strategy to user-auth-strategy * feat: user tokens and API keys now use SHA256 hash for performance improvements * test: album e2e test remove unneeded module import * infra: truncate api key table as old keys will no longer work with new hash algorithm * fix(server): e2e tests (#1435) * fix: root module paths * chore: linting * chore: rename user-auth to strategy.ts and make validate return AuthUserDto * fix: we should always send HttpOnly for our auth cookies * chore: remove now unused crypto functions and jwt dependencies * fix: return the extra fields for AuthUserDto in auth service validate --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
		| @@ -10,9 +10,6 @@ REDIS_HOSTNAME=immich-redis-test | ||||
| # Upload File Config | ||||
| UPLOAD_LOCATION=./upload | ||||
|  | ||||
| # JWT SECRET | ||||
| JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess | ||||
|  | ||||
| # MAPBOX | ||||
| ## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY | ||||
| ENABLE_MAPBOX=false | ||||
|   | ||||
| @@ -30,16 +30,6 @@ REDIS_HOSTNAME=immich_redis | ||||
|  | ||||
| UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup | ||||
|  | ||||
| ################################################################################### | ||||
| # JWT SECRET | ||||
| # | ||||
| # This JWT_SECRET is used to sign the authentication keys for user login | ||||
| # You should set it to a long randomly generated value | ||||
| # You can use this command to generate one: openssl rand -base64 128 | ||||
| ################################################################################### | ||||
|  | ||||
| JWT_SECRET= | ||||
|  | ||||
| ################################################################################### | ||||
| # Reverse Geocoding | ||||
| # | ||||
|   | ||||
| @@ -24,7 +24,7 @@ All the services are packaged to run as with single Docker Compose command. | ||||
|  | ||||
| 1. Clone the project repo. | ||||
| 2. Run `cp docker/example.env docker/.env`. | ||||
| 3. Edit `docker/.env` to provide values for the required variables `UPLOAD_LOCATION` and `JWT_SECRET`. | ||||
| 3. Edit `docker/.env` to provide values for the required variable `UPLOAD_LOCATION`. | ||||
| 4. From the root directory, run: | ||||
|  | ||||
| ```bash title="Start development server" | ||||
|   | ||||
| @@ -63,15 +63,6 @@ UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_ba | ||||
|  | ||||
| LOG_LEVEL=simple | ||||
|  | ||||
| ################################################################################### | ||||
| # JWT SECRET | ||||
| ################################################################################### | ||||
|  | ||||
| # This JWT_SECRET is used to sign the authentication keys for user login | ||||
| # You should set it to a long randomly generated value | ||||
| # You can use this command to generate one: openssl rand -base64 128 | ||||
| JWT_SECRET= | ||||
|  | ||||
| ################################################################################### | ||||
| # Reverse Geocoding | ||||
| #################################################################################### | ||||
| @@ -102,11 +93,6 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server" | ||||
|  | ||||
| - Populate custom database information if necessary. | ||||
| - Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. | ||||
| - Populate a secret value for `JWT_SECRET`. You can use the command below to generate a secure key: | ||||
|  | ||||
| ```bash title="Command to generate secure JWT_SECRET key" | ||||
| openssl rand -base64 128 | ||||
| ``` | ||||
|  | ||||
| ### Step 3 - Start the containers | ||||
|  | ||||
|   | ||||
| @@ -40,11 +40,6 @@ Install Immich using Portainer's Stack feature. | ||||
|  | ||||
| * Populate custom database information if necessary. | ||||
| * Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. | ||||
| * Populate a secret value for `JWT_SECRET`. You can use the command below to generate a secure key: | ||||
|  | ||||
| ```bash title="Generate secure JWT_SECRET key" | ||||
| openssl rand -base64 128 | ||||
| ``` | ||||
|  | ||||
| 11. Click on "**Deploy the stack**". | ||||
|  | ||||
|   | ||||
| @@ -55,7 +55,6 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich" | ||||
| 6.  Select the cog ⚙️ next to Immich, click "**Edit Stack**", then click "**Env File**" | ||||
| 7.  Past the entire contents of the [Immich example.env](https://raw.githubusercontent.com/immich-app/immich/main/docker/example.env) file into the Unraid editor, then **before saving** edit the following: | ||||
|  | ||||
|     - `JWT_SECRET`: Generate a unique secret and paste the value here > Can be generated by either typing `openssl rand -base64 128` in your terminal or copying from [uuidgenerator](https://www.uuidgenerator.net/version1) | ||||
|     - `UPLOAD_LOCATION`: Create a folder in your Images Unraid share and place the **absolute** location here > For example my _"images"_ share has a folder within it called _"immich"_. If I browse to this directory in the terminal and type `pwd` the output is `/mnt/user/images/immich`. This is the exact value I need to enter as my `UPLOAD_LOCATION` | ||||
|  | ||||
|       <img | ||||
|   | ||||
| @@ -45,12 +45,6 @@ populate_upload_location() { | ||||
|   replace_env_value "UPLOAD_LOCATION" $upload_location | ||||
| } | ||||
|  | ||||
| generate_jwt_secret() { | ||||
|   echo "Generating JWT_SECRET value..." | ||||
|   jwt_secret=$(openssl rand -base64 128) | ||||
|   replace_env_value "JWT_SECRET" $jwt_secret | ||||
| } | ||||
|  | ||||
| start_docker_compose() { | ||||
|   echo "Starting Immich's docker containers" | ||||
|  | ||||
| @@ -92,5 +86,4 @@ create_immich_directory | ||||
| download_docker_compose_file | ||||
| download_dot_env_file | ||||
| populate_upload_location | ||||
| generate_jwt_secret | ||||
| start_docker_compose | ||||
|   | ||||
| @@ -19,8 +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.validateSocket(client); | ||||
|       const user = await this.authService.validate(client.request.headers); | ||||
|       if (user) { | ||||
|         client.join(user.id); | ||||
|       } else { | ||||
| @@ -28,7 +27,8 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco | ||||
|         client.disconnect(); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       // Logger.error(`Error establish websocket conneciton ${e}`, 'HandleWebscoketConnection'); | ||||
|       client.emit('error', 'unauthorized'); | ||||
|       client.disconnect(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import { immichAppConfig } from '@app/common/config'; | ||||
| import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; | ||||
| import { AssetModule } from './api-v1/asset/asset.module'; | ||||
| import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module'; | ||||
| import { DeviceInfoModule } from './api-v1/device-info/device-info.module'; | ||||
| import { ConfigModule } from '@nestjs/config'; | ||||
| import { ServerInfoModule } from './api-v1/server-info/server-info.module'; | ||||
| @@ -23,6 +22,9 @@ 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'; | ||||
|  | ||||
| @Module({ | ||||
|   imports: [ | ||||
| @@ -34,8 +36,6 @@ import { | ||||
|  | ||||
|     AssetModule, | ||||
|  | ||||
|     ImmichJwtModule, | ||||
|  | ||||
|     DeviceInfoModule, | ||||
|  | ||||
|     ServerInfoModule, | ||||
| @@ -64,7 +64,7 @@ import { | ||||
|     SystemConfigController, | ||||
|     UserController, | ||||
|   ], | ||||
|   providers: [], | ||||
|   providers: [UserAuthStrategy, APIKeyStrategy, PublicShareStrategy], | ||||
| }) | ||||
| export class AppModule implements NestModule { | ||||
|   // TODO: check if consumer is needed or remove | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| 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-jwt/guards/auth.guard'; | ||||
| import { AuthGuard } from '../modules/immich-auth/guards/auth.guard'; | ||||
|  | ||||
| interface AuthenticatedOptions { | ||||
|   admin?: boolean; | ||||
|   | ||||
							
								
								
									
										3
									
								
								server/apps/immich/src/global.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								server/apps/immich/src/global.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -4,5 +4,8 @@ declare global { | ||||
|   namespace Express { | ||||
|     // eslint-disable-next-line @typescript-eslint/no-empty-interface | ||||
|     interface User extends AuthUserDto {} | ||||
|     export interface Request { | ||||
|       user: AuthUserDto; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -40,9 +40,6 @@ async function bootstrap() { | ||||
|     .addBearerAuth({ | ||||
|       type: 'http', | ||||
|       scheme: 'Bearer', | ||||
|       bearerFormat: 'JWT', | ||||
|       name: 'JWT', | ||||
|       description: 'Enter JWT token', | ||||
|       in: 'header', | ||||
|     }) | ||||
|     .addServer('/api') | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { AuthGuard as PassportAuthGuard } from '@nestjs/passport'; | ||||
| import { API_KEY_STRATEGY } from '../strategies/api-key.strategy'; | ||||
| import { JWT_STRATEGY } from '../strategies/jwt.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, JWT_STRATEGY, API_KEY_STRATEGY]) {} | ||||
| export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, AUTH_COOKIE_STRATEGY, API_KEY_STRATEGY]) {} | ||||
| @@ -0,0 +1,24 @@ | ||||
| import { Injectable, UnauthorizedException } from '@nestjs/common'; | ||||
| import { PassportStrategy } from '@nestjs/passport'; | ||||
| import { AuthService, AuthUserDto, UserService } from '@app/domain'; | ||||
| import { Strategy } from 'passport-custom'; | ||||
| import { Request } from 'express'; | ||||
|  | ||||
| export const AUTH_COOKIE_STRATEGY = 'auth-cookie'; | ||||
|  | ||||
| @Injectable() | ||||
| export class UserAuthStrategy extends PassportStrategy(Strategy, AUTH_COOKIE_STRATEGY) { | ||||
|   constructor(private userService: UserService, private authService: AuthService) { | ||||
|     super(); | ||||
|   } | ||||
|  | ||||
|   async validate(request: Request): Promise<AuthUserDto> { | ||||
|     const authUser = await this.authService.validate(request.headers); | ||||
|  | ||||
|     if (!authUser) { | ||||
|       throw new UnauthorizedException('Incorrect token provided'); | ||||
|     } | ||||
|  | ||||
|     return authUser; | ||||
|   } | ||||
| } | ||||
| @@ -1,9 +0,0 @@ | ||||
| import { Module } from '@nestjs/common'; | ||||
| import { APIKeyStrategy } from './strategies/api-key.strategy'; | ||||
| import { JwtStrategy } from './strategies/jwt.strategy'; | ||||
| import { PublicShareStrategy } from './strategies/public-share.strategy'; | ||||
|  | ||||
| @Module({ | ||||
|   providers: [JwtStrategy, APIKeyStrategy, PublicShareStrategy], | ||||
| }) | ||||
| export class ImmichJwtModule {} | ||||
| @@ -1,24 +0,0 @@ | ||||
| import { AuthService, AuthUserDto, JwtPayloadDto, jwtSecret } from '@app/domain'; | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { PassportStrategy } from '@nestjs/passport'; | ||||
| import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt'; | ||||
|  | ||||
| export const JWT_STRATEGY = 'jwt'; | ||||
|  | ||||
| @Injectable() | ||||
| export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { | ||||
|   constructor(private authService: AuthService) { | ||||
|     super({ | ||||
|       jwtFromRequest: ExtractJwt.fromExtractors([ | ||||
|         (req) => authService.extractJwtFromCookie(req.cookies), | ||||
|         (req) => authService.extractJwtFromHeader(req.headers), | ||||
|       ]), | ||||
|       ignoreExpiration: false, | ||||
|       secretOrKey: jwtSecret, | ||||
|     } as StrategyOptions); | ||||
|   } | ||||
|  | ||||
|   async validate(payload: JwtPayloadDto): Promise<AuthUserDto> { | ||||
|     return this.authService.validatePayload(payload); | ||||
|   } | ||||
| } | ||||
| @@ -5,10 +5,10 @@ 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 { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module'; | ||||
| import { AuthUserDto } from '../src/decorators/auth-user.decorator'; | ||||
| import { AuthService, DomainModule, UserService } from '@app/domain'; | ||||
| import { DataSource } from 'typeorm'; | ||||
| import { AppModule } from '../src/app.module'; | ||||
|  | ||||
| function _createAlbum(app: INestApplication, data: CreateAlbumDto) { | ||||
|   return request(app.getHttpServer()).post('/album').send(data); | ||||
| @@ -21,7 +21,7 @@ describe('Album', () => { | ||||
|   describe('without auth', () => { | ||||
|     beforeAll(async () => { | ||||
|       const moduleFixture: TestingModule = await Test.createTestingModule({ | ||||
|         imports: [DomainModule.register({ imports: [InfraModule] }), AlbumModule, ImmichJwtModule], | ||||
|         imports: [DomainModule.register({ imports: [InfraModule] }), AppModule], | ||||
|       }).compile(); | ||||
|  | ||||
|       app = moduleFixture.createNestApplication(); | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| { | ||||
|   "moduleFileExtensions": ["js", "json", "ts"], | ||||
|   "modulePaths": ["<rootDir>", "<rootDir>../../../"], | ||||
|   "rootDir": ".", | ||||
|   "testEnvironment": "node", | ||||
|   "testRegex": ".e2e-spec.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-jwt/guards/auth.guard'; | ||||
| import { AuthGuard } from '../src/modules/immich-auth/guards/auth.guard'; | ||||
|  | ||||
| type CustomAuthCallback = () => AuthUserDto; | ||||
|  | ||||
|   | ||||
| @@ -3,11 +3,11 @@ import { INestApplication } from '@nestjs/common'; | ||||
| import request from 'supertest'; | ||||
| import { clearDb, authCustom } from './test-utils'; | ||||
| import { InfraModule } from '@app/infra'; | ||||
| import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module'; | ||||
| import { DomainModule, 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'; | ||||
|  | ||||
| function _createUser(userService: UserService, data: CreateUserDto) { | ||||
|   return userService.createUser(data); | ||||
| @@ -25,7 +25,7 @@ describe('User', () => { | ||||
|   describe('without auth', () => { | ||||
|     beforeAll(async () => { | ||||
|       const moduleFixture: TestingModule = await Test.createTestingModule({ | ||||
|         imports: [DomainModule.register({ imports: [InfraModule] }), ImmichJwtModule], | ||||
|         imports: [DomainModule.register({ imports: [InfraModule] }), AppModule], | ||||
|         controllers: [UserController], | ||||
|       }).compile(); | ||||
|  | ||||
|   | ||||
| @@ -2722,8 +2722,6 @@ | ||||
|         "scheme": "Bearer", | ||||
|         "bearerFormat": "JWT", | ||||
|         "type": "http", | ||||
|         "name": "JWT", | ||||
|         "description": "Enter JWT token", | ||||
|         "in": "header" | ||||
|       } | ||||
|     }, | ||||
|   | ||||
| @@ -1,20 +1,5 @@ | ||||
| import { Logger } from '@nestjs/common'; | ||||
| import { ConfigModuleOptions } from '@nestjs/config'; | ||||
| import Joi from 'joi'; | ||||
| import { createSecretKey, generateKeySync } from 'node:crypto'; | ||||
|  | ||||
| const jwtSecretValidator: Joi.CustomValidator<string> = (value) => { | ||||
|   const key = createSecretKey(value, 'base64'); | ||||
|   const keySizeBits = (key.symmetricKeySize ?? 0) * 8; | ||||
|  | ||||
|   if (keySizeBits < 128) { | ||||
|     const newKey = generateKeySync('hmac', { length: 256 }).export().toString('base64'); | ||||
|     Logger.warn('The current JWT_SECRET key is insecure. It should be at least 128 bits long!'); | ||||
|     Logger.warn(`Here is a new, securely generated key that you can use instead: ${newKey}`); | ||||
|   } | ||||
|  | ||||
|   return value; | ||||
| }; | ||||
|  | ||||
| const WHEN_DB_URL_SET = Joi.when('DB_URL', { | ||||
|   is: Joi.exist(), | ||||
| @@ -31,7 +16,6 @@ export const immichAppConfig: ConfigModuleOptions = { | ||||
|     DB_PASSWORD: WHEN_DB_URL_SET, | ||||
|     DB_DATABASE_NAME: WHEN_DB_URL_SET, | ||||
|     DB_URL: Joi.string().optional(), | ||||
|     JWT_SECRET: Joi.string().required().custom(jwtSecretValidator), | ||||
|     DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false), | ||||
|     REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3), | ||||
|     LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose', 'debug', 'log', 'warn', 'error').default('log'), | ||||
|   | ||||
| @@ -10,7 +10,7 @@ export interface IKeyRepository { | ||||
|    * Includes the hashed `key` for verification | ||||
|    * @param id | ||||
|    */ | ||||
|   getKey(id: number): Promise<APIKeyEntity | null>; | ||||
|   getKey(hashedToken: string): Promise<APIKeyEntity | null>; | ||||
|   getById(userId: string, id: number): Promise<APIKeyEntity | null>; | ||||
|   getByUserId(userId: string): Promise<APIKeyEntity[]>; | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { APIKeyEntity } from '@app/infra/db/entities'; | ||||
| import { BadRequestException, UnauthorizedException } from '@nestjs/common'; | ||||
| import { authStub, entityStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test'; | ||||
| import { authStub, userEntityStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test'; | ||||
| import { ICryptoRepository } from '../auth'; | ||||
| import { IKeyRepository } from './api-key.repository'; | ||||
| import { APIKeyService } from './api-key.service'; | ||||
| @@ -10,10 +10,10 @@ const adminKey = Object.freeze({ | ||||
|   name: 'My Key', | ||||
|   key: 'my-api-key (hashed)', | ||||
|   userId: authStub.admin.id, | ||||
|   user: entityStub.admin, | ||||
|   user: userEntityStub.admin, | ||||
| } as APIKeyEntity); | ||||
|  | ||||
| const token = Buffer.from('1:my-api-key', 'utf8').toString('base64'); | ||||
| const token = Buffer.from('my-api-key', 'utf8').toString('base64'); | ||||
|  | ||||
| describe(APIKeyService.name, () => { | ||||
|   let sut: APIKeyService; | ||||
| @@ -38,7 +38,7 @@ describe(APIKeyService.name, () => { | ||||
|         userId: authStub.admin.id, | ||||
|       }); | ||||
|       expect(cryptoMock.randomBytes).toHaveBeenCalled(); | ||||
|       expect(cryptoMock.hash).toHaveBeenCalled(); | ||||
|       expect(cryptoMock.hashSha256).toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should not require a name', async () => { | ||||
| @@ -52,7 +52,7 @@ describe(APIKeyService.name, () => { | ||||
|         userId: authStub.admin.id, | ||||
|       }); | ||||
|       expect(cryptoMock.randomBytes).toHaveBeenCalled(); | ||||
|       expect(cryptoMock.hash).toHaveBeenCalled(); | ||||
|       expect(cryptoMock.hashSha256).toHaveBeenCalled(); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
| @@ -126,8 +126,7 @@ describe(APIKeyService.name, () => { | ||||
|  | ||||
|       await expect(sut.validate(token)).rejects.toBeInstanceOf(UnauthorizedException); | ||||
|  | ||||
|       expect(keyMock.getKey).toHaveBeenCalledWith(1); | ||||
|       expect(cryptoMock.compareSync).not.toHaveBeenCalled(); | ||||
|       expect(keyMock.getKey).toHaveBeenCalledWith('bXktYXBpLWtleQ== (hashed)'); | ||||
|     }); | ||||
|  | ||||
|     it('should validate the token', async () => { | ||||
| @@ -135,8 +134,7 @@ describe(APIKeyService.name, () => { | ||||
|  | ||||
|       await expect(sut.validate(token)).resolves.toEqual(authStub.admin); | ||||
|  | ||||
|       expect(keyMock.getKey).toHaveBeenCalledWith(1); | ||||
|       expect(cryptoMock.compareSync).toHaveBeenCalledWith('my-api-key', 'my-api-key (hashed)'); | ||||
|       expect(keyMock.getKey).toHaveBeenCalledWith('bXktYXBpLWtleQ== (hashed)'); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import { UserEntity } from '@app/infra/db/entities'; | ||||
| import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; | ||||
| import { AuthUserDto, ICryptoRepository } from '../auth'; | ||||
| import { IKeyRepository } from './api-key.repository'; | ||||
| @@ -14,15 +13,13 @@ export class APIKeyService { | ||||
|   ) {} | ||||
|  | ||||
|   async create(authUser: AuthUserDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> { | ||||
|     const key = this.crypto.randomBytes(24).toString('base64').replace(/\W/g, ''); | ||||
|     const secret = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, ''); | ||||
|     const entity = await this.repository.create({ | ||||
|       key: await this.crypto.hash(key, 10), | ||||
|       key: this.crypto.hashSha256(secret), | ||||
|       name: dto.name || 'API Key', | ||||
|       userId: authUser.id, | ||||
|     }); | ||||
|  | ||||
|     const secret = Buffer.from(`${entity.id}:${key}`, 'utf8').toString('base64'); | ||||
|  | ||||
|     return { secret, apiKey: mapKey(entity) }; | ||||
|   } | ||||
|  | ||||
| @@ -60,13 +57,10 @@ export class APIKeyService { | ||||
|   } | ||||
|  | ||||
|   async validate(token: string): Promise<AuthUserDto> { | ||||
|     const [_id, key] = Buffer.from(token, 'base64').toString('utf8').split(':'); | ||||
|     const id = Number(_id); | ||||
|  | ||||
|     if (id && key) { | ||||
|       const entity = await this.repository.getKey(id); | ||||
|       if (entity?.user && entity?.key && this.crypto.compareSync(key, entity.key)) { | ||||
|         const user = entity.user as UserEntity; | ||||
|     const hashedToken = this.crypto.hashSha256(token); | ||||
|     const keyEntity = await this.repository.getKey(hashedToken); | ||||
|     if (keyEntity?.user) { | ||||
|       const user = keyEntity.user; | ||||
|  | ||||
|       return { | ||||
|         id: user.id, | ||||
| @@ -76,7 +70,6 @@ export class APIKeyService { | ||||
|         isAllowUpload: true, | ||||
|       }; | ||||
|     } | ||||
|     } | ||||
|  | ||||
|     throw new UnauthorizedException('Invalid API Key'); | ||||
|   } | ||||
|   | ||||
| @@ -1,7 +0,0 @@ | ||||
| import { JwtModuleOptions } from '@nestjs/jwt'; | ||||
| import { jwtSecret } from './auth.constant'; | ||||
|  | ||||
| export const jwtConfig: JwtModuleOptions = { | ||||
|   secret: jwtSecret, | ||||
|   signOptions: { expiresIn: '30d' }, | ||||
| }; | ||||
| @@ -1,4 +1,3 @@ | ||||
| export const jwtSecret = process.env.JWT_SECRET; | ||||
| export const IMMICH_ACCESS_COOKIE = 'immich_access_token'; | ||||
| export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type'; | ||||
| export enum AuthType { | ||||
|   | ||||
| @@ -4,8 +4,9 @@ import { ISystemConfigRepository } from '../system-config'; | ||||
| import { SystemConfigCore } from '../system-config/system-config.core'; | ||||
| import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant'; | ||||
| import { ICryptoRepository } from './crypto.repository'; | ||||
| import { JwtPayloadDto } from './dto/jwt-payload.dto'; | ||||
| import { LoginResponseDto, mapLoginResponse } from './response-dto'; | ||||
| import { IUserTokenRepository, UserTokenCore } from '@app/domain'; | ||||
| import cookieParser from 'cookie'; | ||||
|  | ||||
| export type JwtValidationResult = { | ||||
|   status: boolean; | ||||
| @@ -13,11 +14,14 @@ export type JwtValidationResult = { | ||||
| }; | ||||
|  | ||||
| export class AuthCore { | ||||
|   private userTokenCore: UserTokenCore; | ||||
|   constructor( | ||||
|     private cryptoRepository: ICryptoRepository, | ||||
|     configRepository: ISystemConfigRepository, | ||||
|     userTokenRepository: IUserTokenRepository, | ||||
|     private config: SystemConfig, | ||||
|   ) { | ||||
|     this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository); | ||||
|     const configCore = new SystemConfigCore(configRepository); | ||||
|     configCore.config$.subscribe((config) => (this.config = config)); | ||||
|   } | ||||
| @@ -33,8 +37,8 @@ export class AuthCore { | ||||
|     let accessTokenCookie = ''; | ||||
|  | ||||
|     if (isSecure) { | ||||
|       accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`; | ||||
|       authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`; | ||||
|       accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`; | ||||
|       authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`; | ||||
|     } else { | ||||
|       accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`; | ||||
|       authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`; | ||||
| @@ -42,9 +46,8 @@ export class AuthCore { | ||||
|     return [accessTokenCookie, authTypeCookie]; | ||||
|   } | ||||
|  | ||||
|   public createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) { | ||||
|     const payload: JwtPayloadDto = { userId: user.id, email: user.email }; | ||||
|     const accessToken = this.generateToken(payload); | ||||
|   public async createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) { | ||||
|     const accessToken = await this.userTokenCore.createToken(user); | ||||
|     const response = mapLoginResponse(user, accessToken); | ||||
|     const cookie = this.getCookies(response, authType, isSecure); | ||||
|     return { response, cookie }; | ||||
| @@ -54,12 +57,12 @@ export class AuthCore { | ||||
|     if (!user || !user.password) { | ||||
|       return false; | ||||
|     } | ||||
|     return this.cryptoRepository.compareSync(inputPassword, user.password); | ||||
|     return this.cryptoRepository.compareBcrypt(inputPassword, user.password); | ||||
|   } | ||||
|  | ||||
|   extractJwtFromHeader(headers: IncomingHttpHeaders) { | ||||
|   extractTokenFromHeader(headers: IncomingHttpHeaders) { | ||||
|     if (!headers.authorization) { | ||||
|       return null; | ||||
|       return this.extractTokenFromCookie(cookieParser.parse(headers.cookie || '')); | ||||
|     } | ||||
|  | ||||
|     const [type, accessToken] = headers.authorization.split(' '); | ||||
| @@ -70,11 +73,7 @@ export class AuthCore { | ||||
|     return accessToken; | ||||
|   } | ||||
|  | ||||
|   extractJwtFromCookie(cookies: Record<string, string>) { | ||||
|   extractTokenFromCookie(cookies: Record<string, string>) { | ||||
|     return cookies?.[IMMICH_ACCESS_COOKIE] || null; | ||||
|   } | ||||
|  | ||||
|   private generateToken(payload: JwtPayloadDto) { | ||||
|     return this.cryptoRepository.signJwt({ ...payload }); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -3,13 +3,13 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; | ||||
| import { generators, Issuer } from 'openid-client'; | ||||
| import { Socket } from 'socket.io'; | ||||
| import { | ||||
|   authStub, | ||||
|   entityStub, | ||||
|   userEntityStub, | ||||
|   loginResponseStub, | ||||
|   newCryptoRepositoryMock, | ||||
|   newSystemConfigRepositoryMock, | ||||
|   newUserRepositoryMock, | ||||
|   systemConfigStub, | ||||
|   userTokenEntityStub, | ||||
| } from '../../test'; | ||||
| import { ISystemConfigRepository } from '../system-config'; | ||||
| import { IUserRepository } from '../user'; | ||||
| @@ -17,6 +17,9 @@ import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth. | ||||
| import { AuthService } from './auth.service'; | ||||
| import { ICryptoRepository } from './crypto.repository'; | ||||
| import { SignUpDto } from './dto'; | ||||
| import { IUserTokenRepository } from '@app/domain'; | ||||
| import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock'; | ||||
| import { IncomingHttpHeaders } from 'http'; | ||||
|  | ||||
| const email = 'test@immich.com'; | ||||
| const sub = 'my-auth-user-sub'; | ||||
| @@ -47,6 +50,7 @@ describe('AuthService', () => { | ||||
|   let cryptoMock: jest.Mocked<ICryptoRepository>; | ||||
|   let userMock: jest.Mocked<IUserRepository>; | ||||
|   let configMock: jest.Mocked<ISystemConfigRepository>; | ||||
|   let userTokenMock: jest.Mocked<IUserTokenRepository>; | ||||
|   let callbackMock: jest.Mock; | ||||
|   let create: (config: SystemConfig) => AuthService; | ||||
|  | ||||
| @@ -76,8 +80,9 @@ describe('AuthService', () => { | ||||
|     cryptoMock = newCryptoRepositoryMock(); | ||||
|     userMock = newUserRepositoryMock(); | ||||
|     configMock = newSystemConfigRepositoryMock(); | ||||
|     userTokenMock = newUserTokenRepositoryMock(); | ||||
|  | ||||
|     create = (config) => new AuthService(cryptoMock, configMock, userMock, config); | ||||
|     create = (config) => new AuthService(cryptoMock, configMock, userMock, userTokenMock, config); | ||||
|  | ||||
|     sut = create(systemConfigStub.enabled); | ||||
|   }); | ||||
| @@ -106,13 +111,15 @@ describe('AuthService', () => { | ||||
|     }); | ||||
|  | ||||
|     it('should successfully log the user in', async () => { | ||||
|       userMock.getByEmail.mockResolvedValue(entityStub.user1); | ||||
|       userMock.getByEmail.mockResolvedValue(userEntityStub.user1); | ||||
|       userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); | ||||
|       await expect(sut.login(fixtures.login, CLIENT_IP, true)).resolves.toEqual(loginResponseStub.user1password); | ||||
|       expect(userMock.getByEmail).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
|  | ||||
|     it('should generate the cookie headers (insecure)', async () => { | ||||
|       userMock.getByEmail.mockResolvedValue(entityStub.user1); | ||||
|       userMock.getByEmail.mockResolvedValue(userEntityStub.user1); | ||||
|       userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); | ||||
|       await expect(sut.login(fixtures.login, CLIENT_IP, false)).resolves.toEqual(loginResponseStub.user1insecure); | ||||
|       expect(userMock.getByEmail).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
| @@ -131,7 +138,7 @@ describe('AuthService', () => { | ||||
|       await sut.changePassword(authUser, dto); | ||||
|  | ||||
|       expect(userMock.getByEmail).toHaveBeenCalledWith(authUser.email, true); | ||||
|       expect(cryptoMock.compareSync).toHaveBeenCalledWith('old-password', 'hash-password'); | ||||
|       expect(cryptoMock.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); | ||||
|     }); | ||||
|  | ||||
|     it('should throw when auth user email is not found', async () => { | ||||
| @@ -147,7 +154,7 @@ describe('AuthService', () => { | ||||
|       const authUser = { email: 'test@imimch.com' } as UserEntity; | ||||
|       const dto = { password: 'old-password', newPassword: 'new-password' }; | ||||
|  | ||||
|       cryptoMock.compareSync.mockReturnValue(false); | ||||
|       cryptoMock.compareBcrypt.mockReturnValue(false); | ||||
|  | ||||
|       userMock.getByEmail.mockResolvedValue({ | ||||
|         email: 'test@immich.com', | ||||
| @@ -161,8 +168,6 @@ describe('AuthService', () => { | ||||
|       const authUser = { email: 'test@imimch.com' } as UserEntity; | ||||
|       const dto = { password: 'old-password', newPassword: 'new-password' }; | ||||
|  | ||||
|       cryptoMock.compareSync.mockReturnValue(false); | ||||
|  | ||||
|       userMock.getByEmail.mockResolvedValue({ | ||||
|         email: 'test@immich.com', | ||||
|         password: '', | ||||
| @@ -212,52 +217,64 @@ describe('AuthService', () => { | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('validateSocket', () => { | ||||
|   describe('validate - socket connections', () => { | ||||
|     it('should validate using authorization header', async () => { | ||||
|       userMock.get.mockResolvedValue(entityStub.user1); | ||||
|       const client = { handshake: { headers: { authorization: 'Bearer jwt-token' } } }; | ||||
|       await expect(sut.validateSocket(client as Socket)).resolves.toEqual(entityStub.user1); | ||||
|       userMock.get.mockResolvedValue(userEntityStub.user1); | ||||
|       userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken); | ||||
|       const client = { request: { headers: { authorization: 'Bearer auth_token' } } }; | ||||
|       await expect(sut.validate((client as Socket).request.headers)).resolves.toEqual(userEntityStub.user1); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('validatePayload', () => { | ||||
|   describe('validate - api request', () => { | ||||
|     it('should throw if no user is found', async () => { | ||||
|       userMock.get.mockResolvedValue(null); | ||||
|       await expect(sut.validatePayload({ email: 'a', userId: 'test' })).rejects.toBeInstanceOf(UnauthorizedException); | ||||
|       await expect(sut.validate({ email: 'a', userId: 'test' })).rejects.toBeInstanceOf(UnauthorizedException); | ||||
|     }); | ||||
|  | ||||
|     it('should return an auth dto', async () => { | ||||
|       userMock.get.mockResolvedValue(entityStub.admin); | ||||
|       await expect(sut.validatePayload({ email: 'a', userId: 'test' })).resolves.toEqual(authStub.admin); | ||||
|       userMock.get.mockResolvedValue(userEntityStub.user1); | ||||
|       userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken); | ||||
|       await expect( | ||||
|         sut.validate({ cookie: 'immich_access_token=auth_token', email: 'a', userId: 'test' }), | ||||
|       ).resolves.toEqual(userEntityStub.user1); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('extractJwtFromCookie', () => { | ||||
|   describe('extractTokenFromHeader - Cookie', () => { | ||||
|     it('should extract the access token', () => { | ||||
|       const cookie = { [IMMICH_ACCESS_COOKIE]: 'signed-jwt', [IMMICH_AUTH_TYPE_COOKIE]: 'password' }; | ||||
|       expect(sut.extractJwtFromCookie(cookie)).toEqual('signed-jwt'); | ||||
|       const cookie: IncomingHttpHeaders = { | ||||
|         cookie: `${IMMICH_ACCESS_COOKIE}=signed-jwt;${IMMICH_AUTH_TYPE_COOKIE}=password`, | ||||
|       }; | ||||
|       expect(sut.extractTokenFromHeader(cookie)).toEqual('signed-jwt'); | ||||
|     }); | ||||
|  | ||||
|     it('should work with no cookies', () => { | ||||
|       expect(sut.extractJwtFromCookie(undefined as any)).toBeNull(); | ||||
|       const cookie: IncomingHttpHeaders = { | ||||
|         cookie: undefined, | ||||
|       }; | ||||
|       expect(sut.extractTokenFromHeader(cookie)).toBeNull(); | ||||
|     }); | ||||
|  | ||||
|     it('should work on empty cookies', () => { | ||||
|       expect(sut.extractJwtFromCookie({})).toBeNull(); | ||||
|       const cookie: IncomingHttpHeaders = { | ||||
|         cookie: '', | ||||
|       }; | ||||
|       expect(sut.extractTokenFromHeader(cookie)).toBeNull(); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('extractJwtFromHeader', () => { | ||||
|   describe('extractTokenFromHeader - Bearer Auth', () => { | ||||
|     it('should extract the access token', () => { | ||||
|       expect(sut.extractJwtFromHeader({ authorization: `Bearer signed-jwt` })).toEqual('signed-jwt'); | ||||
|       expect(sut.extractTokenFromHeader({ authorization: `Bearer signed-jwt` })).toEqual('signed-jwt'); | ||||
|     }); | ||||
|  | ||||
|     it('should work without the auth header', () => { | ||||
|       expect(sut.extractJwtFromHeader({})).toBeNull(); | ||||
|       expect(sut.extractTokenFromHeader({})).toBeNull(); | ||||
|     }); | ||||
|  | ||||
|     it('should ignore basic auth', () => { | ||||
|       expect(sut.extractJwtFromHeader({ authorization: `Basic stuff` })).toBeNull(); | ||||
|       expect(sut.extractTokenFromHeader({ authorization: `Basic stuff` })).toBeNull(); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -7,20 +7,20 @@ import { | ||||
|   Logger, | ||||
|   UnauthorizedException, | ||||
| } from '@nestjs/common'; | ||||
| import * as cookieParser from 'cookie'; | ||||
| import { IncomingHttpHeaders } from 'http'; | ||||
| import { Socket } from 'socket.io'; | ||||
| import { OAuthCore } from '../oauth/oauth.core'; | ||||
| import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config'; | ||||
| import { IUserRepository, UserCore, UserResponseDto } from '../user'; | ||||
| import { AuthType, jwtSecret } from './auth.constant'; | ||||
| import { IUserRepository, UserCore } from '../user'; | ||||
| import { AuthType } from './auth.constant'; | ||||
| import { AuthCore } from './auth.core'; | ||||
| import { ICryptoRepository } from './crypto.repository'; | ||||
| import { AuthUserDto, ChangePasswordDto, JwtPayloadDto, LoginCredentialDto, SignUpDto } from './dto'; | ||||
| import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto'; | ||||
| import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto'; | ||||
| import { IUserTokenRepository, UserTokenCore } from '@app/domain/user-token'; | ||||
|  | ||||
| @Injectable() | ||||
| export class AuthService { | ||||
|   private userTokenCore: UserTokenCore; | ||||
|   private authCore: AuthCore; | ||||
|   private oauthCore: OAuthCore; | ||||
|   private userCore: UserCore; | ||||
| @@ -31,11 +31,14 @@ export class AuthService { | ||||
|     @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, | ||||
|     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, | ||||
|     @Inject(IUserRepository) userRepository: IUserRepository, | ||||
|     @Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig, | ||||
|     @Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository, | ||||
|     @Inject(INITIAL_SYSTEM_CONFIG) | ||||
|     initialConfig: SystemConfig, | ||||
|   ) { | ||||
|     this.authCore = new AuthCore(cryptoRepository, configRepository, initialConfig); | ||||
|     this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository); | ||||
|     this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig); | ||||
|     this.oauthCore = new OAuthCore(configRepository, initialConfig); | ||||
|     this.userCore = new UserCore(userRepository); | ||||
|     this.userCore = new UserCore(userRepository, cryptoRepository); | ||||
|   } | ||||
|  | ||||
|   public async login( | ||||
| @@ -49,7 +52,7 @@ export class AuthService { | ||||
|  | ||||
|     let user = await this.userCore.getByEmail(loginCredential.email, true); | ||||
|     if (user) { | ||||
|       const isAuthenticated = await this.authCore.validatePassword(loginCredential.password, user); | ||||
|       const isAuthenticated = this.authCore.validatePassword(loginCredential.password, user); | ||||
|       if (!isAuthenticated) { | ||||
|         user = null; | ||||
|       } | ||||
| @@ -81,7 +84,7 @@ export class AuthService { | ||||
|       throw new UnauthorizedException(); | ||||
|     } | ||||
|  | ||||
|     const valid = await this.authCore.validatePassword(password, user); | ||||
|     const valid = this.authCore.validatePassword(password, user); | ||||
|     if (!valid) { | ||||
|       throw new BadRequestException('Wrong password'); | ||||
|     } | ||||
| @@ -112,49 +115,28 @@ export class AuthService { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async validateSocket(client: Socket): Promise<UserResponseDto | null> { | ||||
|     try { | ||||
|       const headers = client.handshake.headers; | ||||
|       const accessToken = | ||||
|         this.extractJwtFromCookie(cookieParser.parse(headers.cookie || '')) || this.extractJwtFromHeader(headers); | ||||
|   public async validate(headers: IncomingHttpHeaders): Promise<AuthUserDto> { | ||||
|     const tokenValue = this.extractTokenFromHeader(headers); | ||||
|     if (!tokenValue) { | ||||
|       throw new UnauthorizedException('No access token provided in request'); | ||||
|     } | ||||
|  | ||||
|       if (accessToken) { | ||||
|         const payload = await this.cryptoRepository.verifyJwtAsync<JwtPayloadDto>(accessToken, { secret: jwtSecret }); | ||||
|         if (payload?.userId && payload?.email) { | ||||
|           const user = await this.userCore.get(payload.userId); | ||||
|     const hashedToken = this.cryptoRepository.hashSha256(tokenValue); | ||||
|     const user = await this.userTokenCore.getUserByToken(hashedToken); | ||||
|     if (user) { | ||||
|             return user; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } catch (e) { | ||||
|       return null; | ||||
|     } | ||||
|     return null; | ||||
|       return { | ||||
|         ...user, | ||||
|         isPublicUser: false, | ||||
|         isAllowUpload: true, | ||||
|         isAllowDownload: true, | ||||
|         isShowExif: true, | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|   async validatePayload(payload: JwtPayloadDto) { | ||||
|     const { userId } = payload; | ||||
|     const user = await this.userCore.get(userId); | ||||
|     if (!user) { | ||||
|       throw new UnauthorizedException('Failure to validate JWT payload'); | ||||
|     throw new UnauthorizedException('Invalid access token provided'); | ||||
|   } | ||||
|  | ||||
|     const authUser = new AuthUserDto(); | ||||
|     authUser.id = user.id; | ||||
|     authUser.email = user.email; | ||||
|     authUser.isAdmin = user.isAdmin; | ||||
|     authUser.isPublicUser = false; | ||||
|     authUser.isAllowUpload = true; | ||||
|  | ||||
|     return authUser; | ||||
|   } | ||||
|  | ||||
|   extractJwtFromCookie(cookies: Record<string, string>) { | ||||
|     return this.authCore.extractJwtFromCookie(cookies); | ||||
|   } | ||||
|  | ||||
|   extractJwtFromHeader(headers: IncomingHttpHeaders) { | ||||
|     return this.authCore.extractJwtFromHeader(headers); | ||||
|   extractTokenFromHeader(headers: IncomingHttpHeaders) { | ||||
|     return this.authCore.extractTokenFromHeader(headers); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,11 +1,8 @@ | ||||
| import { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt'; | ||||
|  | ||||
| export const ICryptoRepository = 'ICryptoRepository'; | ||||
|  | ||||
| export interface ICryptoRepository { | ||||
|   randomBytes(size: number): Buffer; | ||||
|   hash(data: string | Buffer, saltOrRounds: string | number): Promise<string>; | ||||
|   compareSync(data: Buffer | string, encrypted: string): boolean; | ||||
|   signJwt(payload: string | Buffer | object, options?: JwtSignOptions): string; | ||||
|   verifyJwtAsync<T extends object = any>(token: string, options?: JwtVerifyOptions): Promise<T>; | ||||
|   hashSha256(data: string): string; | ||||
|   hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise<string>; | ||||
|   compareBcrypt(data: string | Buffer, encrypted: string): boolean; | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| export * from './auth.config'; | ||||
| export * from './auth.constant'; | ||||
| export * from './auth.service'; | ||||
| export * from './crypto.repository'; | ||||
|   | ||||
| @@ -13,7 +13,6 @@ const providers: Provider[] = [ | ||||
|   SystemConfigService, | ||||
|   UserService, | ||||
|   ShareService, | ||||
|  | ||||
|   { | ||||
|     provide: INITIAL_SYSTEM_CONFIG, | ||||
|     inject: [SystemConfigService], | ||||
|   | ||||
| @@ -9,3 +9,4 @@ export * from './share'; | ||||
| export * from './system-config'; | ||||
| export * from './tag'; | ||||
| export * from './user'; | ||||
| export * from './user-token'; | ||||
|   | ||||
| @@ -3,17 +3,20 @@ import { BadRequestException } from '@nestjs/common'; | ||||
| import { generators, Issuer } from 'openid-client'; | ||||
| import { | ||||
|   authStub, | ||||
|   entityStub, | ||||
|   userEntityStub, | ||||
|   loginResponseStub, | ||||
|   newCryptoRepositoryMock, | ||||
|   newSystemConfigRepositoryMock, | ||||
|   newUserRepositoryMock, | ||||
|   systemConfigStub, | ||||
|   userTokenEntityStub, | ||||
| } from '../../test'; | ||||
| import { ICryptoRepository } from '../auth'; | ||||
| import { OAuthService } from '../oauth'; | ||||
| import { ISystemConfigRepository } from '../system-config'; | ||||
| import { IUserRepository } from '../user'; | ||||
| import { IUserTokenRepository } from '@app/domain'; | ||||
| import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock'; | ||||
|  | ||||
| const email = 'user@immich.com'; | ||||
| const sub = 'my-auth-user-sub'; | ||||
| @@ -35,6 +38,7 @@ describe('OAuthService', () => { | ||||
|   let userMock: jest.Mocked<IUserRepository>; | ||||
|   let cryptoMock: jest.Mocked<ICryptoRepository>; | ||||
|   let configMock: jest.Mocked<ISystemConfigRepository>; | ||||
|   let userTokenMock: jest.Mocked<IUserTokenRepository>; | ||||
|   let callbackMock: jest.Mock; | ||||
|   let create: (config: SystemConfig) => OAuthService; | ||||
|  | ||||
| @@ -60,8 +64,9 @@ describe('OAuthService', () => { | ||||
|     cryptoMock = newCryptoRepositoryMock(); | ||||
|     configMock = newSystemConfigRepositoryMock(); | ||||
|     userMock = newUserRepositoryMock(); | ||||
|     userTokenMock = newUserTokenRepositoryMock(); | ||||
|  | ||||
|     create = (config) => new OAuthService(cryptoMock, configMock, userMock, config); | ||||
|     create = (config) => new OAuthService(cryptoMock, configMock, userMock, userTokenMock, config); | ||||
|  | ||||
|     sut = create(systemConfigStub.disabled); | ||||
|   }); | ||||
| @@ -106,23 +111,25 @@ describe('OAuthService', () => { | ||||
|  | ||||
|     it('should link an existing user', async () => { | ||||
|       sut = create(systemConfigStub.noAutoRegister); | ||||
|       userMock.getByEmail.mockResolvedValue(entityStub.user1); | ||||
|       userMock.update.mockResolvedValue(entityStub.user1); | ||||
|       userMock.getByEmail.mockResolvedValue(userEntityStub.user1); | ||||
|       userMock.update.mockResolvedValue(userEntityStub.user1); | ||||
|       userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); | ||||
|  | ||||
|       await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual( | ||||
|         loginResponseStub.user1oauth, | ||||
|       ); | ||||
|  | ||||
|       expect(userMock.getByEmail).toHaveBeenCalledTimes(1); | ||||
|       expect(userMock.update).toHaveBeenCalledWith(entityStub.user1.id, { oauthId: sub }); | ||||
|       expect(userMock.update).toHaveBeenCalledWith(userEntityStub.user1.id, { oauthId: sub }); | ||||
|     }); | ||||
|  | ||||
|     it('should allow auto registering by default', async () => { | ||||
|       sut = create(systemConfigStub.enabled); | ||||
|  | ||||
|       userMock.getByEmail.mockResolvedValue(null); | ||||
|       userMock.getAdmin.mockResolvedValue(entityStub.user1); | ||||
|       userMock.create.mockResolvedValue(entityStub.user1); | ||||
|       userMock.getAdmin.mockResolvedValue(userEntityStub.user1); | ||||
|       userMock.create.mockResolvedValue(userEntityStub.user1); | ||||
|       userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); | ||||
|  | ||||
|       await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual( | ||||
|         loginResponseStub.user1oauth, | ||||
| @@ -135,7 +142,8 @@ describe('OAuthService', () => { | ||||
|     it('should use the mobile redirect override', async () => { | ||||
|       sut = create(systemConfigStub.override); | ||||
|  | ||||
|       userMock.getByOAuthId.mockResolvedValue(entityStub.user1); | ||||
|       userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1); | ||||
|       userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); | ||||
|  | ||||
|       await sut.login({ url: `app.immich:/?code=abc123` }, true); | ||||
|  | ||||
| @@ -147,7 +155,7 @@ describe('OAuthService', () => { | ||||
|     it('should link an account', async () => { | ||||
|       sut = create(systemConfigStub.enabled); | ||||
|  | ||||
|       userMock.update.mockResolvedValue(entityStub.user1); | ||||
|       userMock.update.mockResolvedValue(userEntityStub.user1); | ||||
|  | ||||
|       await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' }); | ||||
|  | ||||
| @@ -171,7 +179,7 @@ describe('OAuthService', () => { | ||||
|     it('should unlink an account', async () => { | ||||
|       sut = create(systemConfigStub.enabled); | ||||
|  | ||||
|       userMock.update.mockResolvedValue(entityStub.user1); | ||||
|       userMock.update.mockResolvedValue(userEntityStub.user1); | ||||
|  | ||||
|       await sut.unlink(authStub.user1); | ||||
|  | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import { IUserRepository, UserCore, UserResponseDto } from '../user'; | ||||
| import { OAuthCallbackDto, OAuthConfigDto } from './dto'; | ||||
| import { OAuthCore } from './oauth.core'; | ||||
| import { OAuthConfigResponseDto } from './response-dto'; | ||||
| import { IUserTokenRepository } from '@app/domain/user-token'; | ||||
|  | ||||
| @Injectable() | ||||
| export class OAuthService { | ||||
| @@ -20,10 +21,11 @@ export class OAuthService { | ||||
|     @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, | ||||
|     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, | ||||
|     @Inject(IUserRepository) userRepository: IUserRepository, | ||||
|     @Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository, | ||||
|     @Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig, | ||||
|   ) { | ||||
|     this.authCore = new AuthCore(cryptoRepository, configRepository, initialConfig); | ||||
|     this.userCore = new UserCore(userRepository); | ||||
|     this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig); | ||||
|     this.userCore = new UserCore(userRepository, cryptoRepository); | ||||
|     this.oauthCore = new OAuthCore(configRepository, initialConfig); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; | ||||
| import { | ||||
|   authStub, | ||||
|   entityStub, | ||||
|   userEntityStub, | ||||
|   newCryptoRepositoryMock, | ||||
|   newSharedLinkRepositoryMock, | ||||
|   newUserRepositoryMock, | ||||
| @@ -50,7 +50,7 @@ describe(ShareService.name, () => { | ||||
|  | ||||
|     it('should accept a valid key', async () => { | ||||
|       shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid); | ||||
|       userMock.get.mockResolvedValue(entityStub.admin); | ||||
|       userMock.get.mockResolvedValue(userEntityStub.admin); | ||||
|       await expect(sut.validate('key')).resolves.toEqual(authStub.adminSharedLink); | ||||
|     }); | ||||
|   }); | ||||
|   | ||||
| @@ -25,7 +25,7 @@ export class ShareService { | ||||
|     @Inject(IUserRepository) userRepository: IUserRepository, | ||||
|   ) { | ||||
|     this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository); | ||||
|     this.userCore = new UserCore(userRepository); | ||||
|     this.userCore = new UserCore(userRepository, cryptoRepository); | ||||
|   } | ||||
|  | ||||
|   async validate(key: string): Promise<AuthUserDto> { | ||||
|   | ||||
							
								
								
									
										2
									
								
								server/libs/domain/src/user-token/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								server/libs/domain/src/user-token/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| export * from './user-token.repository'; | ||||
| export * from './user-token.core'; | ||||
							
								
								
									
										28
									
								
								server/libs/domain/src/user-token/user-token.core.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								server/libs/domain/src/user-token/user-token.core.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| import { UserEntity } from '@app/infra/db/entities'; | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { ICryptoRepository } from '../auth'; | ||||
| import { IUserTokenRepository } from './user-token.repository'; | ||||
|  | ||||
| @Injectable() | ||||
| export class UserTokenCore { | ||||
|   constructor(private crypto: ICryptoRepository, private repository: IUserTokenRepository) {} | ||||
|  | ||||
|   public async getUserByToken(tokenValue: string): Promise<UserEntity | null> { | ||||
|     const token = await this.repository.get(tokenValue); | ||||
|     if (token?.user) { | ||||
|       return token.user; | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   public async createToken(user: UserEntity): Promise<string> { | ||||
|     const key = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, ''); | ||||
|     const token = this.crypto.hashSha256(key); | ||||
|     await this.repository.create({ | ||||
|       token, | ||||
|       user, | ||||
|     }); | ||||
|  | ||||
|     return key; | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| import { UserTokenEntity } from '@app/infra/db/entities'; | ||||
|  | ||||
| export const IUserTokenRepository = 'IUserTokenRepository'; | ||||
|  | ||||
| export interface IUserTokenRepository { | ||||
|   create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>; | ||||
|   delete(userToken: string): Promise<void>; | ||||
|   get(userToken: string): Promise<UserTokenEntity | null>; | ||||
| } | ||||
| @@ -10,14 +10,14 @@ import { | ||||
| import { hash } from 'bcrypt'; | ||||
| import { constants, createReadStream, ReadStream } from 'fs'; | ||||
| import fs from 'fs/promises'; | ||||
| import { AuthUserDto } from '../auth'; | ||||
| import { AuthUserDto, ICryptoRepository } from '../auth'; | ||||
| import { CreateAdminDto, CreateUserDto, CreateUserOAuthDto } from './dto/create-user.dto'; | ||||
| import { IUserRepository, UserListFilter } from './user.repository'; | ||||
|  | ||||
| const SALT_ROUNDS = 10; | ||||
|  | ||||
| export class UserCore { | ||||
|   constructor(private userRepository: IUserRepository) {} | ||||
|   constructor(private userRepository: IUserRepository, private cryptoRepository: ICryptoRepository) {} | ||||
|  | ||||
|   async updateUser(authUser: AuthUserDto, id: string, dto: Partial<UserEntity>): Promise<UserEntity> { | ||||
|     if (!(authUser.isAdmin || authUser.id === id)) { | ||||
| @@ -37,7 +37,7 @@ export class UserCore { | ||||
|  | ||||
|     try { | ||||
|       if (dto.password) { | ||||
|         dto.password = await hash(dto.password, SALT_ROUNDS); | ||||
|         dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS); | ||||
|       } | ||||
|  | ||||
|       return this.userRepository.update(id, dto); | ||||
|   | ||||
| @@ -2,8 +2,8 @@ import { IUserRepository } from './user.repository'; | ||||
| import { UserEntity } from '@app/infra/db/entities'; | ||||
| import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; | ||||
| import { when } from 'jest-when'; | ||||
| import { newUserRepositoryMock } from '../../test'; | ||||
| import { AuthUserDto } from '../auth'; | ||||
| import { newCryptoRepositoryMock, newUserRepositoryMock } from '../../test'; | ||||
| import { AuthUserDto, ICryptoRepository } from '../auth'; | ||||
| import { UpdateUserDto } from './dto/update-user.dto'; | ||||
| import { UserService } from './user.service'; | ||||
|  | ||||
| @@ -77,10 +77,12 @@ const adminUserResponse = Object.freeze({ | ||||
| describe(UserService.name, () => { | ||||
|   let sut: UserService; | ||||
|   let userRepositoryMock: jest.Mocked<IUserRepository>; | ||||
|   let cryptoRepositoryMock: jest.Mocked<ICryptoRepository>; | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     userRepositoryMock = newUserRepositoryMock(); | ||||
|     sut = new UserService(userRepositoryMock); | ||||
|     cryptoRepositoryMock = newCryptoRepositoryMock(); | ||||
|     sut = new UserService(userRepositoryMock, cryptoRepositoryMock); | ||||
|  | ||||
|     when(userRepositoryMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser); | ||||
|     when(userRepositoryMock.get).calledWith(adminUser.id, undefined).mockResolvedValue(adminUser); | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; | ||||
| import { randomBytes } from 'crypto'; | ||||
| import { ReadStream } from 'fs'; | ||||
| import { AuthUserDto } from '../auth'; | ||||
| import { AuthUserDto, ICryptoRepository } from '../auth'; | ||||
| import { IUserRepository } from '../user'; | ||||
| import { CreateUserDto } from './dto/create-user.dto'; | ||||
| import { UpdateUserDto } from './dto/update-user.dto'; | ||||
| @@ -17,8 +17,11 @@ import { UserCore } from './user.core'; | ||||
| @Injectable() | ||||
| export class UserService { | ||||
|   private userCore: UserCore; | ||||
|   constructor(@Inject(IUserRepository) userRepository: IUserRepository) { | ||||
|     this.userCore = new UserCore(userRepository); | ||||
|   constructor( | ||||
|     @Inject(IUserRepository) userRepository: IUserRepository, | ||||
|     @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, | ||||
|   ) { | ||||
|     this.userCore = new UserCore(userRepository, cryptoRepository); | ||||
|   } | ||||
|  | ||||
|   async getAllUsers(authUser: AuthUserDto, isAll: boolean): Promise<UserResponseDto[]> { | ||||
|   | ||||
| @@ -3,9 +3,8 @@ import { ICryptoRepository } from '../src'; | ||||
| export const newCryptoRepositoryMock = (): jest.Mocked<ICryptoRepository> => { | ||||
|   return { | ||||
|     randomBytes: jest.fn().mockReturnValue(Buffer.from('random-bytes', 'utf8')), | ||||
|     compareSync: jest.fn().mockReturnValue(true), | ||||
|     hash: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)), | ||||
|     signJwt: jest.fn().mockReturnValue('signed-jwt'), | ||||
|     verifyJwtAsync: jest.fn().mockResolvedValue({ userId: 'test', email: 'test' }), | ||||
|     compareBcrypt: jest.fn().mockReturnValue(true), | ||||
|     hashBcrypt: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)), | ||||
|     hashSha256: jest.fn().mockImplementation((input) => `${input} (hashed)`), | ||||
|   }; | ||||
| }; | ||||
|   | ||||
| @@ -1,4 +1,11 @@ | ||||
| import { AssetType, SharedLinkEntity, SharedLinkType, SystemConfig, UserEntity } from '@app/infra/db/entities'; | ||||
| import { | ||||
|   AssetType, | ||||
|   SharedLinkEntity, | ||||
|   SharedLinkType, | ||||
|   SystemConfig, | ||||
|   UserEntity, | ||||
|   UserTokenEntity, | ||||
| } from '@app/infra/db/entities'; | ||||
| import { AlbumResponseDto, AssetResponseDto, AuthUserDto, ExifResponseDto, SharedLinkResponseDto } from '../src'; | ||||
|  | ||||
| const today = new Date(); | ||||
| @@ -81,6 +88,8 @@ export const authStub = { | ||||
|     isAdmin: false, | ||||
|     isPublicUser: false, | ||||
|     isAllowUpload: true, | ||||
|     isAllowDownload: true, | ||||
|     isShowExif: true, | ||||
|   }), | ||||
|   adminSharedLink: Object.freeze<AuthUserDto>({ | ||||
|     id: 'admin_id', | ||||
| @@ -104,7 +113,7 @@ export const authStub = { | ||||
|   }), | ||||
| }; | ||||
|  | ||||
| export const entityStub = { | ||||
| export const userEntityStub = { | ||||
|   admin: Object.freeze<UserEntity>({ | ||||
|     ...authStub.admin, | ||||
|     password: 'admin_password', | ||||
| @@ -129,6 +138,16 @@ export const entityStub = { | ||||
|   }), | ||||
| }; | ||||
|  | ||||
| export const userTokenEntityStub = { | ||||
|   userToken: Object.freeze<UserTokenEntity>({ | ||||
|     id: 'token-id', | ||||
|     token: 'auth_token', | ||||
|     user: userEntityStub.user1, | ||||
|     createdAt: '2021-01-01', | ||||
|     updatedAt: '2021-01-01', | ||||
|   }), | ||||
| }; | ||||
|  | ||||
| export const systemConfigStub = { | ||||
|   defaults: Object.freeze({ | ||||
|     ffmpeg: { | ||||
| @@ -204,7 +223,7 @@ export const systemConfigStub = { | ||||
| export const loginResponseStub = { | ||||
|   user1oauth: { | ||||
|     response: { | ||||
|       accessToken: 'signed-jwt', | ||||
|       accessToken: 'cmFuZG9tLWJ5dGVz', | ||||
|       userId: 'immich_id', | ||||
|       userEmail: 'immich@test.com', | ||||
|       firstName: 'immich_first_name', | ||||
| @@ -214,13 +233,13 @@ export const loginResponseStub = { | ||||
|       shouldChangePassword: false, | ||||
|     }, | ||||
|     cookie: [ | ||||
|       'immich_access_token=signed-jwt; Secure; Path=/; Max-Age=604800; SameSite=Strict;', | ||||
|       'immich_auth_type=oauth; Secure; Path=/; Max-Age=604800; SameSite=Strict;', | ||||
|       'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;', | ||||
|       'immich_auth_type=oauth; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;', | ||||
|     ], | ||||
|   }, | ||||
|   user1password: { | ||||
|     response: { | ||||
|       accessToken: 'signed-jwt', | ||||
|       accessToken: 'cmFuZG9tLWJ5dGVz', | ||||
|       userId: 'immich_id', | ||||
|       userEmail: 'immich@test.com', | ||||
|       firstName: 'immich_first_name', | ||||
| @@ -230,13 +249,13 @@ export const loginResponseStub = { | ||||
|       shouldChangePassword: false, | ||||
|     }, | ||||
|     cookie: [ | ||||
|       'immich_access_token=signed-jwt; Secure; Path=/; Max-Age=604800; SameSite=Strict;', | ||||
|       'immich_auth_type=password; Secure; Path=/; Max-Age=604800; SameSite=Strict;', | ||||
|       'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;', | ||||
|       'immich_auth_type=password; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;', | ||||
|     ], | ||||
|   }, | ||||
|   user1insecure: { | ||||
|     response: { | ||||
|       accessToken: 'signed-jwt', | ||||
|       accessToken: 'cmFuZG9tLWJ5dGVz', | ||||
|       userId: 'immich_id', | ||||
|       userEmail: 'immich@test.com', | ||||
|       firstName: 'immich_first_name', | ||||
| @@ -246,7 +265,7 @@ export const loginResponseStub = { | ||||
|       shouldChangePassword: false, | ||||
|     }, | ||||
|     cookie: [ | ||||
|       'immich_access_token=signed-jwt; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;', | ||||
|       'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;', | ||||
|       'immich_auth_type=password; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;', | ||||
|     ], | ||||
|   }, | ||||
|   | ||||
							
								
								
									
										9
									
								
								server/libs/domain/test/user-token.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								server/libs/domain/test/user-token.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| import { IUserTokenRepository } from '../src'; | ||||
|  | ||||
| export const newUserTokenRepositoryMock = (): jest.Mocked<IUserTokenRepository> => { | ||||
|   return { | ||||
|     create: jest.fn(), | ||||
|     delete: jest.fn(), | ||||
|     get: jest.fn(), | ||||
|   }; | ||||
| }; | ||||
| @@ -1,22 +1,16 @@ | ||||
| import { ICryptoRepository } from '@app/domain'; | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { JwtService, JwtVerifyOptions } from '@nestjs/jwt'; | ||||
| import { compareSync, hash } from 'bcrypt'; | ||||
| import { randomBytes } from 'crypto'; | ||||
| import { randomBytes, createHash } from 'crypto'; | ||||
|  | ||||
| @Injectable() | ||||
| export class CryptoRepository implements ICryptoRepository { | ||||
|   constructor(private jwtService: JwtService) {} | ||||
|  | ||||
|   randomBytes = randomBytes; | ||||
|   hash = hash; | ||||
|   compareSync = compareSync; | ||||
|  | ||||
|   signJwt(payload: string | Buffer | object) { | ||||
|     return this.jwtService.sign(payload); | ||||
|   } | ||||
|   hashBcrypt = hash; | ||||
|   compareBcrypt = compareSync; | ||||
|  | ||||
|   verifyJwtAsync<T extends object = any>(token: string, options?: JwtVerifyOptions): Promise<T> { | ||||
|     return this.jwtService.verifyAsync(token, options); | ||||
|   hashSha256(value: string) { | ||||
|     return createHash('sha256').update(value).digest('base64'); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -5,11 +5,11 @@ const url = process.env.DB_URL; | ||||
| const urlOrParts = url | ||||
|   ? { url } | ||||
|   : { | ||||
|       host: process.env.DB_HOSTNAME || 'immich_postgres', | ||||
|       host: process.env.DB_HOSTNAME || 'localhost', | ||||
|       port: parseInt(process.env.DB_PORT || '5432'), | ||||
|       username: process.env.DB_USERNAME, | ||||
|       password: process.env.DB_PASSWORD, | ||||
|       database: process.env.DB_DATABASE_NAME, | ||||
|       username: process.env.DB_USERNAME || 'postgres', | ||||
|       password: process.env.DB_PASSWORD || 'postgres', | ||||
|       database: process.env.DB_DATABASE_NAME || 'immich', | ||||
|     }; | ||||
|  | ||||
| export const databaseConfig: PostgresConnectionOptions = { | ||||
|   | ||||
| @@ -9,4 +9,5 @@ export * from './system-config.entity'; | ||||
| export * from './tag.entity'; | ||||
| export * from './user-album.entity'; | ||||
| export * from './user.entity'; | ||||
| export * from './user-token.entity'; | ||||
| export * from './shared-link.entity'; | ||||
|   | ||||
							
								
								
									
										20
									
								
								server/libs/infra/src/db/entities/user-token.entity.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								server/libs/infra/src/db/entities/user-token.entity.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; | ||||
| import { UserEntity } from './user.entity'; | ||||
|  | ||||
| @Entity('user_token') | ||||
| export class UserTokenEntity { | ||||
|   @PrimaryGeneratedColumn('uuid') | ||||
|   id!: string; | ||||
|  | ||||
|   @Column({ select: false }) | ||||
|   token!: string; | ||||
|  | ||||
|   @ManyToOne(() => UserEntity) | ||||
|   user!: UserEntity; | ||||
|  | ||||
|   @CreateDateColumn({ type: 'timestamptz' }) | ||||
|   createdAt!: string; | ||||
|  | ||||
|   @UpdateDateColumn({ type: 'timestamptz' }) | ||||
|   updatedAt!: string; | ||||
| } | ||||
| @@ -0,0 +1,16 @@ | ||||
| import { MigrationInterface, QueryRunner } from "typeorm"; | ||||
|  | ||||
| export class CreateUserTokenEntity1674342044239 implements MigrationInterface { | ||||
|     name = 'CreateUserTokenEntity1674342044239' | ||||
|  | ||||
|     public async up(queryRunner: QueryRunner): Promise<void> { | ||||
|         await queryRunner.query(`CREATE TABLE "user_token" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "token" character varying NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "userId" uuid, CONSTRAINT "PK_48cb6b5c20faa63157b3c1baf7f" PRIMARY KEY ("id"))`); | ||||
|         await queryRunner.query(`ALTER TABLE "user_token" ADD CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" 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 "user_token" DROP CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918"`); | ||||
|         await queryRunner.query(`DROP TABLE "user_token"`); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,14 @@ | ||||
| import { MigrationInterface, QueryRunner } from "typeorm" | ||||
|  | ||||
| export class TruncateAPIKeys1674774248319 implements MigrationInterface { | ||||
|     name = 'TruncateAPIKeys1674774248319' | ||||
|  | ||||
|     public async up(queryRunner: QueryRunner): Promise<void> { | ||||
|         await queryRunner.query(`TRUNCATE TABLE "api_keys"`); | ||||
|     } | ||||
|  | ||||
|     public async down(): Promise<void> { | ||||
|         //noop | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -21,14 +21,14 @@ export class APIKeyRepository implements IKeyRepository { | ||||
|     await this.repository.delete({ userId, id }); | ||||
|   } | ||||
|  | ||||
|   getKey(id: number): Promise<APIKeyEntity | null> { | ||||
|   getKey(hashedToken: string): Promise<APIKeyEntity | null> { | ||||
|     return this.repository.findOne({ | ||||
|       select: { | ||||
|         id: true, | ||||
|         key: true, | ||||
|         userId: true, | ||||
|       }, | ||||
|       where: { id }, | ||||
|       where: { key: hashedToken }, | ||||
|       relations: { | ||||
|         user: true, | ||||
|       }, | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| export * from './api-key.repository'; | ||||
| export * from './shared-link.repository'; | ||||
| export * from './user.repository'; | ||||
| export * from './user-token.repository'; | ||||
|   | ||||
							
								
								
									
										25
									
								
								server/libs/infra/src/db/repository/user-token.repository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								server/libs/infra/src/db/repository/user-token.repository.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { Repository } from 'typeorm'; | ||||
| import { UserTokenEntity } from '@app/infra/db/entities/user-token.entity'; | ||||
| import { IUserTokenRepository } from '@app/domain/user-token'; | ||||
|  | ||||
| @Injectable() | ||||
| export class UserTokenRepository implements IUserTokenRepository { | ||||
|   constructor( | ||||
|     @InjectRepository(UserTokenEntity) | ||||
|     private userTokenRepository: Repository<UserTokenEntity>, | ||||
|   ) {} | ||||
|  | ||||
|   async get(userToken: string): Promise<UserTokenEntity | null> { | ||||
|     return this.userTokenRepository.findOne({ where: { token: userToken }, relations: { user: true } }); | ||||
|   } | ||||
|  | ||||
|   async create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> { | ||||
|     return this.userTokenRepository.save(userToken); | ||||
|   } | ||||
|  | ||||
|   async delete(userToken: string): Promise<void> { | ||||
|     await this.userTokenRepository.delete(userToken); | ||||
|   } | ||||
| } | ||||
| @@ -7,17 +7,17 @@ import { | ||||
|   IUserRepository, | ||||
|   QueueName, | ||||
| } from '@app/domain'; | ||||
| import { databaseConfig, UserEntity } from './db'; | ||||
| import { databaseConfig, UserEntity, UserTokenEntity } from './db'; | ||||
| import { BullModule } from '@nestjs/bull'; | ||||
| import { Global, Module, Provider } from '@nestjs/common'; | ||||
| import { JwtModule } from '@nestjs/jwt'; | ||||
| import { TypeOrmModule } from '@nestjs/typeorm'; | ||||
| import { APIKeyEntity, SharedLinkEntity, SystemConfigEntity, UserRepository } from './db'; | ||||
| import { APIKeyRepository, SharedLinkRepository } from './db/repository'; | ||||
| import { jwtConfig } from '@app/domain'; | ||||
| import { CryptoRepository } from './auth/crypto.repository'; | ||||
| import { SystemConfigRepository } from './db/repository/system-config.repository'; | ||||
| import { JobRepository } from './job'; | ||||
| import { IUserTokenRepository } from '@app/domain/user-token'; | ||||
| import { UserTokenRepository } from '@app/infra/db/repository/user-token.repository'; | ||||
|  | ||||
| const providers: Provider[] = [ | ||||
|   { provide: ICryptoRepository, useClass: CryptoRepository }, | ||||
| @@ -26,14 +26,14 @@ const providers: Provider[] = [ | ||||
|   { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, | ||||
|   { provide: ISystemConfigRepository, useClass: SystemConfigRepository }, | ||||
|   { provide: IUserRepository, useClass: UserRepository }, | ||||
|   { provide: IUserTokenRepository, useClass: UserTokenRepository }, | ||||
| ]; | ||||
|  | ||||
| @Global() | ||||
| @Module({ | ||||
|   imports: [ | ||||
|     JwtModule.register(jwtConfig), | ||||
|     TypeOrmModule.forRoot(databaseConfig), | ||||
|     TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SharedLinkEntity, SystemConfigEntity]), | ||||
|     TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SharedLinkEntity, SystemConfigEntity, UserTokenEntity]), | ||||
|     BullModule.forRootAsync({ | ||||
|       useFactory: async () => ({ | ||||
|         prefix: 'immich_bull', | ||||
| @@ -64,6 +64,6 @@ const providers: Provider[] = [ | ||||
|     ), | ||||
|   ], | ||||
|   providers: [...providers], | ||||
|   exports: [...providers, BullModule, JwtModule], | ||||
|   exports: [...providers, BullModule], | ||||
| }) | ||||
| export class InfraModule {} | ||||
|   | ||||
							
								
								
									
										208
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										208
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -13,7 +13,6 @@ | ||||
|         "@nestjs/common": "^9.2.1", | ||||
|         "@nestjs/config": "^2.2.0", | ||||
|         "@nestjs/core": "^9.2.1", | ||||
|         "@nestjs/jwt": "^10.0.1", | ||||
|         "@nestjs/mapped-types": "1.2.0", | ||||
|         "@nestjs/passport": "^9.0.0", | ||||
|         "@nestjs/platform-express": "^9.2.1", | ||||
| @@ -50,7 +49,6 @@ | ||||
|         "passport": "^0.6.0", | ||||
|         "passport-custom": "^1.1.1", | ||||
|         "passport-http-header-strategy": "^1.1.0", | ||||
|         "passport-jwt": "^4.0.0", | ||||
|         "pg": "^8.8.0", | ||||
|         "redis": "^4.5.1", | ||||
|         "reflect-metadata": "^0.1.13", | ||||
| @@ -83,7 +81,6 @@ | ||||
|         "@types/multer": "^1.4.7", | ||||
|         "@types/mv": "^2.1.2", | ||||
|         "@types/node": "^16.0.0", | ||||
|         "@types/passport-jwt": "^3.0.6", | ||||
|         "@types/sharp": "^0.30.2", | ||||
|         "@types/supertest": "^2.0.11", | ||||
|         "@typescript-eslint/eslint-plugin": "^5.48.1", | ||||
| @@ -1521,18 +1518,6 @@ | ||||
|         "uuid": "dist/bin/uuid" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@nestjs/jwt": { | ||||
|       "version": "10.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.0.1.tgz", | ||||
|       "integrity": "sha512-LwXBKVYHnFeX6GH/Wt0WDjsWCmNDC6tEdLlwNMAvJgYp+TkiCpEmQLkgRpifdUE29mvYSbjSnVs2kW2ob935NA==", | ||||
|       "dependencies": { | ||||
|         "@types/jsonwebtoken": "8.5.9", | ||||
|         "jsonwebtoken": "9.0.0" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "@nestjs/common": "^8.0.0 || ^9.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@nestjs/mapped-types": { | ||||
|       "version": "1.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.0.tgz", | ||||
| @@ -2714,14 +2699,6 @@ | ||||
|       "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "node_modules/@types/jsonwebtoken": { | ||||
|       "version": "8.5.9", | ||||
|       "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.9.tgz", | ||||
|       "integrity": "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==", | ||||
|       "dependencies": { | ||||
|         "@types/node": "*" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@types/lodash": { | ||||
|       "version": "4.14.178", | ||||
|       "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz", | ||||
| @@ -2770,36 +2747,6 @@ | ||||
|       "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", | ||||
|       "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" | ||||
|     }, | ||||
|     "node_modules/@types/passport": { | ||||
|       "version": "1.0.7", | ||||
|       "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.7.tgz", | ||||
|       "integrity": "sha512-JtswU8N3kxBYgo+n9of7C97YQBT+AYPP2aBfNGTzABqPAZnK/WOAaKfh3XesUYMZRrXFuoPc2Hv0/G/nQFveHw==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "@types/express": "*" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@types/passport-jwt": { | ||||
|       "version": "3.0.6", | ||||
|       "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.6.tgz", | ||||
|       "integrity": "sha512-cmAAMIRTaEwpqxlrZyiEY9kdibk94gP5KTF8AT1Ra4rWNZYHNMreqhKUEeC5WJtuN5SJZjPQmV+XO2P5PlnvNQ==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "@types/express": "*", | ||||
|         "@types/jsonwebtoken": "*", | ||||
|         "@types/passport-strategy": "*" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@types/passport-strategy": { | ||||
|       "version": "0.2.35", | ||||
|       "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", | ||||
|       "integrity": "sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "@types/express": "*", | ||||
|         "@types/passport": "*" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@types/prettier": { | ||||
|       "version": "2.4.3", | ||||
|       "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.3.tgz", | ||||
| @@ -3973,11 +3920,6 @@ | ||||
|         "node": "*" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/buffer-equal-constant-time": { | ||||
|       "version": "1.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", | ||||
|       "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" | ||||
|     }, | ||||
|     "node_modules/buffer-from": { | ||||
|       "version": "1.1.2", | ||||
|       "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", | ||||
| @@ -5019,14 +4961,6 @@ | ||||
|         "safer-buffer": "^2.1.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/ecdsa-sig-formatter": { | ||||
|       "version": "1.0.11", | ||||
|       "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", | ||||
|       "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", | ||||
|       "dependencies": { | ||||
|         "safe-buffer": "^5.0.1" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/ee-first": { | ||||
|       "version": "1.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", | ||||
| @@ -7895,21 +7829,6 @@ | ||||
|         "graceful-fs": "^4.1.6" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/jsonwebtoken": { | ||||
|       "version": "9.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", | ||||
|       "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", | ||||
|       "dependencies": { | ||||
|         "jws": "^3.2.2", | ||||
|         "lodash": "^4.17.21", | ||||
|         "ms": "^2.1.1", | ||||
|         "semver": "^7.3.8" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=12", | ||||
|         "npm": ">=6" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/jsprim": { | ||||
|       "version": "1.4.2", | ||||
|       "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", | ||||
| @@ -7924,25 +7843,6 @@ | ||||
|         "node": ">=0.6.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/jwa": { | ||||
|       "version": "1.4.1", | ||||
|       "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", | ||||
|       "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", | ||||
|       "dependencies": { | ||||
|         "buffer-equal-constant-time": "1.0.1", | ||||
|         "ecdsa-sig-formatter": "1.0.11", | ||||
|         "safe-buffer": "^5.0.1" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/jws": { | ||||
|       "version": "3.2.2", | ||||
|       "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", | ||||
|       "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", | ||||
|       "dependencies": { | ||||
|         "jwa": "^1.4.1", | ||||
|         "safe-buffer": "^5.0.1" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/kdt": { | ||||
|       "version": "0.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/kdt/-/kdt-0.1.0.tgz", | ||||
| @@ -9005,15 +8905,6 @@ | ||||
|         "passport-strategy": "^1.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/passport-jwt": { | ||||
|       "version": "4.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", | ||||
|       "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", | ||||
|       "dependencies": { | ||||
|         "jsonwebtoken": "^9.0.0", | ||||
|         "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", | ||||
| @@ -12769,15 +12660,6 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "@nestjs/jwt": { | ||||
|       "version": "10.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.0.1.tgz", | ||||
|       "integrity": "sha512-LwXBKVYHnFeX6GH/Wt0WDjsWCmNDC6tEdLlwNMAvJgYp+TkiCpEmQLkgRpifdUE29mvYSbjSnVs2kW2ob935NA==", | ||||
|       "requires": { | ||||
|         "@types/jsonwebtoken": "8.5.9", | ||||
|         "jsonwebtoken": "9.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "@nestjs/mapped-types": { | ||||
|       "version": "1.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.0.tgz", | ||||
| @@ -13715,14 +13597,6 @@ | ||||
|       "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "@types/jsonwebtoken": { | ||||
|       "version": "8.5.9", | ||||
|       "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.9.tgz", | ||||
|       "integrity": "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==", | ||||
|       "requires": { | ||||
|         "@types/node": "*" | ||||
|       } | ||||
|     }, | ||||
|     "@types/lodash": { | ||||
|       "version": "4.14.178", | ||||
|       "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz", | ||||
| @@ -13771,36 +13645,6 @@ | ||||
|       "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", | ||||
|       "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" | ||||
|     }, | ||||
|     "@types/passport": { | ||||
|       "version": "1.0.7", | ||||
|       "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.7.tgz", | ||||
|       "integrity": "sha512-JtswU8N3kxBYgo+n9of7C97YQBT+AYPP2aBfNGTzABqPAZnK/WOAaKfh3XesUYMZRrXFuoPc2Hv0/G/nQFveHw==", | ||||
|       "dev": true, | ||||
|       "requires": { | ||||
|         "@types/express": "*" | ||||
|       } | ||||
|     }, | ||||
|     "@types/passport-jwt": { | ||||
|       "version": "3.0.6", | ||||
|       "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.6.tgz", | ||||
|       "integrity": "sha512-cmAAMIRTaEwpqxlrZyiEY9kdibk94gP5KTF8AT1Ra4rWNZYHNMreqhKUEeC5WJtuN5SJZjPQmV+XO2P5PlnvNQ==", | ||||
|       "dev": true, | ||||
|       "requires": { | ||||
|         "@types/express": "*", | ||||
|         "@types/jsonwebtoken": "*", | ||||
|         "@types/passport-strategy": "*" | ||||
|       } | ||||
|     }, | ||||
|     "@types/passport-strategy": { | ||||
|       "version": "0.2.35", | ||||
|       "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", | ||||
|       "integrity": "sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==", | ||||
|       "dev": true, | ||||
|       "requires": { | ||||
|         "@types/express": "*", | ||||
|         "@types/passport": "*" | ||||
|       } | ||||
|     }, | ||||
|     "@types/prettier": { | ||||
|       "version": "2.4.3", | ||||
|       "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.3.tgz", | ||||
| @@ -14727,11 +14571,6 @@ | ||||
|       "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", | ||||
|       "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" | ||||
|     }, | ||||
|     "buffer-equal-constant-time": { | ||||
|       "version": "1.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", | ||||
|       "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" | ||||
|     }, | ||||
|     "buffer-from": { | ||||
|       "version": "1.1.2", | ||||
|       "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", | ||||
| @@ -15545,14 +15384,6 @@ | ||||
|         "safer-buffer": "^2.1.0" | ||||
|       } | ||||
|     }, | ||||
|     "ecdsa-sig-formatter": { | ||||
|       "version": "1.0.11", | ||||
|       "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", | ||||
|       "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", | ||||
|       "requires": { | ||||
|         "safe-buffer": "^5.0.1" | ||||
|       } | ||||
|     }, | ||||
|     "ee-first": { | ||||
|       "version": "1.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", | ||||
| @@ -17690,17 +17521,6 @@ | ||||
|         "universalify": "^2.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "jsonwebtoken": { | ||||
|       "version": "9.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", | ||||
|       "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", | ||||
|       "requires": { | ||||
|         "jws": "^3.2.2", | ||||
|         "lodash": "^4.17.21", | ||||
|         "ms": "^2.1.1", | ||||
|         "semver": "^7.3.8" | ||||
|       } | ||||
|     }, | ||||
|     "jsprim": { | ||||
|       "version": "1.4.2", | ||||
|       "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", | ||||
| @@ -17712,25 +17532,6 @@ | ||||
|         "verror": "1.10.0" | ||||
|       } | ||||
|     }, | ||||
|     "jwa": { | ||||
|       "version": "1.4.1", | ||||
|       "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", | ||||
|       "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", | ||||
|       "requires": { | ||||
|         "buffer-equal-constant-time": "1.0.1", | ||||
|         "ecdsa-sig-formatter": "1.0.11", | ||||
|         "safe-buffer": "^5.0.1" | ||||
|       } | ||||
|     }, | ||||
|     "jws": { | ||||
|       "version": "3.2.2", | ||||
|       "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", | ||||
|       "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", | ||||
|       "requires": { | ||||
|         "jwa": "^1.4.1", | ||||
|         "safe-buffer": "^5.0.1" | ||||
|       } | ||||
|     }, | ||||
|     "kdt": { | ||||
|       "version": "0.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/kdt/-/kdt-0.1.0.tgz", | ||||
| @@ -18555,15 +18356,6 @@ | ||||
|         "passport-strategy": "^1.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "passport-jwt": { | ||||
|       "version": "4.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", | ||||
|       "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", | ||||
|       "requires": { | ||||
|         "jsonwebtoken": "^9.0.0", | ||||
|         "passport-strategy": "^1.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "passport-strategy": { | ||||
|       "version": "1.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", | ||||
|   | ||||
| @@ -29,6 +29,10 @@ | ||||
|     "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", | ||||
|     "test:e2e": "jest --config ./apps/immich/test/jest-e2e.json --runInBand", | ||||
|     "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js", | ||||
|     "typeorm:migrations:generate": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:generate -d ./libs/infra/src/db/config/database.config.ts", | ||||
|     "typeorm:migrations:run": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:run -d ./libs/infra/src/db/config/database.config.ts", | ||||
|     "typeorm:migrations:revert": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:revert -d ./libs/infra/src/db/config/database.config.ts", | ||||
|     "typeorm:schema:drop": "node --require ts-node/register ./node_modules/typeorm/cli.js schema:drop -d ./libs/infra/src/db/config/database.config.ts", | ||||
|     "api:typescript": "bash ./bin/generate-open-api.sh web", | ||||
|     "api:dart": "bash ./bin/generate-open-api.sh mobile", | ||||
|     "api:generate": "bash ./bin/generate-open-api.sh" | ||||
| @@ -38,7 +42,6 @@ | ||||
|     "@nestjs/common": "^9.2.1", | ||||
|     "@nestjs/config": "^2.2.0", | ||||
|     "@nestjs/core": "^9.2.1", | ||||
|     "@nestjs/jwt": "^10.0.1", | ||||
|     "@nestjs/mapped-types": "1.2.0", | ||||
|     "@nestjs/passport": "^9.0.0", | ||||
|     "@nestjs/platform-express": "^9.2.1", | ||||
| @@ -75,7 +78,6 @@ | ||||
|     "passport": "^0.6.0", | ||||
|     "passport-custom": "^1.1.1", | ||||
|     "passport-http-header-strategy": "^1.1.0", | ||||
|     "passport-jwt": "^4.0.0", | ||||
|     "pg": "^8.8.0", | ||||
|     "redis": "^4.5.1", | ||||
|     "reflect-metadata": "^0.1.13", | ||||
| @@ -105,7 +107,6 @@ | ||||
|     "@types/multer": "^1.4.7", | ||||
|     "@types/mv": "^2.1.2", | ||||
|     "@types/node": "^16.0.0", | ||||
|     "@types/passport-jwt": "^3.0.6", | ||||
|     "@types/sharp": "^0.30.2", | ||||
|     "@types/supertest": "^2.0.11", | ||||
|     "@typescript-eslint/eslint-plugin": "^5.48.1", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user