diff --git a/docker/.env.test b/docker/.env.test index d48b4f53fa..23f58fe805 100644 --- a/docker/.env.test +++ b/docker/.env.test @@ -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 diff --git a/docker/example.env b/docker/example.env index 922a873c05..2cfb1e7351 100644 --- a/docker/example.env +++ b/docker/example.env @@ -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 # diff --git a/docs/docs/developer/setup.md b/docs/docs/developer/setup.md index c48eb32bd6..362c32627a 100644 --- a/docs/docs/developer/setup.md +++ b/docs/docs/developer/setup.md @@ -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" diff --git a/docs/docs/install/docker-compose.md b/docs/docs/install/docker-compose.md index a062233069..1241cec421 100644 --- a/docs/docs/install/docker-compose.md +++ b/docs/docs/install/docker-compose.md @@ -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 diff --git a/docs/docs/install/portainer.md b/docs/docs/install/portainer.md index de3767e780..09c5917f3d 100644 --- a/docs/docs/install/portainer.md +++ b/docs/docs/install/portainer.md @@ -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**". diff --git a/docs/docs/install/unraid.md b/docs/docs/install/unraid.md index 235a47b4de..3cf5f80fa8 100644 --- a/docs/docs/install/unraid.md +++ b/docs/docs/install/unraid.md @@ -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` { + const authUser = await this.authService.validate(request.headers); + + if (!authUser) { + throw new UnauthorizedException('Incorrect token provided'); + } + + return authUser; + } +} diff --git a/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts b/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts deleted file mode 100644 index e3922d5fc1..0000000000 --- a/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts +++ /dev/null @@ -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 {} diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts b/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts deleted file mode 100644 index 1468dbfec9..0000000000 --- a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts +++ /dev/null @@ -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 { - return this.authService.validatePayload(payload); - } -} diff --git a/server/apps/immich/test/album.e2e-spec.ts b/server/apps/immich/test/album.e2e-spec.ts index e9de296efe..17a2bd23aa 100644 --- a/server/apps/immich/test/album.e2e-spec.ts +++ b/server/apps/immich/test/album.e2e-spec.ts @@ -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(); diff --git a/server/apps/immich/test/jest-e2e.json b/server/apps/immich/test/jest-e2e.json index c0014051c5..6867cf956e 100644 --- a/server/apps/immich/test/jest-e2e.json +++ b/server/apps/immich/test/jest-e2e.json @@ -1,5 +1,6 @@ { "moduleFileExtensions": ["js", "json", "ts"], + "modulePaths": ["", "../../../"], "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", diff --git a/server/apps/immich/test/test-utils.ts b/server/apps/immich/test/test-utils.ts index 9ab27ff3c9..67de60d19f 100644 --- a/server/apps/immich/test/test-utils.ts +++ b/server/apps/immich/test/test-utils.ts @@ -2,7 +2,7 @@ import { CanActivate, ExecutionContext } from '@nestjs/common'; import { TestingModuleBuilder } from '@nestjs/testing'; import { DataSource } from 'typeorm'; import { AuthUserDto } from '../src/decorators/auth-user.decorator'; -import { AuthGuard } from '../src/modules/immich-jwt/guards/auth.guard'; +import { AuthGuard } from '../src/modules/immich-auth/guards/auth.guard'; type CustomAuthCallback = () => AuthUserDto; diff --git a/server/apps/immich/test/user.e2e-spec.ts b/server/apps/immich/test/user.e2e-spec.ts index dde23b141a..173f2e357d 100644 --- a/server/apps/immich/test/user.e2e-spec.ts +++ b/server/apps/immich/test/user.e2e-spec.ts @@ -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(); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 1641c9bf8b..dca5d80492 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -2722,8 +2722,6 @@ "scheme": "Bearer", "bearerFormat": "JWT", "type": "http", - "name": "JWT", - "description": "Enter JWT token", "in": "header" } }, diff --git a/server/libs/common/src/config/app.config.ts b/server/libs/common/src/config/app.config.ts index 71a47b0b18..db619b32da 100644 --- a/server/libs/common/src/config/app.config.ts +++ b/server/libs/common/src/config/app.config.ts @@ -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 = (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'), diff --git a/server/libs/domain/src/api-key/api-key.repository.ts b/server/libs/domain/src/api-key/api-key.repository.ts index 961d521648..76182fe1da 100644 --- a/server/libs/domain/src/api-key/api-key.repository.ts +++ b/server/libs/domain/src/api-key/api-key.repository.ts @@ -10,7 +10,7 @@ export interface IKeyRepository { * Includes the hashed `key` for verification * @param id */ - getKey(id: number): Promise; + getKey(hashedToken: string): Promise; getById(userId: string, id: number): Promise; getByUserId(userId: string): Promise; } diff --git a/server/libs/domain/src/api-key/api-key.service.spec.ts b/server/libs/domain/src/api-key/api-key.service.spec.ts index 0b9516af74..4761734c3a 100644 --- a/server/libs/domain/src/api-key/api-key.service.spec.ts +++ b/server/libs/domain/src/api-key/api-key.service.spec.ts @@ -1,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)'); }); }); }); diff --git a/server/libs/domain/src/api-key/api-key.service.ts b/server/libs/domain/src/api-key/api-key.service.ts index aefff8f64d..c5bf096933 100644 --- a/server/libs/domain/src/api-key/api-key.service.ts +++ b/server/libs/domain/src/api-key/api-key.service.ts @@ -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 { - const key = this.crypto.randomBytes(24).toString('base64').replace(/\W/g, ''); + const secret = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, ''); const entity = await this.repository.create({ - key: await this.crypto.hash(key, 10), + key: this.crypto.hashSha256(secret), name: dto.name || 'API Key', userId: authUser.id, }); - const secret = Buffer.from(`${entity.id}:${key}`, 'utf8').toString('base64'); - return { secret, apiKey: mapKey(entity) }; } @@ -60,22 +57,18 @@ export class APIKeyService { } async validate(token: string): Promise { - const [_id, key] = Buffer.from(token, 'base64').toString('utf8').split(':'); - const id = Number(_id); + const hashedToken = this.crypto.hashSha256(token); + const keyEntity = await this.repository.getKey(hashedToken); + if (keyEntity?.user) { + const user = keyEntity.user; - if (id && key) { - const entity = await this.repository.getKey(id); - if (entity?.user && entity?.key && this.crypto.compareSync(key, entity.key)) { - const user = entity.user as UserEntity; - - return { - id: user.id, - email: user.email, - isAdmin: user.isAdmin, - isPublicUser: false, - isAllowUpload: true, - }; - } + return { + id: user.id, + email: user.email, + isAdmin: user.isAdmin, + isPublicUser: false, + isAllowUpload: true, + }; } throw new UnauthorizedException('Invalid API Key'); diff --git a/server/libs/domain/src/auth/auth.config.ts b/server/libs/domain/src/auth/auth.config.ts deleted file mode 100644 index 71dcd3a98c..0000000000 --- a/server/libs/domain/src/auth/auth.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { JwtModuleOptions } from '@nestjs/jwt'; -import { jwtSecret } from './auth.constant'; - -export const jwtConfig: JwtModuleOptions = { - secret: jwtSecret, - signOptions: { expiresIn: '30d' }, -}; diff --git a/server/libs/domain/src/auth/auth.constant.ts b/server/libs/domain/src/auth/auth.constant.ts index fbab227755..2bf04f6721 100644 --- a/server/libs/domain/src/auth/auth.constant.ts +++ b/server/libs/domain/src/auth/auth.constant.ts @@ -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 { diff --git a/server/libs/domain/src/auth/auth.core.ts b/server/libs/domain/src/auth/auth.core.ts index 109fac8dab..7cf7ac8e0a 100644 --- a/server/libs/domain/src/auth/auth.core.ts +++ b/server/libs/domain/src/auth/auth.core.ts @@ -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) { + extractTokenFromCookie(cookies: Record) { return cookies?.[IMMICH_ACCESS_COOKIE] || null; } - - private generateToken(payload: JwtPayloadDto) { - return this.cryptoRepository.signJwt({ ...payload }); - } } diff --git a/server/libs/domain/src/auth/auth.service.spec.ts b/server/libs/domain/src/auth/auth.service.spec.ts index db71cb54b9..486d26d82a 100644 --- a/server/libs/domain/src/auth/auth.service.spec.ts +++ b/server/libs/domain/src/auth/auth.service.spec.ts @@ -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; let userMock: jest.Mocked; let configMock: jest.Mocked; + let userTokenMock: jest.Mocked; 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(); }); }); }); diff --git a/server/libs/domain/src/auth/auth.service.ts b/server/libs/domain/src/auth/auth.service.ts index 0fcb799672..6872cbcecd 100644 --- a/server/libs/domain/src/auth/auth.service.ts +++ b/server/libs/domain/src/auth/auth.service.ts @@ -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 { - try { - const headers = client.handshake.headers; - const accessToken = - this.extractJwtFromCookie(cookieParser.parse(headers.cookie || '')) || this.extractJwtFromHeader(headers); - - if (accessToken) { - const payload = await this.cryptoRepository.verifyJwtAsync(accessToken, { secret: jwtSecret }); - if (payload?.userId && payload?.email) { - const user = await this.userCore.get(payload.userId); - if (user) { - return user; - } - } - } - } catch (e) { - return null; - } - return null; - } - - async validatePayload(payload: JwtPayloadDto) { - const { userId } = payload; - const user = await this.userCore.get(userId); - if (!user) { - throw new UnauthorizedException('Failure to validate JWT payload'); + public async validate(headers: IncomingHttpHeaders): Promise { + const tokenValue = this.extractTokenFromHeader(headers); + if (!tokenValue) { + throw new UnauthorizedException('No access token provided in request'); } - const authUser = new AuthUserDto(); - authUser.id = user.id; - authUser.email = user.email; - authUser.isAdmin = user.isAdmin; - authUser.isPublicUser = false; - authUser.isAllowUpload = true; + const hashedToken = this.cryptoRepository.hashSha256(tokenValue); + const user = await this.userTokenCore.getUserByToken(hashedToken); + if (user) { + return { + ...user, + isPublicUser: false, + isAllowUpload: true, + isAllowDownload: true, + isShowExif: true, + }; + } - return authUser; + throw new UnauthorizedException('Invalid access token provided'); } - extractJwtFromCookie(cookies: Record) { - return this.authCore.extractJwtFromCookie(cookies); - } - - extractJwtFromHeader(headers: IncomingHttpHeaders) { - return this.authCore.extractJwtFromHeader(headers); + extractTokenFromHeader(headers: IncomingHttpHeaders) { + return this.authCore.extractTokenFromHeader(headers); } } diff --git a/server/libs/domain/src/auth/crypto.repository.ts b/server/libs/domain/src/auth/crypto.repository.ts index e12240c74d..d400b017da 100644 --- a/server/libs/domain/src/auth/crypto.repository.ts +++ b/server/libs/domain/src/auth/crypto.repository.ts @@ -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; - compareSync(data: Buffer | string, encrypted: string): boolean; - signJwt(payload: string | Buffer | object, options?: JwtSignOptions): string; - verifyJwtAsync(token: string, options?: JwtVerifyOptions): Promise; + hashSha256(data: string): string; + hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise; + compareBcrypt(data: string | Buffer, encrypted: string): boolean; } diff --git a/server/libs/domain/src/auth/index.ts b/server/libs/domain/src/auth/index.ts index be6def62bb..118de239ea 100644 --- a/server/libs/domain/src/auth/index.ts +++ b/server/libs/domain/src/auth/index.ts @@ -1,4 +1,3 @@ -export * from './auth.config'; export * from './auth.constant'; export * from './auth.service'; export * from './crypto.repository'; diff --git a/server/libs/domain/src/domain.module.ts b/server/libs/domain/src/domain.module.ts index 8079851276..23b4adf190 100644 --- a/server/libs/domain/src/domain.module.ts +++ b/server/libs/domain/src/domain.module.ts @@ -13,7 +13,6 @@ const providers: Provider[] = [ SystemConfigService, UserService, ShareService, - { provide: INITIAL_SYSTEM_CONFIG, inject: [SystemConfigService], diff --git a/server/libs/domain/src/index.ts b/server/libs/domain/src/index.ts index 809f8b0618..38b491751d 100644 --- a/server/libs/domain/src/index.ts +++ b/server/libs/domain/src/index.ts @@ -9,3 +9,4 @@ export * from './share'; export * from './system-config'; export * from './tag'; export * from './user'; +export * from './user-token'; diff --git a/server/libs/domain/src/oauth/oauth.service.spec.ts b/server/libs/domain/src/oauth/oauth.service.spec.ts index 5408ee0392..0cf18587d1 100644 --- a/server/libs/domain/src/oauth/oauth.service.spec.ts +++ b/server/libs/domain/src/oauth/oauth.service.spec.ts @@ -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; let cryptoMock: jest.Mocked; let configMock: jest.Mocked; + let userTokenMock: jest.Mocked; 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); diff --git a/server/libs/domain/src/oauth/oauth.service.ts b/server/libs/domain/src/oauth/oauth.service.ts index f054f019e8..7d919d75ad 100644 --- a/server/libs/domain/src/oauth/oauth.service.ts +++ b/server/libs/domain/src/oauth/oauth.service.ts @@ -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); } diff --git a/server/libs/domain/src/share/share.service.spec.ts b/server/libs/domain/src/share/share.service.spec.ts index 9f997b4a56..c8dd994fc3 100644 --- a/server/libs/domain/src/share/share.service.spec.ts +++ b/server/libs/domain/src/share/share.service.spec.ts @@ -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); }); }); diff --git a/server/libs/domain/src/share/share.service.ts b/server/libs/domain/src/share/share.service.ts index eca46d97ab..e175b6e943 100644 --- a/server/libs/domain/src/share/share.service.ts +++ b/server/libs/domain/src/share/share.service.ts @@ -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 { diff --git a/server/libs/domain/src/user-token/index.ts b/server/libs/domain/src/user-token/index.ts new file mode 100644 index 0000000000..46c83640c7 --- /dev/null +++ b/server/libs/domain/src/user-token/index.ts @@ -0,0 +1,2 @@ +export * from './user-token.repository'; +export * from './user-token.core'; diff --git a/server/libs/domain/src/user-token/user-token.core.ts b/server/libs/domain/src/user-token/user-token.core.ts new file mode 100644 index 0000000000..7ae3b27835 --- /dev/null +++ b/server/libs/domain/src/user-token/user-token.core.ts @@ -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 { + const token = await this.repository.get(tokenValue); + if (token?.user) { + return token.user; + } + return null; + } + + public async createToken(user: UserEntity): Promise { + 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; + } +} diff --git a/server/libs/domain/src/user-token/user-token.repository.ts b/server/libs/domain/src/user-token/user-token.repository.ts new file mode 100644 index 0000000000..a084d22e8f --- /dev/null +++ b/server/libs/domain/src/user-token/user-token.repository.ts @@ -0,0 +1,9 @@ +import { UserTokenEntity } from '@app/infra/db/entities'; + +export const IUserTokenRepository = 'IUserTokenRepository'; + +export interface IUserTokenRepository { + create(dto: Partial): Promise; + delete(userToken: string): Promise; + get(userToken: string): Promise; +} diff --git a/server/libs/domain/src/user/user.core.ts b/server/libs/domain/src/user/user.core.ts index a1cc54f42b..30edc160bf 100644 --- a/server/libs/domain/src/user/user.core.ts +++ b/server/libs/domain/src/user/user.core.ts @@ -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): Promise { 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); diff --git a/server/libs/domain/src/user/user.service.spec.ts b/server/libs/domain/src/user/user.service.spec.ts index deb61f16fd..62df6b8ce5 100644 --- a/server/libs/domain/src/user/user.service.spec.ts +++ b/server/libs/domain/src/user/user.service.spec.ts @@ -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; + let cryptoRepositoryMock: jest.Mocked; 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); diff --git a/server/libs/domain/src/user/user.service.ts b/server/libs/domain/src/user/user.service.ts index e0d02876b9..74e669fcef 100644 --- a/server/libs/domain/src/user/user.service.ts +++ b/server/libs/domain/src/user/user.service.ts @@ -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 { diff --git a/server/libs/domain/test/crypto.repository.mock.ts b/server/libs/domain/test/crypto.repository.mock.ts index fe7e1dccc9..1e37222e44 100644 --- a/server/libs/domain/test/crypto.repository.mock.ts +++ b/server/libs/domain/test/crypto.repository.mock.ts @@ -3,9 +3,8 @@ import { ICryptoRepository } from '../src'; export const newCryptoRepositoryMock = (): jest.Mocked => { 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)`), }; }; diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index 3f267f4274..510c7e7893 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -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({ id: 'admin_id', @@ -104,7 +113,7 @@ export const authStub = { }), }; -export const entityStub = { +export const userEntityStub = { admin: Object.freeze({ ...authStub.admin, password: 'admin_password', @@ -129,6 +138,16 @@ export const entityStub = { }), }; +export const userTokenEntityStub = { + userToken: Object.freeze({ + 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;', ], }, diff --git a/server/libs/domain/test/user-token.repository.mock.ts b/server/libs/domain/test/user-token.repository.mock.ts new file mode 100644 index 0000000000..593f96c0f4 --- /dev/null +++ b/server/libs/domain/test/user-token.repository.mock.ts @@ -0,0 +1,9 @@ +import { IUserTokenRepository } from '../src'; + +export const newUserTokenRepositoryMock = (): jest.Mocked => { + return { + create: jest.fn(), + delete: jest.fn(), + get: jest.fn(), + }; +}; diff --git a/server/libs/infra/src/auth/crypto.repository.ts b/server/libs/infra/src/auth/crypto.repository.ts index 83d99a3ec8..59c7c310ef 100644 --- a/server/libs/infra/src/auth/crypto.repository.ts +++ b/server/libs/infra/src/auth/crypto.repository.ts @@ -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(token: string, options?: JwtVerifyOptions): Promise { - return this.jwtService.verifyAsync(token, options); + hashSha256(value: string) { + return createHash('sha256').update(value).digest('base64'); } } diff --git a/server/libs/infra/src/db/config/database.config.ts b/server/libs/infra/src/db/config/database.config.ts index 84765d0afb..b79ae32cb7 100644 --- a/server/libs/infra/src/db/config/database.config.ts +++ b/server/libs/infra/src/db/config/database.config.ts @@ -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 = { diff --git a/server/libs/infra/src/db/entities/index.ts b/server/libs/infra/src/db/entities/index.ts index 81073d4ce1..3ea8abcb15 100644 --- a/server/libs/infra/src/db/entities/index.ts +++ b/server/libs/infra/src/db/entities/index.ts @@ -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'; diff --git a/server/libs/infra/src/db/entities/user-token.entity.ts b/server/libs/infra/src/db/entities/user-token.entity.ts new file mode 100644 index 0000000000..3418f2c823 --- /dev/null +++ b/server/libs/infra/src/db/entities/user-token.entity.ts @@ -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; +} diff --git a/server/libs/infra/src/db/migrations/1674342044239-CreateUserTokenEntity.ts b/server/libs/infra/src/db/migrations/1674342044239-CreateUserTokenEntity.ts new file mode 100644 index 0000000000..e289787f91 --- /dev/null +++ b/server/libs/infra/src/db/migrations/1674342044239-CreateUserTokenEntity.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateUserTokenEntity1674342044239 implements MigrationInterface { + name = 'CreateUserTokenEntity1674342044239' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`ALTER TABLE "user_token" DROP CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918"`); + await queryRunner.query(`DROP TABLE "user_token"`); + } + +} diff --git a/server/libs/infra/src/db/migrations/1674774248319-TruncateAPIKeys.ts b/server/libs/infra/src/db/migrations/1674774248319-TruncateAPIKeys.ts new file mode 100644 index 0000000000..efbb5c41af --- /dev/null +++ b/server/libs/infra/src/db/migrations/1674774248319-TruncateAPIKeys.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class TruncateAPIKeys1674774248319 implements MigrationInterface { + name = 'TruncateAPIKeys1674774248319' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`TRUNCATE TABLE "api_keys"`); + } + + public async down(): Promise { + //noop + } + +} diff --git a/server/libs/infra/src/db/repository/api-key.repository.ts b/server/libs/infra/src/db/repository/api-key.repository.ts index 18ee6e6925..35119d2d7c 100644 --- a/server/libs/infra/src/db/repository/api-key.repository.ts +++ b/server/libs/infra/src/db/repository/api-key.repository.ts @@ -21,14 +21,14 @@ export class APIKeyRepository implements IKeyRepository { await this.repository.delete({ userId, id }); } - getKey(id: number): Promise { + getKey(hashedToken: string): Promise { return this.repository.findOne({ select: { id: true, key: true, userId: true, }, - where: { id }, + where: { key: hashedToken }, relations: { user: true, }, diff --git a/server/libs/infra/src/db/repository/index.ts b/server/libs/infra/src/db/repository/index.ts index 899bc21760..056c960573 100644 --- a/server/libs/infra/src/db/repository/index.ts +++ b/server/libs/infra/src/db/repository/index.ts @@ -1,3 +1,4 @@ export * from './api-key.repository'; export * from './shared-link.repository'; export * from './user.repository'; +export * from './user-token.repository'; diff --git a/server/libs/infra/src/db/repository/user-token.repository.ts b/server/libs/infra/src/db/repository/user-token.repository.ts new file mode 100644 index 0000000000..1fd9d03639 --- /dev/null +++ b/server/libs/infra/src/db/repository/user-token.repository.ts @@ -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, + ) {} + + async get(userToken: string): Promise { + return this.userTokenRepository.findOne({ where: { token: userToken }, relations: { user: true } }); + } + + async create(userToken: Partial): Promise { + return this.userTokenRepository.save(userToken); + } + + async delete(userToken: string): Promise { + await this.userTokenRepository.delete(userToken); + } +} diff --git a/server/libs/infra/src/infra.module.ts b/server/libs/infra/src/infra.module.ts index 67248c4eb4..0f37221f2b 100644 --- a/server/libs/infra/src/infra.module.ts +++ b/server/libs/infra/src/infra.module.ts @@ -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 {} diff --git a/server/package-lock.json b/server/package-lock.json index 500c72d19c..50f738191f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index b433840221..d906f2b1bd 100644 --- a/server/package.json +++ b/server/package.json @@ -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",