mirror of
https://github.com/immich-app/immich.git
synced 2024-12-25 10:43:13 +02:00
feat(server,web): OIDC Implementation (#884)
* chore: merge * feat: nullable password * feat: server debugger * chore: regenerate api * feat: auto-register flag * refactor: oauth endpoints * chore: regenerate api * fix: default scope configuration * refactor: pass in redirect uri from client * chore: docs * fix: bugs * refactor: auth services and user repository * fix: select password * fix: tests * fix: get signing algorithm from discovery document * refactor: cookie constants * feat: oauth logout * test: auth services * fix: query param check * fix: regenerate open-api
This commit is contained in:
parent
d476656789
commit
d3c35ec9c5
68
docs/docs/usage/oauth.md
Normal file
68
docs/docs/usage/oauth.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# OAuth Authentication
|
||||||
|
|
||||||
|
This page contains details about using OAuth 2 in Immich.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
|
||||||
|
|
||||||
|
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
||||||
|
- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/)
|
||||||
|
- [Okta](https://www.okta.com/openid-connect/)
|
||||||
|
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before enabling OAuth in Immich, a new client application needs to be configured in the 3rd-party authentication server. While the specifics of this setup vary from provider to provider, the general approach should be the same.
|
||||||
|
|
||||||
|
1. Create a new (Client) Application
|
||||||
|
|
||||||
|
1. The **Provider** type should be `OpenID Connect` or `OAuth2`
|
||||||
|
2. The **Client type** should be `Confidential`
|
||||||
|
3. The **Application** type should be `Web`
|
||||||
|
4. The **Grant** type should be `Authorization Code`
|
||||||
|
|
||||||
|
2. Configure Redirect URIs/Origins
|
||||||
|
|
||||||
|
1. The **Sign-in redirect URIs** should include:
|
||||||
|
|
||||||
|
- All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`)
|
||||||
|
|
||||||
|
## Enable OAuth
|
||||||
|
|
||||||
|
Once you have a new OAuth client application configured, Immich can be configured using the following environment variables:
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
| ------------------- | ------- | -------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| OAUTH_ENABLED | boolean | false | Enable/disable OAuth2 |
|
||||||
|
| OAUTH_ISSUER_URL | URL | (required) | Required. Self-discovery URL for client (from previous step) |
|
||||||
|
| OAUTH_CLIENT_ID | string | (required) | Required. Client ID (from previous step) |
|
||||||
|
| OAUTH_CLIENT_SECRET | string | (required) | Required. Client Secret (previous step |
|
||||||
|
| OAUTH_SCOPE | string | openid email profile | Full list of scopes to send with the request (space delimited) |
|
||||||
|
| OAUTH_AUTO_REGISTER | boolean | true | When true, will automatically register a user the first time they sign in |
|
||||||
|
| OAUTH_BUTTON_TEXT | string | Login with OAuth | Text for the OAuth button on the web |
|
||||||
|
|
||||||
|
:::info
|
||||||
|
The Issuer URL should look something like the following, and return a valid json document.
|
||||||
|
|
||||||
|
- `https://accounts.google.com/.well-known/openid-configuration`
|
||||||
|
- `http://localhost:9000/application/o/immich/.well-known/openid-configuration`
|
||||||
|
|
||||||
|
The `.well-known/openid-configuration` part of the url is optional and will be automatically added during discovery.
|
||||||
|
:::
|
||||||
|
|
||||||
|
Here is an example of a valid configuration for setting up Immich to use OAuth with Authentik:
|
||||||
|
|
||||||
|
```
|
||||||
|
OAUTH_ENABLED=true
|
||||||
|
OAUTH_ISSUER_URL=http://192.168.0.187:9000/application/o/immich
|
||||||
|
OAUTH_CLIENT_ID=f08f9c5b4f77dcfd3916b1c032336b5544a7b368
|
||||||
|
OAUTH_CLIENT_SECRET=6fe2e697644da6ff6aef73387a457d819018189086fa54b151a6067fbb884e75f7e5c90be16d3c688cf902c6974817a85eab93007d76675041eaead8c39cf5a2
|
||||||
|
OAUTH_BUTTON_TEXT=Login with Authentik
|
||||||
|
```
|
||||||
|
|
||||||
|
[oidc]: https://openid.net/connect/
|
@ -46,6 +46,10 @@ doc/JobStatusResponseDto.md
|
|||||||
doc/LoginCredentialDto.md
|
doc/LoginCredentialDto.md
|
||||||
doc/LoginResponseDto.md
|
doc/LoginResponseDto.md
|
||||||
doc/LogoutResponseDto.md
|
doc/LogoutResponseDto.md
|
||||||
|
doc/OAuthApi.md
|
||||||
|
doc/OAuthCallbackDto.md
|
||||||
|
doc/OAuthConfigDto.md
|
||||||
|
doc/OAuthConfigResponseDto.md
|
||||||
doc/RemoveAssetsDto.md
|
doc/RemoveAssetsDto.md
|
||||||
doc/SearchAssetDto.md
|
doc/SearchAssetDto.md
|
||||||
doc/ServerInfoApi.md
|
doc/ServerInfoApi.md
|
||||||
@ -73,6 +77,7 @@ lib/api/asset_api.dart
|
|||||||
lib/api/authentication_api.dart
|
lib/api/authentication_api.dart
|
||||||
lib/api/device_info_api.dart
|
lib/api/device_info_api.dart
|
||||||
lib/api/job_api.dart
|
lib/api/job_api.dart
|
||||||
|
lib/api/o_auth_api.dart
|
||||||
lib/api/server_info_api.dart
|
lib/api/server_info_api.dart
|
||||||
lib/api/user_api.dart
|
lib/api/user_api.dart
|
||||||
lib/api_client.dart
|
lib/api_client.dart
|
||||||
@ -122,6 +127,9 @@ lib/model/job_status_response_dto.dart
|
|||||||
lib/model/login_credential_dto.dart
|
lib/model/login_credential_dto.dart
|
||||||
lib/model/login_response_dto.dart
|
lib/model/login_response_dto.dart
|
||||||
lib/model/logout_response_dto.dart
|
lib/model/logout_response_dto.dart
|
||||||
|
lib/model/o_auth_callback_dto.dart
|
||||||
|
lib/model/o_auth_config_dto.dart
|
||||||
|
lib/model/o_auth_config_response_dto.dart
|
||||||
lib/model/remove_assets_dto.dart
|
lib/model/remove_assets_dto.dart
|
||||||
lib/model/search_asset_dto.dart
|
lib/model/search_asset_dto.dart
|
||||||
lib/model/server_info_response_dto.dart
|
lib/model/server_info_response_dto.dart
|
||||||
|
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/doc/OAuthApi.md
Normal file
BIN
mobile/openapi/doc/OAuthApi.md
Normal file
Binary file not shown.
BIN
mobile/openapi/doc/OAuthCallbackDto.md
Normal file
BIN
mobile/openapi/doc/OAuthCallbackDto.md
Normal file
Binary file not shown.
BIN
mobile/openapi/doc/OAuthConfigDto.md
Normal file
BIN
mobile/openapi/doc/OAuthConfigDto.md
Normal file
Binary file not shown.
BIN
mobile/openapi/doc/OAuthConfigResponseDto.md
Normal file
BIN
mobile/openapi/doc/OAuthConfigResponseDto.md
Normal file
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/api/o_auth_api.dart
Normal file
BIN
mobile/openapi/lib/api/o_auth_api.dart
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/model/o_auth_callback_dto.dart
Normal file
BIN
mobile/openapi/lib/model/o_auth_callback_dto.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/o_auth_config_dto.dart
Normal file
BIN
mobile/openapi/lib/model/o_auth_config_dto.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/o_auth_config_response_dto.dart
Normal file
BIN
mobile/openapi/lib/model/o_auth_config_response_dto.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/test/o_auth_api_test.dart
Normal file
BIN
mobile/openapi/test/o_auth_api_test.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/test/o_auth_callback_dto_test.dart
Normal file
BIN
mobile/openapi/test/o_auth_callback_dto_test.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/test/o_auth_config_dto_test.dart
Normal file
BIN
mobile/openapi/test/o_auth_config_dto_test.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/test/o_auth_config_response_dto_test.dart
Normal file
BIN
mobile/openapi/test/o_auth_config_response_dto_test.dart
Normal file
Binary file not shown.
@ -1,36 +1,31 @@
|
|||||||
import { Body, Controller, Post, Res, ValidationPipe, Ip } from '@nestjs/common';
|
import { Body, Controller, Ip, Post, Req, Res, ValidationPipe } from '@nestjs/common';
|
||||||
import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { AuthType, IMMICH_AUTH_TYPE_COOKIE } from '../../constants/jwt.constant';
|
||||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||||
import { Authenticated } from '../../decorators/authenticated.decorator';
|
import { Authenticated } from '../../decorators/authenticated.decorator';
|
||||||
|
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { LoginCredentialDto } from './dto/login-credential.dto';
|
import { LoginCredentialDto } from './dto/login-credential.dto';
|
||||||
import { LoginResponseDto } from './response-dto/login-response.dto';
|
|
||||||
import { SignUpDto } from './dto/sign-up.dto';
|
import { SignUpDto } from './dto/sign-up.dto';
|
||||||
import { AdminSignupResponseDto } from './response-dto/admin-signup-response.dto';
|
import { AdminSignupResponseDto } from './response-dto/admin-signup-response.dto';
|
||||||
import { ValidateAccessTokenResponseDto } from './response-dto/validate-asset-token-response.dto,';
|
import { LoginResponseDto } from './response-dto/login-response.dto';
|
||||||
import { Response } from 'express';
|
|
||||||
import { LogoutResponseDto } from './response-dto/logout-response.dto';
|
import { LogoutResponseDto } from './response-dto/logout-response.dto';
|
||||||
|
import { ValidateAccessTokenResponseDto } from './response-dto/validate-asset-token-response.dto,';
|
||||||
|
|
||||||
@ApiTags('Authentication')
|
@ApiTags('Authentication')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService, private readonly immichJwtService: ImmichJwtService) {}
|
||||||
|
|
||||||
@Post('/login')
|
@Post('/login')
|
||||||
async login(
|
async login(
|
||||||
@Body(new ValidationPipe({ transform: true })) loginCredential: LoginCredentialDto,
|
@Body(new ValidationPipe({ transform: true })) loginCredential: LoginCredentialDto,
|
||||||
@Ip() clientIp: string,
|
@Ip() clientIp: string,
|
||||||
@Res() response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
): Promise<LoginResponseDto> {
|
): Promise<LoginResponseDto> {
|
||||||
const loginResponse = await this.authService.login(loginCredential, clientIp);
|
const loginResponse = await this.authService.login(loginCredential, clientIp);
|
||||||
|
response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.PASSWORD));
|
||||||
// Set Cookies
|
|
||||||
const accessTokenCookie = this.authService.getCookieWithJwtToken(loginResponse);
|
|
||||||
const isAuthCookie = `immich_is_authenticated=true; Path=/; Max-Age=${7 * 24 * 3600}`;
|
|
||||||
|
|
||||||
response.setHeader('Set-Cookie', [accessTokenCookie, isAuthCookie]);
|
|
||||||
response.send(loginResponse);
|
|
||||||
|
|
||||||
return loginResponse;
|
return loginResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,13 +46,14 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('/logout')
|
@Post('/logout')
|
||||||
async logout(@Res() response: Response): Promise<LogoutResponseDto> {
|
async logout(@Req() req: Request, @Res({ passthrough: true }) response: Response): Promise<LogoutResponseDto> {
|
||||||
response.clearCookie('immich_access_token');
|
const authType: AuthType = req.cookies[IMMICH_AUTH_TYPE_COOKIE];
|
||||||
response.clearCookie('immich_is_authenticated');
|
|
||||||
|
|
||||||
const status = new LogoutResponseDto(true);
|
const cookies = this.immichJwtService.getCookieNames();
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
response.clearCookie(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
response.send(status);
|
return this.authService.logout(authType);
|
||||||
return status;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { AuthController } from './auth.controller';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { UserEntity } from '@app/database/entities/user.entity';
|
|
||||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
|
||||||
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
|
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { OAuthModule } from '../oauth/oauth.module';
|
||||||
import { jwtConfig } from '../../config/jwt.config';
|
import { UserModule } from '../user/user.module';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
|
imports: [UserModule, ImmichJwtModule, OAuthModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, ImmichJwtService],
|
providers: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
147
server/apps/immich/src/api-v1/auth/auth.service.spec.ts
Normal file
147
server/apps/immich/src/api-v1/auth/auth.service.spec.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { UserEntity } from '@app/database/entities/user.entity';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { AuthType } from '../../constants/jwt.constant';
|
||||||
|
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||||
|
import { OAuthService } from '../oauth/oauth.service';
|
||||||
|
import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { SignUpDto } from './dto/sign-up.dto';
|
||||||
|
import { LoginResponseDto } from './response-dto/login-response.dto';
|
||||||
|
|
||||||
|
const fixtures = {
|
||||||
|
login: {
|
||||||
|
email: 'test@immich.com',
|
||||||
|
password: 'password',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLIENT_IP = '127.0.0.1';
|
||||||
|
|
||||||
|
jest.mock('bcrypt');
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let sut: AuthService;
|
||||||
|
let userRepositoryMock: jest.Mocked<IUserRepository>;
|
||||||
|
let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
|
||||||
|
let oauthServiceMock: jest.Mocked<OAuthService>;
|
||||||
|
let compare: jest.Mock;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.mock('bcrypt');
|
||||||
|
compare = bcrypt.compare as jest.Mock;
|
||||||
|
|
||||||
|
userRepositoryMock = {
|
||||||
|
get: jest.fn(),
|
||||||
|
getAdmin: jest.fn(),
|
||||||
|
getByEmail: jest.fn(),
|
||||||
|
getList: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
restore: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
immichJwtServiceMock = {
|
||||||
|
getCookieNames: jest.fn(),
|
||||||
|
getCookies: jest.fn(),
|
||||||
|
createLoginResponse: jest.fn(),
|
||||||
|
validateToken: jest.fn(),
|
||||||
|
extractJwtFromHeader: jest.fn(),
|
||||||
|
extractJwtFromCookie: jest.fn(),
|
||||||
|
} as unknown as jest.Mocked<ImmichJwtService>;
|
||||||
|
|
||||||
|
oauthServiceMock = {
|
||||||
|
getLogoutEndpoint: jest.fn(),
|
||||||
|
} as unknown as jest.Mocked<OAuthService>;
|
||||||
|
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
{ provide: ImmichJwtService, useValue: immichJwtServiceMock },
|
||||||
|
{ provide: OAuthService, useValue: oauthServiceMock },
|
||||||
|
{
|
||||||
|
provide: USER_REPOSITORY,
|
||||||
|
useValue: userRepositoryMock,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
sut = moduleRef.get(AuthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(sut).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('should check the user exists', async () => {
|
||||||
|
userRepositoryMock.getByEmail.mockResolvedValue(null);
|
||||||
|
await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check the user has a password', async () => {
|
||||||
|
userRepositoryMock.getByEmail.mockResolvedValue({} as UserEntity);
|
||||||
|
await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should successfully log the user in', async () => {
|
||||||
|
userRepositoryMock.getByEmail.mockResolvedValue({ password: 'password' } as UserEntity);
|
||||||
|
compare.mockResolvedValue(true);
|
||||||
|
const dto = { firstName: 'test', lastName: 'immich' } as LoginResponseDto;
|
||||||
|
immichJwtServiceMock.createLoginResponse.mockResolvedValue(dto);
|
||||||
|
await expect(sut.login(fixtures.login, CLIENT_IP)).resolves.toEqual(dto);
|
||||||
|
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
|
expect(immichJwtServiceMock.createLoginResponse).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
it('should return the end session endpoint', async () => {
|
||||||
|
oauthServiceMock.getLogoutEndpoint.mockResolvedValue('end-session-endpoint');
|
||||||
|
await expect(sut.logout(AuthType.OAUTH)).resolves.toEqual({
|
||||||
|
successful: true,
|
||||||
|
redirectUri: 'end-session-endpoint',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the default redirect', async () => {
|
||||||
|
await expect(sut.logout(AuthType.PASSWORD)).resolves.toEqual({
|
||||||
|
successful: true,
|
||||||
|
redirectUri: '/auth/login',
|
||||||
|
});
|
||||||
|
expect(oauthServiceMock.getLogoutEndpoint).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('adminSignUp', () => {
|
||||||
|
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', firstName: 'immich', lastName: 'admin' };
|
||||||
|
|
||||||
|
it('should only allow one admin', async () => {
|
||||||
|
userRepositoryMock.getAdmin.mockResolvedValue({} as UserEntity);
|
||||||
|
await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(userRepositoryMock.getAdmin).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sign up the admin', async () => {
|
||||||
|
userRepositoryMock.getAdmin.mockResolvedValue(null);
|
||||||
|
userRepositoryMock.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: 'today' } as UserEntity);
|
||||||
|
await expect(sut.adminSignUp(dto)).resolves.toEqual({
|
||||||
|
id: 'admin',
|
||||||
|
createdAt: 'today',
|
||||||
|
email: 'test@immich.com',
|
||||||
|
firstName: 'immich',
|
||||||
|
lastName: 'admin',
|
||||||
|
});
|
||||||
|
expect(userRepositoryMock.getAdmin).toHaveBeenCalled();
|
||||||
|
expect(userRepositoryMock.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,106 +1,80 @@
|
|||||||
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { UserEntity } from '@app/database/entities/user.entity';
|
|
||||||
import { LoginCredentialDto } from './dto/login-credential.dto';
|
|
||||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
|
||||||
import { JwtPayloadDto } from './dto/jwt-payload.dto';
|
|
||||||
import { SignUpDto } from './dto/sign-up.dto';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { LoginResponseDto, mapLoginResponse } from './response-dto/login-response.dto';
|
import { UserEntity } from '../../../../../libs/database/src/entities/user.entity';
|
||||||
|
import { AuthType } from '../../constants/jwt.constant';
|
||||||
|
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||||
|
import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
|
||||||
|
import { LoginCredentialDto } from './dto/login-credential.dto';
|
||||||
|
import { SignUpDto } from './dto/sign-up.dto';
|
||||||
import { AdminSignupResponseDto, mapAdminSignupResponse } from './response-dto/admin-signup-response.dto';
|
import { AdminSignupResponseDto, mapAdminSignupResponse } from './response-dto/admin-signup-response.dto';
|
||||||
|
import { LoginResponseDto } from './response-dto/login-response.dto';
|
||||||
|
import { LogoutResponseDto } from './response-dto/logout-response.dto';
|
||||||
|
import { OAuthService } from '../oauth/oauth.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(UserEntity)
|
private oauthService: OAuthService,
|
||||||
private userRepository: Repository<UserEntity>,
|
|
||||||
private immichJwtService: ImmichJwtService,
|
private immichJwtService: ImmichJwtService,
|
||||||
|
@Inject(USER_REPOSITORY) private userRepository: IUserRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity | null> {
|
public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> {
|
||||||
const user = await this.userRepository.findOne({
|
let user = await this.userRepository.getByEmail(loginCredential.email, true);
|
||||||
where: {
|
|
||||||
email: loginCredential.email,
|
if (user) {
|
||||||
},
|
const isAuthenticated = await this.validatePassword(loginCredential.password, user);
|
||||||
select: [
|
if (!isAuthenticated) {
|
||||||
'id',
|
user = null;
|
||||||
'email',
|
}
|
||||||
'password',
|
}
|
||||||
'salt',
|
|
||||||
'firstName',
|
|
||||||
'lastName',
|
|
||||||
'isAdmin',
|
|
||||||
'profileImagePath',
|
|
||||||
'shouldChangePassword',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const isAuthenticated = await this.validatePassword(user.password!, loginCredential.password, user.salt!);
|
|
||||||
|
|
||||||
if (isAuthenticated) {
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> {
|
|
||||||
const validatedUser = await this.validateUser(loginCredential);
|
|
||||||
|
|
||||||
if (!validatedUser) {
|
|
||||||
Logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`);
|
Logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`);
|
||||||
throw new BadRequestException('Incorrect email or password');
|
throw new BadRequestException('Incorrect email or password');
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = new JwtPayloadDto(validatedUser.id, validatedUser.email);
|
return this.immichJwtService.createLoginResponse(user);
|
||||||
const accessToken = await this.immichJwtService.generateToken(payload);
|
|
||||||
|
|
||||||
return mapLoginResponse(validatedUser, accessToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCookieWithJwtToken(authLoginInfo: LoginResponseDto) {
|
public async logout(authType: AuthType): Promise<LogoutResponseDto> {
|
||||||
const maxAge = 7 * 24 * 3600; // 7 days
|
if (authType === AuthType.OAUTH) {
|
||||||
return `immich_access_token=${authLoginInfo.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}`;
|
const url = await this.oauthService.getLogoutEndpoint();
|
||||||
|
if (url) {
|
||||||
|
return { successful: true, redirectUri: url };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { successful: true, redirectUri: '/auth/login' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// !TODO: refactor this method to use the userService createUser method
|
public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
|
||||||
public async adminSignUp(signUpCredential: SignUpDto): Promise<AdminSignupResponseDto> {
|
const adminUser = await this.userRepository.getAdmin();
|
||||||
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
|
|
||||||
|
|
||||||
if (adminUser) {
|
if (adminUser) {
|
||||||
throw new BadRequestException('The server already has an admin');
|
throw new BadRequestException('The server already has an admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const newAdminUser = new UserEntity();
|
|
||||||
newAdminUser.email = signUpCredential.email;
|
|
||||||
newAdminUser.salt = await bcrypt.genSalt();
|
|
||||||
newAdminUser.password = await this.hashPassword(signUpCredential.password, newAdminUser.salt);
|
|
||||||
newAdminUser.firstName = signUpCredential.firstName;
|
|
||||||
newAdminUser.lastName = signUpCredential.lastName;
|
|
||||||
newAdminUser.isAdmin = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const savedNewAdminUserUser = await this.userRepository.save(newAdminUser);
|
const admin = await this.userRepository.create({
|
||||||
|
isAdmin: true,
|
||||||
|
email: dto.email,
|
||||||
|
firstName: dto.firstName,
|
||||||
|
lastName: dto.lastName,
|
||||||
|
password: dto.password,
|
||||||
|
});
|
||||||
|
|
||||||
return mapAdminSignupResponse(savedNewAdminUserUser);
|
return mapAdminSignupResponse(admin);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error('e', 'signUp');
|
Logger.error('e', 'signUp');
|
||||||
throw new InternalServerErrorException('Failed to register new admin user');
|
throw new InternalServerErrorException('Failed to register new admin user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async hashPassword(password: string, salt: string): Promise<string> {
|
private async validatePassword(inputPassword: string, user: UserEntity): Promise<boolean> {
|
||||||
return bcrypt.hash(password, salt);
|
if (!user || !user.password) {
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
private async validatePassword(hasedPassword: string, inputPassword: string, salt: string): Promise<boolean> {
|
return await bcrypt.compare(inputPassword, user.password);
|
||||||
const hash = await bcrypt.hash(inputPassword, salt);
|
|
||||||
return hash === hasedPassword;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,4 +7,7 @@ export class LogoutResponseDto {
|
|||||||
|
|
||||||
@ApiResponseProperty()
|
@ApiResponseProperty()
|
||||||
successful!: boolean;
|
successful!: boolean;
|
||||||
|
|
||||||
|
@ApiResponseProperty()
|
||||||
|
redirectUri!: string;
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { UserEntity } from '@app/database/entities/user.entity';
|
import { UserEntity } from '@app/database/entities/user.entity';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import cookieParser from 'cookie';
|
import cookieParser from 'cookie';
|
||||||
|
import { IMMICH_ACCESS_COOKIE } from '../../constants/jwt.constant';
|
||||||
|
|
||||||
@WebSocketGateway({ cors: true })
|
@WebSocketGateway({ cors: true })
|
||||||
export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
constructor(
|
constructor(
|
||||||
@ -30,8 +32,8 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
|
|
||||||
if (client.handshake.headers.cookie != undefined) {
|
if (client.handshake.headers.cookie != undefined) {
|
||||||
const cookies = cookieParser.parse(client.handshake.headers.cookie);
|
const cookies = cookieParser.parse(client.handshake.headers.cookie);
|
||||||
if (cookies.immich_access_token) {
|
if (cookies[IMMICH_ACCESS_COOKIE]) {
|
||||||
accessToken = cookies.immich_access_token;
|
accessToken = cookies[IMMICH_ACCESS_COOKIE];
|
||||||
} else {
|
} else {
|
||||||
client.emit('error', 'unauthorized');
|
client.emit('error', 'unauthorized');
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class OAuthCallbackDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty()
|
||||||
|
url!: string;
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class OAuthConfigDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty()
|
||||||
|
redirectUri!: string;
|
||||||
|
}
|
27
server/apps/immich/src/api-v1/oauth/oauth.controller.ts
Normal file
27
server/apps/immich/src/api-v1/oauth/oauth.controller.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Body, Controller, Post, Res, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { AuthType } from '../../constants/jwt.constant';
|
||||||
|
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||||
|
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
|
||||||
|
import { OAuthConfigDto } from './dto/oauth-config.dto';
|
||||||
|
import { OAuthService } from './oauth.service';
|
||||||
|
import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto';
|
||||||
|
|
||||||
|
@ApiTags('OAuth')
|
||||||
|
@Controller('oauth')
|
||||||
|
export class OAuthController {
|
||||||
|
constructor(private readonly immichJwtService: ImmichJwtService, private readonly oauthService: OAuthService) {}
|
||||||
|
|
||||||
|
@Post('/config')
|
||||||
|
public generateConfig(@Body(ValidationPipe) dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
|
||||||
|
return this.oauthService.generateConfig(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/callback')
|
||||||
|
public async callback(@Res({ passthrough: true }) response: Response, @Body(ValidationPipe) dto: OAuthCallbackDto) {
|
||||||
|
const loginResponse = await this.oauthService.callback(dto);
|
||||||
|
response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH));
|
||||||
|
return loginResponse;
|
||||||
|
}
|
||||||
|
}
|
13
server/apps/immich/src/api-v1/oauth/oauth.module.ts
Normal file
13
server/apps/immich/src/api-v1/oauth/oauth.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
|
||||||
|
import { UserModule } from '../user/user.module';
|
||||||
|
import { OAuthController } from './oauth.controller';
|
||||||
|
import { OAuthService } from './oauth.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [UserModule, ImmichJwtModule],
|
||||||
|
controllers: [OAuthController],
|
||||||
|
providers: [OAuthService],
|
||||||
|
exports: [OAuthService],
|
||||||
|
})
|
||||||
|
export class OAuthModule {}
|
169
server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts
Normal file
169
server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import { UserEntity } from '@app/database/entities/user.entity';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { generators, Issuer } from 'openid-client';
|
||||||
|
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||||
|
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
|
||||||
|
import { OAuthService } from '../oauth/oauth.service';
|
||||||
|
import { IUserRepository } from '../user/user-repository';
|
||||||
|
|
||||||
|
interface OAuthConfig {
|
||||||
|
OAUTH_ENABLED: boolean;
|
||||||
|
OAUTH_AUTO_REGISTER: boolean;
|
||||||
|
OAUTH_ISSUER_URL: string;
|
||||||
|
OAUTH_SCOPE: string;
|
||||||
|
OAUTH_BUTTON_TEXT: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockConfig = (config: Partial<OAuthConfig>) => {
|
||||||
|
return (value: keyof OAuthConfig, defaultValue: any) => config[value] ?? defaultValue ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const email = 'user@immich.com';
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
id: 'user',
|
||||||
|
email,
|
||||||
|
firstName: 'user',
|
||||||
|
lastName: 'imimch',
|
||||||
|
} as UserEntity;
|
||||||
|
|
||||||
|
const loginResponse = {
|
||||||
|
accessToken: 'access-token',
|
||||||
|
userId: 'user',
|
||||||
|
userEmail: 'user@immich.com,',
|
||||||
|
} as LoginResponseDto;
|
||||||
|
|
||||||
|
describe('OAuthService', () => {
|
||||||
|
let sut: OAuthService;
|
||||||
|
let userRepositoryMock: jest.Mocked<IUserRepository>;
|
||||||
|
let configServiceMock: jest.Mocked<ConfigService>;
|
||||||
|
let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.spyOn(generators, 'state').mockReturnValue('state');
|
||||||
|
jest.spyOn(Issuer, 'discover').mockResolvedValue({
|
||||||
|
id_token_signing_alg_values_supported: ['HS256'],
|
||||||
|
Client: jest.fn().mockResolvedValue({
|
||||||
|
issuer: {
|
||||||
|
metadata: {
|
||||||
|
end_session_endpoint: 'http://end-session-endpoint',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'),
|
||||||
|
callbackParams: jest.fn().mockReturnValue({ state: 'state' }),
|
||||||
|
callback: jest.fn().mockReturnValue({ access_token: 'access-token' }),
|
||||||
|
userinfo: jest.fn().mockResolvedValue({ email }),
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
userRepositoryMock = {
|
||||||
|
get: jest.fn(),
|
||||||
|
getAdmin: jest.fn(),
|
||||||
|
getByEmail: jest.fn(),
|
||||||
|
getList: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
restore: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
immichJwtServiceMock = {
|
||||||
|
getCookieNames: jest.fn(),
|
||||||
|
getCookies: jest.fn(),
|
||||||
|
createLoginResponse: jest.fn(),
|
||||||
|
validateToken: jest.fn(),
|
||||||
|
extractJwtFromHeader: jest.fn(),
|
||||||
|
extractJwtFromCookie: jest.fn(),
|
||||||
|
} as unknown as jest.Mocked<ImmichJwtService>;
|
||||||
|
|
||||||
|
configServiceMock = {
|
||||||
|
get: jest.fn(),
|
||||||
|
} as unknown as jest.Mocked<ConfigService>;
|
||||||
|
|
||||||
|
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(sut).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateConfig', () => {
|
||||||
|
it('should work when oauth is not configured', async () => {
|
||||||
|
await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({ enabled: false });
|
||||||
|
expect(configServiceMock.get).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate the config', async () => {
|
||||||
|
configServiceMock.get.mockImplementation(
|
||||||
|
mockConfig({
|
||||||
|
OAUTH_ENABLED: true,
|
||||||
|
OAUTH_BUTTON_TEXT: 'OAuth',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
|
||||||
|
await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
|
||||||
|
enabled: true,
|
||||||
|
buttonText: 'OAuth',
|
||||||
|
url: 'http://authorization-url',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('callback', () => {
|
||||||
|
it('should throw an error if OAuth is not enabled', async () => {
|
||||||
|
await expect(sut.callback({ url: '' })).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow auto registering', async () => {
|
||||||
|
configServiceMock.get.mockImplementation(
|
||||||
|
mockConfig({
|
||||||
|
OAUTH_ENABLED: true,
|
||||||
|
OAUTH_AUTO_REGISTER: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
|
||||||
|
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
|
||||||
|
jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null);
|
||||||
|
userRepositoryMock.getByEmail.mockResolvedValue(null);
|
||||||
|
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow auto registering by default', async () => {
|
||||||
|
configServiceMock.get.mockImplementation(mockConfig({ OAUTH_ENABLED: true }));
|
||||||
|
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
|
||||||
|
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
|
||||||
|
jest.spyOn(sut['logger'], 'log').mockImplementation(() => null);
|
||||||
|
userRepositoryMock.getByEmail.mockResolvedValue(null);
|
||||||
|
userRepositoryMock.create.mockResolvedValue(user);
|
||||||
|
immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
|
||||||
|
|
||||||
|
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
|
||||||
|
|
||||||
|
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
|
expect(userRepositoryMock.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(immichJwtServiceMock.createLoginResponse).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLogoutEndpoint', () => {
|
||||||
|
it('should return null if OAuth is not configured', async () => {
|
||||||
|
await expect(sut.getLogoutEndpoint()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get the session endpoint from the discovery document', async () => {
|
||||||
|
configServiceMock.get.mockImplementation(
|
||||||
|
mockConfig({
|
||||||
|
OAUTH_ENABLED: true,
|
||||||
|
OAUTH_ISSUER_URL: 'http://issuer',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
|
||||||
|
|
||||||
|
await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
108
server/apps/immich/src/api-v1/oauth/oauth.service.ts
Normal file
108
server/apps/immich/src/api-v1/oauth/oauth.service.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { ClientMetadata, generators, Issuer, UserinfoResponse } from 'openid-client';
|
||||||
|
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||||
|
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
|
||||||
|
import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
|
||||||
|
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
|
||||||
|
import { OAuthConfigDto } from './dto/oauth-config.dto';
|
||||||
|
import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto';
|
||||||
|
|
||||||
|
type OAuthProfile = UserinfoResponse & {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OAuthService {
|
||||||
|
private readonly logger = new Logger(OAuthService.name);
|
||||||
|
|
||||||
|
private readonly enabled: boolean;
|
||||||
|
private readonly autoRegister: boolean;
|
||||||
|
private readonly buttonText: string;
|
||||||
|
private readonly issuerUrl: string;
|
||||||
|
private readonly clientMetadata: ClientMetadata;
|
||||||
|
private readonly scope: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private immichJwtService: ImmichJwtService,
|
||||||
|
configService: ConfigService,
|
||||||
|
@Inject(USER_REPOSITORY) private userRepository: IUserRepository,
|
||||||
|
) {
|
||||||
|
this.enabled = configService.get('OAUTH_ENABLED', false);
|
||||||
|
this.autoRegister = configService.get('OAUTH_AUTO_REGISTER', true);
|
||||||
|
this.issuerUrl = configService.get<string>('OAUTH_ISSUER_URL', '');
|
||||||
|
this.scope = configService.get<string>('OAUTH_SCOPE', '');
|
||||||
|
this.buttonText = configService.get<string>('OAUTH_BUTTON_TEXT', '');
|
||||||
|
|
||||||
|
this.clientMetadata = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
client_id: configService.get('OAUTH_CLIENT_ID')!,
|
||||||
|
client_secret: configService.get('OAUTH_CLIENT_SECRET'),
|
||||||
|
response_types: ['code'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return { enabled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = (await this.getClient()).authorizationUrl({
|
||||||
|
redirect_uri: dto.redirectUri,
|
||||||
|
scope: this.scope,
|
||||||
|
state: generators.state(),
|
||||||
|
});
|
||||||
|
return { enabled: true, buttonText: this.buttonText, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callback(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
|
||||||
|
const redirectUri = dto.url.split('?')[0];
|
||||||
|
const client = await this.getClient();
|
||||||
|
const params = client.callbackParams(dto.url);
|
||||||
|
const tokens = await client.callback(redirectUri, params, { state: params.state });
|
||||||
|
const profile = await client.userinfo<OAuthProfile>(tokens.access_token || '');
|
||||||
|
|
||||||
|
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||||
|
let user = await this.userRepository.getByEmail(profile.email);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
if (!this.autoRegister) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Unable to register ${profile.email}. To enable auto registering, set OAUTH_AUTO_REGISTER=true.`,
|
||||||
|
);
|
||||||
|
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Registering new user: ${profile.email}`);
|
||||||
|
user = await this.userRepository.create({
|
||||||
|
firstName: profile.given_name || '',
|
||||||
|
lastName: profile.family_name || '',
|
||||||
|
email: profile.email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.immichJwtService.createLoginResponse(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLogoutEndpoint(): Promise<string | null> {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getClient() {
|
||||||
|
if (!this.enabled) {
|
||||||
|
throw new BadRequestException('OAuth2 is not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuer = await Issuer.discover(this.issuerUrl);
|
||||||
|
const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
|
||||||
|
const metadata = { ...this.clientMetadata };
|
||||||
|
if (algorithms[0] === 'HS256') {
|
||||||
|
metadata.id_token_signed_response_alg = algorithms[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new issuer.Client(metadata);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
import { ApiResponseProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class OAuthConfigResponseDto {
|
||||||
|
@ApiResponseProperty()
|
||||||
|
enabled!: boolean;
|
||||||
|
|
||||||
|
@ApiResponseProperty()
|
||||||
|
url?: string;
|
||||||
|
|
||||||
|
@ApiResponseProperty()
|
||||||
|
buttonText?: string;
|
||||||
|
}
|
@ -1,18 +1,16 @@
|
|||||||
import { UserEntity } from '@app/database/entities/user.entity';
|
import { UserEntity } from '@app/database/entities/user.entity';
|
||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Not, Repository } from 'typeorm';
|
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { Not, Repository } from 'typeorm';
|
||||||
|
|
||||||
export interface IUserRepository {
|
export interface IUserRepository {
|
||||||
get(userId: string, withDeleted?: boolean): Promise<UserEntity | null>;
|
get(id: string, withDeleted?: boolean): Promise<UserEntity | null>;
|
||||||
getByEmail(email: string): Promise<UserEntity | null>;
|
getAdmin(): Promise<UserEntity | null>;
|
||||||
|
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>;
|
||||||
getList(filter?: { excludeId?: string }): Promise<UserEntity[]>;
|
getList(filter?: { excludeId?: string }): Promise<UserEntity[]>;
|
||||||
create(createUserDto: CreateUserDto): Promise<UserEntity>;
|
create(user: Partial<UserEntity>): Promise<UserEntity>;
|
||||||
update(user: UserEntity, updateUserDto: UpdateUserDto): Promise<UserEntity>;
|
update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
|
||||||
createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity>;
|
|
||||||
delete(user: UserEntity): Promise<UserEntity>;
|
delete(user: UserEntity): Promise<UserEntity>;
|
||||||
restore(user: UserEntity): Promise<UserEntity>;
|
restore(user: UserEntity): Promise<UserEntity>;
|
||||||
}
|
}
|
||||||
@ -25,25 +23,29 @@ export class UserRepository implements IUserRepository {
|
|||||||
private userRepository: Repository<UserEntity>,
|
private userRepository: Repository<UserEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async hashPassword(password: string, salt: string): Promise<string> {
|
public async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
|
||||||
return bcrypt.hash(password, salt);
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
|
|
||||||
return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted });
|
return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByEmail(email: string): Promise<UserEntity | null> {
|
public async getAdmin(): Promise<UserEntity | null> {
|
||||||
return this.userRepository.findOne({ where: { email } });
|
return this.userRepository.findOne({ where: { isAdmin: true } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO add DTO for filtering
|
public async getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null> {
|
||||||
async getList({ excludeId }: { excludeId?: string } = {}): Promise<UserEntity[]> {
|
let builder = this.userRepository.createQueryBuilder('user').where({ email });
|
||||||
|
|
||||||
|
if (withPassword) {
|
||||||
|
builder = builder.addSelect('user.password');
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.getOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getList({ excludeId }: { excludeId?: string } = {}): Promise<UserEntity[]> {
|
||||||
if (!excludeId) {
|
if (!excludeId) {
|
||||||
return this.userRepository.find(); // TODO: this should also be ordered the same as below
|
return this.userRepository.find(); // TODO: this should also be ordered the same as below
|
||||||
}
|
}
|
||||||
return this.userRepository
|
return this.userRepository.find({
|
||||||
.find({
|
|
||||||
where: { id: Not(excludeId) },
|
where: { id: Not(excludeId) },
|
||||||
withDeleted: true,
|
withDeleted: true,
|
||||||
order: {
|
order: {
|
||||||
@ -52,33 +54,27 @@ export class UserRepository implements IUserRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(createUserDto: CreateUserDto): Promise<UserEntity> {
|
public async create(user: Partial<UserEntity>): Promise<UserEntity> {
|
||||||
const newUser = new UserEntity();
|
if (user.password) {
|
||||||
newUser.email = createUserDto.email;
|
user.salt = await bcrypt.genSalt();
|
||||||
newUser.salt = await bcrypt.genSalt();
|
user.password = await this.hashPassword(user.password, user.salt);
|
||||||
newUser.password = await this.hashPassword(createUserDto.password, newUser.salt);
|
}
|
||||||
newUser.firstName = createUserDto.firstName;
|
user.isAdmin = false;
|
||||||
newUser.lastName = createUserDto.lastName;
|
|
||||||
newUser.isAdmin = false;
|
|
||||||
|
|
||||||
return this.userRepository.save(newUser);
|
return this.userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(user: UserEntity, updateUserDto: UpdateUserDto): Promise<UserEntity> {
|
public async update(id: string, user: Partial<UserEntity>): Promise<UserEntity> {
|
||||||
user.lastName = updateUserDto.lastName || user.lastName;
|
user.id = id;
|
||||||
user.firstName = updateUserDto.firstName || user.firstName;
|
|
||||||
user.profileImagePath = updateUserDto.profileImagePath || user.profileImagePath;
|
|
||||||
user.shouldChangePassword =
|
|
||||||
updateUserDto.shouldChangePassword != undefined ? updateUserDto.shouldChangePassword : user.shouldChangePassword;
|
|
||||||
|
|
||||||
// If payload includes password - Create new password for user
|
// If payload includes password - Create new password for user
|
||||||
if (updateUserDto.password) {
|
if (user.password) {
|
||||||
user.salt = await bcrypt.genSalt();
|
user.salt = await bcrypt.genSalt();
|
||||||
user.password = await this.hashPassword(updateUserDto.password, user.salt);
|
user.password = await this.hashPassword(user.password, user.salt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: can this happen? If so we can move it to the service, otherwise remove it (also from DTO)
|
// TODO: can this happen? If so we can move it to the service, otherwise remove it (also from DTO)
|
||||||
if (updateUserDto.isAdmin) {
|
if (user.isAdmin) {
|
||||||
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
|
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
|
||||||
|
|
||||||
if (adminUser) {
|
if (adminUser) {
|
||||||
@ -91,19 +87,18 @@ export class UserRepository implements IUserRepository {
|
|||||||
return this.userRepository.save(user);
|
return this.userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(user: UserEntity): Promise<UserEntity> {
|
public async delete(user: UserEntity): Promise<UserEntity> {
|
||||||
if (user.isAdmin) {
|
if (user.isAdmin) {
|
||||||
throw new BadRequestException('Cannot delete admin user! stay sane!');
|
throw new BadRequestException('Cannot delete admin user! stay sane!');
|
||||||
}
|
}
|
||||||
return this.userRepository.softRemove(user);
|
return this.userRepository.softRemove(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async restore(user: UserEntity): Promise<UserEntity> {
|
public async restore(user: UserEntity): Promise<UserEntity> {
|
||||||
return this.userRepository.recover(user);
|
return this.userRepository.recover(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity> {
|
private async hashPassword(password: string, salt: string): Promise<string> {
|
||||||
user.profileImagePath = fileInfo.path;
|
return bcrypt.hash(password, salt);
|
||||||
return this.userRepository.save(user);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,24 +1,23 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { UserService } from './user.service';
|
|
||||||
import { UserController } from './user.controller';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { UserEntity } from '@app/database/entities/user.entity';
|
import { UserEntity } from '@app/database/entities/user.entity';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { jwtConfig } from '../../config/jwt.config';
|
||||||
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
|
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
|
||||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
|
||||||
import { jwtConfig } from '../../config/jwt.config';
|
|
||||||
import { UserRepository, USER_REPOSITORY } from './user-repository';
|
import { UserRepository, USER_REPOSITORY } from './user-repository';
|
||||||
|
import { UserController } from './user.controller';
|
||||||
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
|
const USER_REPOSITORY_PROVIDER = {
|
||||||
|
provide: USER_REPOSITORY,
|
||||||
|
useClass: UserRepository,
|
||||||
|
};
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
|
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [
|
providers: [UserService, ImmichJwtService, USER_REPOSITORY_PROVIDER],
|
||||||
UserService,
|
exports: [USER_REPOSITORY_PROVIDER],
|
||||||
ImmichJwtService,
|
|
||||||
{
|
|
||||||
provide: USER_REPOSITORY,
|
|
||||||
useClass: UserRepository,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class UserModule {}
|
export class UserModule {}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { UserEntity } from '@app/database/entities/user.entity';
|
import { UserEntity } from '@app/database/entities/user.entity';
|
||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { newUserRepositoryMock } from '../../../test/test-utils';
|
||||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||||
import { IUserRepository } from './user-repository';
|
import { IUserRepository } from './user-repository';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
@ -58,16 +59,7 @@ describe('UserService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
userRepositoryMock = {
|
userRepositoryMock = newUserRepositoryMock();
|
||||||
create: jest.fn(),
|
|
||||||
createProfileImage: jest.fn(),
|
|
||||||
get: jest.fn(),
|
|
||||||
getByEmail: jest.fn(),
|
|
||||||
getList: jest.fn(),
|
|
||||||
update: jest.fn(),
|
|
||||||
delete: jest.fn(),
|
|
||||||
restore: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
sui = new UserService(userRepositoryMock);
|
sui = new UserService(userRepositoryMock);
|
||||||
});
|
});
|
||||||
|
@ -9,17 +9,17 @@ import {
|
|||||||
StreamableFile,
|
StreamableFile,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Response as Res } from 'express';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
import { createReadStream } from 'fs';
|
|
||||||
import { Response as Res } from 'express';
|
|
||||||
import { mapUser, UserResponseDto } from './response-dto/user-response.dto';
|
|
||||||
import { mapUserCountResponse, UserCountResponseDto } from './response-dto/user-count-response.dto';
|
|
||||||
import {
|
import {
|
||||||
CreateProfileImageResponseDto,
|
CreateProfileImageResponseDto,
|
||||||
mapCreateProfileImageResponse,
|
mapCreateProfileImageResponse,
|
||||||
} from './response-dto/create-profile-image-response.dto';
|
} from './response-dto/create-profile-image-response.dto';
|
||||||
|
import { mapUserCountResponse, UserCountResponseDto } from './response-dto/user-count-response.dto';
|
||||||
|
import { mapUser, UserResponseDto } from './response-dto/user-response.dto';
|
||||||
import { IUserRepository, USER_REPOSITORY } from './user-repository';
|
import { IUserRepository, USER_REPOSITORY } from './user-repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -98,7 +98,7 @@ export class UserService {
|
|||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const updatedUser = await this.userRepository.update(user, updateUserDto);
|
const updatedUser = await this.userRepository.update(user.id, updateUserDto);
|
||||||
|
|
||||||
return mapUser(updatedUser);
|
return mapUser(updatedUser);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -159,7 +159,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.userRepository.createProfileImage(user, fileInfo);
|
await this.userRepository.update(user.id, { profileImagePath: fileInfo.path });
|
||||||
|
|
||||||
return mapCreateProfileImageResponse(authUser.id, fileInfo.path);
|
return mapCreateProfileImageResponse(authUser.id, fileInfo.path);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -16,6 +16,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
|
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
|
||||||
import { DatabaseModule } from '@app/database';
|
import { DatabaseModule } from '@app/database';
|
||||||
import { JobModule } from './api-v1/job/job.module';
|
import { JobModule } from './api-v1/job/job.module';
|
||||||
|
import { OAuthModule } from './api-v1/oauth/oauth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -27,6 +28,7 @@ import { JobModule } from './api-v1/job/job.module';
|
|||||||
AssetModule,
|
AssetModule,
|
||||||
|
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
OAuthModule,
|
||||||
|
|
||||||
ImmichJwtModule,
|
ImmichJwtModule,
|
||||||
|
|
||||||
|
@ -1 +1,7 @@
|
|||||||
export const jwtSecret = process.env.JWT_SECRET;
|
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 {
|
||||||
|
PASSWORD = 'password',
|
||||||
|
OAUTH = 'oauth',
|
||||||
|
}
|
||||||
|
@ -1,53 +1,96 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
|
import { UserEntity } from '../../../../../libs/database/src/entities/user.entity';
|
||||||
|
import { LoginResponseDto } from '../../api-v1/auth/response-dto/login-response.dto';
|
||||||
|
import { AuthType } from '../../constants/jwt.constant';
|
||||||
import { ImmichJwtService } from './immich-jwt.service';
|
import { ImmichJwtService } from './immich-jwt.service';
|
||||||
|
|
||||||
describe('ImmichJwtService', () => {
|
describe('ImmichJwtService', () => {
|
||||||
let jwtService: JwtService;
|
let jwtServiceMock: jest.Mocked<JwtService>;
|
||||||
let service: ImmichJwtService;
|
let sut: ImmichJwtService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jwtService = new JwtService();
|
jwtServiceMock = {
|
||||||
service = new ImmichJwtService(jwtService);
|
sign: jest.fn(),
|
||||||
|
verifyAsync: jest.fn(),
|
||||||
|
} as unknown as jest.Mocked<JwtService>;
|
||||||
|
|
||||||
|
sut = new ImmichJwtService(jwtServiceMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generateToken', () => {
|
describe('getCookieNames', () => {
|
||||||
it('should generate the token', async () => {
|
it('should return the cookie names', async () => {
|
||||||
const spy = jest.spyOn(jwtService, 'sign');
|
expect(sut.getCookieNames()).toEqual(['immich_access_token', 'immich_auth_type']);
|
||||||
spy.mockImplementation((value) => value as string);
|
});
|
||||||
const dto = { userId: 'test-user', email: 'test-user@immich.com' };
|
});
|
||||||
const token = await service.generateToken(dto);
|
|
||||||
expect(token).toEqual(dto);
|
describe('getCookies', () => {
|
||||||
|
it('should generate the cookie headers', async () => {
|
||||||
|
jwtServiceMock.sign.mockImplementation((value) => value as string);
|
||||||
|
const dto = { accessToken: 'test-user@immich.com', userId: 'test-user' };
|
||||||
|
const cookies = await sut.getCookies(dto as LoginResponseDto, AuthType.PASSWORD);
|
||||||
|
expect(cookies).toEqual([
|
||||||
|
'immich_access_token=test-user@immich.com; HttpOnly; Path=/; Max-Age=604800',
|
||||||
|
'immich_auth_type=password; Path=/; Max-Age=604800',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createLoginResponse', () => {
|
||||||
|
it('should create the login response', async () => {
|
||||||
|
jwtServiceMock.sign.mockReturnValue('fancy-token');
|
||||||
|
const user: UserEntity = {
|
||||||
|
id: 'user',
|
||||||
|
firstName: 'immich',
|
||||||
|
lastName: 'user',
|
||||||
|
isAdmin: false,
|
||||||
|
email: 'test@immich.com',
|
||||||
|
password: 'changeme',
|
||||||
|
salt: '123',
|
||||||
|
profileImagePath: '',
|
||||||
|
shouldChangePassword: false,
|
||||||
|
createdAt: 'today',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dto: LoginResponseDto = {
|
||||||
|
accessToken: 'fancy-token',
|
||||||
|
firstName: 'immich',
|
||||||
|
isAdmin: false,
|
||||||
|
lastName: 'user',
|
||||||
|
profileImagePath: '',
|
||||||
|
shouldChangePassword: false,
|
||||||
|
userEmail: 'test@immich.com',
|
||||||
|
userId: 'user',
|
||||||
|
};
|
||||||
|
await expect(sut.createLoginResponse(user)).resolves.toEqual(dto);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validateToken', () => {
|
describe('validateToken', () => {
|
||||||
it('should validate the token', async () => {
|
it('should validate the token', async () => {
|
||||||
const dto = { userId: 'test-user', email: 'test-user@immich.com' };
|
const dto = { userId: 'test-user', email: 'test-user@immich.com' };
|
||||||
const spy = jest.spyOn(jwtService, 'verifyAsync');
|
jwtServiceMock.verifyAsync.mockImplementation(() => dto as any);
|
||||||
spy.mockImplementation(() => dto as any);
|
const response = await sut.validateToken('access-token');
|
||||||
const response = await service.validateToken('access-token');
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
expect(jwtServiceMock.verifyAsync).toHaveBeenCalledTimes(1);
|
||||||
expect(response).toEqual({ userId: 'test-user', status: true });
|
expect(response).toEqual({ userId: 'test-user', status: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle an invalid token', async () => {
|
it('should handle an invalid token', async () => {
|
||||||
const verifyAsync = jest.spyOn(jwtService, 'verifyAsync');
|
jwtServiceMock.verifyAsync.mockImplementation(() => {
|
||||||
verifyAsync.mockImplementation(() => {
|
|
||||||
throw new Error('Invalid token!');
|
throw new Error('Invalid token!');
|
||||||
});
|
});
|
||||||
|
|
||||||
const error = jest.spyOn(Logger, 'error');
|
const error = jest.spyOn(Logger, 'error');
|
||||||
error.mockImplementation(() => null);
|
error.mockImplementation(() => null);
|
||||||
const response = await service.validateToken('access-token');
|
const response = await sut.validateToken('access-token');
|
||||||
|
|
||||||
expect(verifyAsync).toHaveBeenCalledTimes(1);
|
expect(jwtServiceMock.verifyAsync).toHaveBeenCalledTimes(1);
|
||||||
expect(error).toHaveBeenCalledTimes(1);
|
expect(error).toHaveBeenCalledTimes(1);
|
||||||
expect(response).toEqual({ userId: null, status: false });
|
expect(response).toEqual({ userId: null, status: false });
|
||||||
});
|
});
|
||||||
@ -58,7 +101,7 @@ describe('ImmichJwtService', () => {
|
|||||||
const request = {
|
const request = {
|
||||||
headers: {},
|
headers: {},
|
||||||
} as Request;
|
} as Request;
|
||||||
const token = service.extractJwtFromHeader(request);
|
const token = sut.extractJwtFromHeader(request);
|
||||||
expect(token).toBe(null);
|
expect(token).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -75,15 +118,15 @@ describe('ImmichJwtService', () => {
|
|||||||
},
|
},
|
||||||
} as Request;
|
} as Request;
|
||||||
|
|
||||||
expect(service.extractJwtFromHeader(upper)).toBe('token');
|
expect(sut.extractJwtFromHeader(upper)).toBe('token');
|
||||||
expect(service.extractJwtFromHeader(lower)).toBe('token');
|
expect(sut.extractJwtFromHeader(lower)).toBe('token');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('extracJwtFromCookie', () => {
|
describe('extracJwtFromCookie', () => {
|
||||||
it('should handle no cookie', () => {
|
it('should handle no cookie', () => {
|
||||||
const request = {} as Request;
|
const request = {} as Request;
|
||||||
const token = service.extractJwtFromCookie(request);
|
const token = sut.extractJwtFromCookie(request);
|
||||||
expect(token).toBe(null);
|
expect(token).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -93,7 +136,7 @@ describe('ImmichJwtService', () => {
|
|||||||
immich_access_token: 'cookie',
|
immich_access_token: 'cookie',
|
||||||
},
|
},
|
||||||
} as Request;
|
} as Request;
|
||||||
const token = service.extractJwtFromCookie(request);
|
const token = sut.extractJwtFromCookie(request);
|
||||||
expect(token).toBe('cookie');
|
expect(token).toBe('cookie');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
|
import { UserEntity } from '@app/database/entities/user.entity';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { JwtPayloadDto } from '../../api-v1/auth/dto/jwt-payload.dto';
|
import { JwtPayloadDto } from '../../api-v1/auth/dto/jwt-payload.dto';
|
||||||
import { jwtSecret } from '../../constants/jwt.constant';
|
import { LoginResponseDto, mapLoginResponse } from '../../api-v1/auth/response-dto/login-response.dto';
|
||||||
|
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE, jwtSecret } from '../../constants/jwt.constant';
|
||||||
|
|
||||||
export type JwtValidationResult = {
|
export type JwtValidationResult = {
|
||||||
status: boolean;
|
status: boolean;
|
||||||
@ -13,10 +15,24 @@ export type JwtValidationResult = {
|
|||||||
export class ImmichJwtService {
|
export class ImmichJwtService {
|
||||||
constructor(private jwtService: JwtService) {}
|
constructor(private jwtService: JwtService) {}
|
||||||
|
|
||||||
public async generateToken(payload: JwtPayloadDto) {
|
public getCookieNames() {
|
||||||
return this.jwtService.sign({
|
return [IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE];
|
||||||
...payload,
|
}
|
||||||
});
|
|
||||||
|
public getCookies(loginResponse: LoginResponseDto, authType: AuthType) {
|
||||||
|
const maxAge = 7 * 24 * 3600; // 7 days
|
||||||
|
|
||||||
|
const accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}`;
|
||||||
|
const authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; Path=/; Max-Age=${maxAge}`;
|
||||||
|
|
||||||
|
return [accessTokenCookie, authTypeCookie];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createLoginResponse(user: UserEntity): Promise<LoginResponseDto> {
|
||||||
|
const payload = new JwtPayloadDto(user.id, user.email);
|
||||||
|
const accessToken = await this.generateToken(payload);
|
||||||
|
|
||||||
|
return mapLoginResponse(user, accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async validateToken(accessToken: string): Promise<JwtValidationResult> {
|
public async validateToken(accessToken: string): Promise<JwtValidationResult> {
|
||||||
@ -48,10 +64,12 @@ export class ImmichJwtService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public extractJwtFromCookie(req: Request) {
|
public extractJwtFromCookie(req: Request) {
|
||||||
if (req.cookies?.immich_access_token) {
|
return req.cookies?.[IMMICH_ACCESS_COOKIE] || null;
|
||||||
return req.cookies.immich_access_token;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
private async generateToken(payload: JwtPayloadDto) {
|
||||||
|
return this.jwtService.sign({
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
import { TestingModuleBuilder } from '@nestjs/testing';
|
import { TestingModuleBuilder } from '@nestjs/testing';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { IUserRepository } from '../src/api-v1/user/user-repository';
|
||||||
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
||||||
import { JwtAuthGuard } from '../src/modules/immich-jwt/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../src/modules/immich-jwt/guards/jwt-auth.guard';
|
||||||
|
|
||||||
@ -14,6 +15,19 @@ export async function clearDb(db: DataSource) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function newUserRepositoryMock(): jest.Mocked<IUserRepository> {
|
||||||
|
return {
|
||||||
|
get: jest.fn(),
|
||||||
|
getAdmin: jest.fn(),
|
||||||
|
getByEmail: jest.fn(),
|
||||||
|
getList: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
restore: jest.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getAuthUser(): AuthUserDto {
|
export function getAuthUser(): AuthUserDto {
|
||||||
return {
|
return {
|
||||||
id: '3108ac14-8afb-4b7e-87fd-39ebb6b79750',
|
id: '3108ac14-8afb-4b7e-87fd-39ebb6b79750',
|
||||||
|
File diff suppressed because one or more lines are too long
@ -16,6 +16,12 @@ const jwtSecretValidator: Joi.CustomValidator<string> = (value) => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WHEN_OAUTH_ENABLED = Joi.when('OAUTH_ENABLED', {
|
||||||
|
is: true,
|
||||||
|
then: Joi.string().required(),
|
||||||
|
otherwise: Joi.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const immichAppConfig: ConfigModuleOptions = {
|
export const immichAppConfig: ConfigModuleOptions = {
|
||||||
envFilePath: '.env',
|
envFilePath: '.env',
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
@ -28,5 +34,12 @@ export const immichAppConfig: ConfigModuleOptions = {
|
|||||||
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
|
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
|
||||||
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3),
|
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3),
|
||||||
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
|
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
|
||||||
|
OAUTH_ENABLED: Joi.bool().valid(true, false).default(false),
|
||||||
|
OAUTH_BUTTON_TEXT: Joi.string().optional().default('Login with OAuth'),
|
||||||
|
OAUTH_AUTO_REGISTER: Joi.bool().valid(true, false).default(true),
|
||||||
|
OAUTH_ISSUER_URL: WHEN_OAUTH_ENABLED,
|
||||||
|
OAUTH_SCOPE: Joi.string().optional().default('openid email profile'),
|
||||||
|
OAUTH_CLIENT_ID: WHEN_OAUTH_ENABLED,
|
||||||
|
OAUTH_CLIENT_SECRET: WHEN_OAUTH_ENABLED,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, DeleteDateColumn } from 'typeorm';
|
import { Column, CreateDateColumn, DeleteDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
@Entity('users')
|
@Entity('users')
|
||||||
export class UserEntity {
|
export class UserEntity {
|
||||||
@ -17,10 +17,10 @@ export class UserEntity {
|
|||||||
@Column()
|
@Column()
|
||||||
email!: string;
|
email!: string;
|
||||||
|
|
||||||
@Column({ select: false })
|
@Column({ default: '', select: false })
|
||||||
password?: string;
|
password?: string;
|
||||||
|
|
||||||
@Column({ select: false })
|
@Column({ default: '', select: false })
|
||||||
salt?: string;
|
salt?: string;
|
||||||
|
|
||||||
@Column({ default: '' })
|
@Column({ default: '' })
|
||||||
|
67
server/package-lock.json
generated
67
server/package-lock.json
generated
@ -41,6 +41,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"luxon": "^3.0.3",
|
"luxon": "^3.0.3",
|
||||||
"nest-commander": "^3.3.0",
|
"nest-commander": "^3.3.0",
|
||||||
|
"openid-client": "^5.2.1",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
"passport-jwt": "^4.0.0",
|
"passport-jwt": "^4.0.0",
|
||||||
"pg": "^8.7.1",
|
"pg": "^8.7.1",
|
||||||
@ -7449,6 +7450,14 @@
|
|||||||
"@sideway/pinpoint": "^2.0.0"
|
"@sideway/pinpoint": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "4.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.10.3.tgz",
|
||||||
|
"integrity": "sha512-3S4wQnaoJKSAx9uHSoyf8B/lxjs1qCntHWL6wNFszJazo+FtWe+qD0zVfY0BlqJ5HHK4jcnM98k3BQzVLbzE4g==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@ -8439,6 +8448,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oidc-token-hash": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || >=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@ -8472,6 +8489,28 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-KPxqWnxobG/70Cxqyvd43RWfCfHedFnCdHSBpw5f7WnTnuBAeBnvot/BIo+brrcTr0wyAYUlL/qejQSGwWtdIg==",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^4.10.0",
|
||||||
|
"lru-cache": "^6.0.0",
|
||||||
|
"object-hash": "^2.0.1",
|
||||||
|
"oidc-token-hash": "^5.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/object-hash": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.1",
|
"version": "0.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
|
||||||
@ -17131,6 +17170,11 @@
|
|||||||
"@sideway/pinpoint": "^2.0.0"
|
"@sideway/pinpoint": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"jose": {
|
||||||
|
"version": "4.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.10.3.tgz",
|
||||||
|
"integrity": "sha512-3S4wQnaoJKSAx9uHSoyf8B/lxjs1qCntHWL6wNFszJazo+FtWe+qD0zVfY0BlqJ5HHK4jcnM98k3BQzVLbzE4g=="
|
||||||
|
},
|
||||||
"js-tokens": {
|
"js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@ -17939,6 +17983,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
|
||||||
"integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g=="
|
"integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g=="
|
||||||
},
|
},
|
||||||
|
"oidc-token-hash": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ=="
|
||||||
|
},
|
||||||
"on-finished": {
|
"on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@ -17963,6 +18012,24 @@
|
|||||||
"mimic-fn": "^2.1.0"
|
"mimic-fn": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"openid-client": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-KPxqWnxobG/70Cxqyvd43RWfCfHedFnCdHSBpw5f7WnTnuBAeBnvot/BIo+brrcTr0wyAYUlL/qejQSGwWtdIg==",
|
||||||
|
"requires": {
|
||||||
|
"jose": "^4.10.0",
|
||||||
|
"lru-cache": "^6.0.0",
|
||||||
|
"object-hash": "^2.0.1",
|
||||||
|
"oidc-token-hash": "^5.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"object-hash": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"optionator": {
|
"optionator": {
|
||||||
"version": "0.9.1",
|
"version": "0.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
|
||||||
|
@ -62,6 +62,7 @@
|
|||||||
"local-reverse-geocoder": "^0.12.5",
|
"local-reverse-geocoder": "^0.12.5",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"luxon": "^3.0.3",
|
"luxon": "^3.0.3",
|
||||||
|
"openid-client": "^5.2.1",
|
||||||
"nest-commander": "^3.3.0",
|
"nest-commander": "^3.3.0",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
"passport-jwt": "^4.0.0",
|
"passport-jwt": "^4.0.0",
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
Configuration,
|
Configuration,
|
||||||
DeviceInfoApi,
|
DeviceInfoApi,
|
||||||
JobApi,
|
JobApi,
|
||||||
|
OAuthApi,
|
||||||
ServerInfoApi,
|
ServerInfoApi,
|
||||||
UserApi
|
UserApi
|
||||||
} from './open-api';
|
} from './open-api';
|
||||||
@ -15,6 +16,7 @@ class ImmichApi {
|
|||||||
public albumApi: AlbumApi;
|
public albumApi: AlbumApi;
|
||||||
public assetApi: AssetApi;
|
public assetApi: AssetApi;
|
||||||
public authenticationApi: AuthenticationApi;
|
public authenticationApi: AuthenticationApi;
|
||||||
|
public oauthApi: OAuthApi;
|
||||||
public deviceInfoApi: DeviceInfoApi;
|
public deviceInfoApi: DeviceInfoApi;
|
||||||
public serverInfoApi: ServerInfoApi;
|
public serverInfoApi: ServerInfoApi;
|
||||||
public jobApi: JobApi;
|
public jobApi: JobApi;
|
||||||
@ -26,6 +28,7 @@ class ImmichApi {
|
|||||||
this.albumApi = new AlbumApi(this.config);
|
this.albumApi = new AlbumApi(this.config);
|
||||||
this.assetApi = new AssetApi(this.config);
|
this.assetApi = new AssetApi(this.config);
|
||||||
this.authenticationApi = new AuthenticationApi(this.config);
|
this.authenticationApi = new AuthenticationApi(this.config);
|
||||||
|
this.oauthApi = new OAuthApi(this.config);
|
||||||
this.deviceInfoApi = new DeviceInfoApi(this.config);
|
this.deviceInfoApi = new DeviceInfoApi(this.config);
|
||||||
this.serverInfoApi = new ServerInfoApi(this.config);
|
this.serverInfoApi = new ServerInfoApi(this.config);
|
||||||
this.jobApi = new JobApi(this.config);
|
this.jobApi = new JobApi(this.config);
|
||||||
|
@ -1125,6 +1125,63 @@ export interface LogoutResponseDto {
|
|||||||
* @memberof LogoutResponseDto
|
* @memberof LogoutResponseDto
|
||||||
*/
|
*/
|
||||||
'successful': boolean;
|
'successful': boolean;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof LogoutResponseDto
|
||||||
|
*/
|
||||||
|
'redirectUri': string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @interface OAuthCallbackDto
|
||||||
|
*/
|
||||||
|
export interface OAuthCallbackDto {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof OAuthCallbackDto
|
||||||
|
*/
|
||||||
|
'url': string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @interface OAuthConfigDto
|
||||||
|
*/
|
||||||
|
export interface OAuthConfigDto {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof OAuthConfigDto
|
||||||
|
*/
|
||||||
|
'redirectUri': string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @interface OAuthConfigResponseDto
|
||||||
|
*/
|
||||||
|
export interface OAuthConfigResponseDto {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof OAuthConfigResponseDto
|
||||||
|
*/
|
||||||
|
'enabled': boolean;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof OAuthConfigResponseDto
|
||||||
|
*/
|
||||||
|
'url'?: string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof OAuthConfigResponseDto
|
||||||
|
*/
|
||||||
|
'buttonText'?: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -4459,6 +4516,174 @@ export class JobApi extends BaseAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuthApi - axios parameter creator
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const OAuthApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {OAuthCallbackDto} oAuthCallbackDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
callback: async (oAuthCallbackDto: OAuthCallbackDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'oAuthCallbackDto' is not null or undefined
|
||||||
|
assertParamExists('callback', 'oAuthCallbackDto', oAuthCallbackDto)
|
||||||
|
const localVarPath = `/oauth/callback`;
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
localVarRequestOptions.data = serializeDataIfNeeded(oAuthCallbackDto, localVarRequestOptions, configuration)
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {OAuthConfigDto} oAuthConfigDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
generateConfig: async (oAuthConfigDto: OAuthConfigDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'oAuthConfigDto' is not null or undefined
|
||||||
|
assertParamExists('generateConfig', 'oAuthConfigDto', oAuthConfigDto)
|
||||||
|
const localVarPath = `/oauth/config`;
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
localVarRequestOptions.data = serializeDataIfNeeded(oAuthConfigDto, localVarRequestOptions, configuration)
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuthApi - functional programming interface
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const OAuthApiFp = function(configuration?: Configuration) {
|
||||||
|
const localVarAxiosParamCreator = OAuthApiAxiosParamCreator(configuration)
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {OAuthCallbackDto} oAuthCallbackDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async callback(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<LoginResponseDto>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.callback(oAuthCallbackDto, options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {OAuthConfigDto} oAuthConfigDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<OAuthConfigResponseDto>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.generateConfig(oAuthConfigDto, options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuthApi - factory interface
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const OAuthApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
|
||||||
|
const localVarFp = OAuthApiFp(configuration)
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {OAuthCallbackDto} oAuthCallbackDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
callback(oAuthCallbackDto: OAuthCallbackDto, options?: any): AxiosPromise<LoginResponseDto> {
|
||||||
|
return localVarFp.callback(oAuthCallbackDto, options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {OAuthConfigDto} oAuthConfigDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
generateConfig(oAuthConfigDto: OAuthConfigDto, options?: any): AxiosPromise<OAuthConfigResponseDto> {
|
||||||
|
return localVarFp.generateConfig(oAuthConfigDto, options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuthApi - object-oriented interface
|
||||||
|
* @export
|
||||||
|
* @class OAuthApi
|
||||||
|
* @extends {BaseAPI}
|
||||||
|
*/
|
||||||
|
export class OAuthApi extends BaseAPI {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {OAuthCallbackDto} oAuthCallbackDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof OAuthApi
|
||||||
|
*/
|
||||||
|
public callback(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig) {
|
||||||
|
return OAuthApiFp(this.configuration).callback(oAuthCallbackDto, options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {OAuthConfigDto} oAuthConfigDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof OAuthApi
|
||||||
|
*/
|
||||||
|
public generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig) {
|
||||||
|
return OAuthApiFp(this.configuration).generateConfig(oAuthConfigDto, options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ServerInfoApi - axios parameter creator
|
* ServerInfoApi - axios parameter creator
|
||||||
* @export
|
* @export
|
||||||
|
@ -1,17 +1,49 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||||
import { loginPageMessage } from '$lib/constants';
|
import { loginPageMessage } from '$lib/constants';
|
||||||
import { api } from '@api';
|
import { api, OAuthConfigResponseDto } from '@api';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
|
|
||||||
let error: string;
|
let error: string;
|
||||||
let email = '';
|
let email = '';
|
||||||
let password = '';
|
let password = '';
|
||||||
|
let oauthError: string;
|
||||||
|
let oauthConfig: OAuthConfigResponseDto = { enabled: false };
|
||||||
|
let loading = true;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const search = window.location.search;
|
||||||
|
if (search.includes('code=') || search.includes('error=')) {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
await api.oauthApi.callback({ url: window.location.href });
|
||||||
|
dispatch('success');
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error [login-form] [oauth.callback]', e);
|
||||||
|
oauthError = 'Unable to complete OAuth login';
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redirectUri = window.location.href.split('?')[0];
|
||||||
|
console.log(`OAuth Redirect URI: ${redirectUri}`);
|
||||||
|
const { data } = await api.oauthApi.generateConfig({ redirectUri });
|
||||||
|
oauthConfig = data;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error [login-form] [oauth.generateConfig]', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
try {
|
try {
|
||||||
error = '';
|
error = '';
|
||||||
|
loading = true;
|
||||||
|
|
||||||
const { data } = await api.authenticationApi.login({
|
const { data } = await api.authenticationApi.login({
|
||||||
email,
|
email,
|
||||||
@ -27,6 +59,7 @@
|
|||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = 'Incorrect email or password';
|
error = 'Incorrect email or password';
|
||||||
|
loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -48,41 +81,65 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form on:submit|preventDefault={login} autocomplete="off">
|
{#if loading}
|
||||||
<div class="m-4 flex flex-col gap-2">
|
<div class="flex place-items-center place-content-center">
|
||||||
<label class="immich-form-label" for="email">Email</label>
|
<LoadingSpinner />
|
||||||
<input
|
|
||||||
class="immich-form-input"
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
bind:value={email}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form on:submit|preventDefault={login} autocomplete="off">
|
||||||
|
<div class="m-4 flex flex-col gap-2">
|
||||||
|
<label class="immich-form-label" for="email">Email</label>
|
||||||
|
<input
|
||||||
|
class="immich-form-input"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
bind:value={email}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="m-4 flex flex-col gap-2">
|
<div class="m-4 flex flex-col gap-2">
|
||||||
<label class="immich-form-label" for="password">Password</label>
|
<label class="immich-form-label" for="password">Password</label>
|
||||||
<input
|
<input
|
||||||
class="immich-form-input"
|
class="immich-form-input"
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="text-red-400 pl-4">{error}</p>
|
<p class="text-red-400 pl-4">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex w-full">
|
<div class="flex w-full">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="m-4 p-2 bg-immich-primary dark:bg-immich-dark-primary dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80 hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold"
|
disabled={loading}
|
||||||
>Login</button
|
class="m-4 p-2 bg-immich-primary dark:bg-immich-dark-primary dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80 hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold"
|
||||||
>
|
>Login</button
|
||||||
</div>
|
>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
|
{#if oauthConfig.enabled}
|
||||||
|
<div class="flex flex-col gap-4 px-4">
|
||||||
|
<hr />
|
||||||
|
{#if oauthError}
|
||||||
|
<p class="text-red-400">{oauthError}</p>
|
||||||
|
{/if}
|
||||||
|
<a href={oauthConfig.url} class="flex w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
class="bg-immich-primary dark:bg-immich-dark-primary dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80 hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold"
|
||||||
|
>{oauthConfig.buttonText || 'Login with OAuth'}</button
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
@ -38,8 +38,11 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logOut = async () => {
|
const logOut = async () => {
|
||||||
|
const { data } = await api.authenticationApi.logout();
|
||||||
|
|
||||||
await fetch('auth/logout', { method: 'POST' });
|
await fetch('auth/logout', { method: 'POST' });
|
||||||
goto('/auth/login');
|
|
||||||
|
goto(data.redirectUri || '/auth/login');
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ export const POST: RequestHandler = async () => {
|
|||||||
|
|
||||||
headers.append(
|
headers.append(
|
||||||
'set-cookie',
|
'set-cookie',
|
||||||
'immich_is_authenticated=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT;'
|
'immich_auth_type=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT;'
|
||||||
);
|
);
|
||||||
headers.append(
|
headers.append(
|
||||||
'set-cookie',
|
'set-cookie',
|
||||||
|
Loading…
Reference in New Issue
Block a user