1
0
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:
Jason Rasmussen 2022-11-14 21:24:25 -05:00 committed by GitHub
parent d476656789
commit d3c35ec9c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1230 additions and 250 deletions

68
docs/docs/usage/oauth.md Normal file
View 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/

View File

@ -46,6 +46,10 @@ doc/JobStatusResponseDto.md
doc/LoginCredentialDto.md
doc/LoginResponseDto.md
doc/LogoutResponseDto.md
doc/OAuthApi.md
doc/OAuthCallbackDto.md
doc/OAuthConfigDto.md
doc/OAuthConfigResponseDto.md
doc/RemoveAssetsDto.md
doc/SearchAssetDto.md
doc/ServerInfoApi.md
@ -73,6 +77,7 @@ lib/api/asset_api.dart
lib/api/authentication_api.dart
lib/api/device_info_api.dart
lib/api/job_api.dart
lib/api/o_auth_api.dart
lib/api/server_info_api.dart
lib/api/user_api.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_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/search_asset_dto.dart
lib/model/server_info_response_dto.dart

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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 { Request, Response } from 'express';
import { AuthType, IMMICH_AUTH_TYPE_COOKIE } from '../../constants/jwt.constant';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { AuthService } from './auth.service';
import { LoginCredentialDto } from './dto/login-credential.dto';
import { LoginResponseDto } from './response-dto/login-response.dto';
import { SignUpDto } from './dto/sign-up.dto';
import { AdminSignupResponseDto } from './response-dto/admin-signup-response.dto';
import { ValidateAccessTokenResponseDto } from './response-dto/validate-asset-token-response.dto,';
import { Response } from 'express';
import { LoginResponseDto } from './response-dto/login-response.dto';
import { LogoutResponseDto } from './response-dto/logout-response.dto';
import { ValidateAccessTokenResponseDto } from './response-dto/validate-asset-token-response.dto,';
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(private readonly authService: AuthService, private readonly immichJwtService: ImmichJwtService) {}
@Post('/login')
async login(
@Body(new ValidationPipe({ transform: true })) loginCredential: LoginCredentialDto,
@Ip() clientIp: string,
@Res() response: Response,
@Res({ passthrough: true }) response: Response,
): Promise<LoginResponseDto> {
const loginResponse = await this.authService.login(loginCredential, clientIp);
// 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);
response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.PASSWORD));
return loginResponse;
}
@ -51,13 +46,14 @@ export class AuthController {
}
@Post('/logout')
async logout(@Res() response: Response): Promise<LogoutResponseDto> {
response.clearCookie('immich_access_token');
response.clearCookie('immich_is_authenticated');
async logout(@Req() req: Request, @Res({ passthrough: true }) response: Response): Promise<LogoutResponseDto> {
const authType: AuthType = req.cookies[IMMICH_AUTH_TYPE_COOKIE];
const status = new LogoutResponseDto(true);
const cookies = this.immichJwtService.getCookieNames();
for (const cookie of cookies) {
response.clearCookie(cookie);
}
response.send(status);
return status;
return this.authService.logout(authType);
}
}

View File

@ -1,16 +1,13 @@
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 { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
import { OAuthModule } from '../oauth/oauth.module';
import { UserModule } from '../user/user.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
imports: [UserModule, ImmichJwtModule, OAuthModule],
controllers: [AuthController],
providers: [AuthService, ImmichJwtService],
providers: [AuthService],
})
export class AuthModule {}

View 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();
});
});
});

View File

@ -1,106 +1,80 @@
import { BadRequestException, 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 { BadRequestException, Inject, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
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 { LoginResponseDto } from './response-dto/login-response.dto';
import { LogoutResponseDto } from './response-dto/logout-response.dto';
import { OAuthService } from '../oauth/oauth.service';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
private oauthService: OAuthService,
private immichJwtService: ImmichJwtService,
@Inject(USER_REPOSITORY) private userRepository: IUserRepository,
) {}
private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity | null> {
const user = await this.userRepository.findOne({
where: {
email: loginCredential.email,
},
select: [
'id',
'email',
'password',
'salt',
'firstName',
'lastName',
'isAdmin',
'profileImagePath',
'shouldChangePassword',
],
});
public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> {
let user = await this.userRepository.getByEmail(loginCredential.email, true);
if (user) {
const isAuthenticated = await this.validatePassword(loginCredential.password, user);
if (!isAuthenticated) {
user = null;
}
}
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}`);
throw new BadRequestException('Incorrect email or password');
}
const payload = new JwtPayloadDto(validatedUser.id, validatedUser.email);
const accessToken = await this.immichJwtService.generateToken(payload);
return mapLoginResponse(validatedUser, accessToken);
return this.immichJwtService.createLoginResponse(user);
}
public getCookieWithJwtToken(authLoginInfo: LoginResponseDto) {
const maxAge = 7 * 24 * 3600; // 7 days
return `immich_access_token=${authLoginInfo.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}`;
public async logout(authType: AuthType): Promise<LogoutResponseDto> {
if (authType === AuthType.OAUTH) {
const url = await this.oauthService.getLogoutEndpoint();
if (url) {
return { successful: true, redirectUri: url };
}
}
// !TODO: refactor this method to use the userService createUser method
public async adminSignUp(signUpCredential: SignUpDto): Promise<AdminSignupResponseDto> {
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
return { successful: true, redirectUri: '/auth/login' };
}
public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
const adminUser = await this.userRepository.getAdmin();
if (adminUser) {
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 {
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) {
Logger.error('e', 'signUp');
throw new InternalServerErrorException('Failed to register new admin user');
}
}
private async hashPassword(password: string, salt: string): Promise<string> {
return bcrypt.hash(password, salt);
private async validatePassword(inputPassword: string, user: UserEntity): Promise<boolean> {
if (!user || !user.password) {
return false;
}
private async validatePassword(hasedPassword: string, inputPassword: string, salt: string): Promise<boolean> {
const hash = await bcrypt.hash(inputPassword, salt);
return hash === hasedPassword;
return await bcrypt.compare(inputPassword, user.password);
}
}

View File

@ -7,4 +7,7 @@ export class LogoutResponseDto {
@ApiResponseProperty()
successful!: boolean;
@ApiResponseProperty()
redirectUri!: string;
}

View File

@ -6,6 +6,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import { UserEntity } from '@app/database/entities/user.entity';
import { Repository } from 'typeorm';
import cookieParser from 'cookie';
import { IMMICH_ACCESS_COOKIE } from '../../constants/jwt.constant';
@WebSocketGateway({ cors: true })
export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect {
constructor(
@ -30,8 +32,8 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
if (client.handshake.headers.cookie != undefined) {
const cookies = cookieParser.parse(client.handshake.headers.cookie);
if (cookies.immich_access_token) {
accessToken = cookies.immich_access_token;
if (cookies[IMMICH_ACCESS_COOKIE]) {
accessToken = cookies[IMMICH_ACCESS_COOKIE];
} else {
client.emit('error', 'unauthorized');
client.disconnect();

View File

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class OAuthCallbackDto {
@IsNotEmpty()
@IsString()
@ApiProperty()
url!: string;
}

View File

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class OAuthConfigDto {
@IsNotEmpty()
@IsString()
@ApiProperty()
redirectUri!: string;
}

View 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;
}
}

View 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 {}

View 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');
});
});
});

View 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);
}
}

View File

@ -0,0 +1,12 @@
import { ApiResponseProperty } from '@nestjs/swagger';
export class OAuthConfigResponseDto {
@ApiResponseProperty()
enabled!: boolean;
@ApiResponseProperty()
url?: string;
@ApiResponseProperty()
buttonText?: string;
}

View File

@ -1,18 +1,16 @@
import { UserEntity } from '@app/database/entities/user.entity';
import { BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { CreateUserDto } from './dto/create-user.dto';
import * as bcrypt from 'bcrypt';
import { UpdateUserDto } from './dto/update-user.dto';
import { Not, Repository } from 'typeorm';
export interface IUserRepository {
get(userId: string, withDeleted?: boolean): Promise<UserEntity | null>;
getByEmail(email: string): Promise<UserEntity | null>;
get(id: string, withDeleted?: boolean): Promise<UserEntity | null>;
getAdmin(): Promise<UserEntity | null>;
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>;
getList(filter?: { excludeId?: string }): Promise<UserEntity[]>;
create(createUserDto: CreateUserDto): Promise<UserEntity>;
update(user: UserEntity, updateUserDto: UpdateUserDto): Promise<UserEntity>;
createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity>;
create(user: Partial<UserEntity>): Promise<UserEntity>;
update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
delete(user: UserEntity): Promise<UserEntity>;
restore(user: UserEntity): Promise<UserEntity>;
}
@ -25,25 +23,29 @@ export class UserRepository implements IUserRepository {
private userRepository: Repository<UserEntity>,
) {}
private async hashPassword(password: string, salt: string): Promise<string> {
return bcrypt.hash(password, salt);
}
async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
public async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted });
}
async getByEmail(email: string): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { email } });
public async getAdmin(): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { isAdmin: true } });
}
// TODO add DTO for filtering
async getList({ excludeId }: { excludeId?: string } = {}): Promise<UserEntity[]> {
public async getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null> {
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) {
return this.userRepository.find(); // TODO: this should also be ordered the same as below
}
return this.userRepository
.find({
return this.userRepository.find({
where: { id: Not(excludeId) },
withDeleted: true,
order: {
@ -52,33 +54,27 @@ export class UserRepository implements IUserRepository {
});
}
async create(createUserDto: CreateUserDto): Promise<UserEntity> {
const newUser = new UserEntity();
newUser.email = createUserDto.email;
newUser.salt = await bcrypt.genSalt();
newUser.password = await this.hashPassword(createUserDto.password, newUser.salt);
newUser.firstName = createUserDto.firstName;
newUser.lastName = createUserDto.lastName;
newUser.isAdmin = false;
public async create(user: Partial<UserEntity>): Promise<UserEntity> {
if (user.password) {
user.salt = await bcrypt.genSalt();
user.password = await this.hashPassword(user.password, user.salt);
}
user.isAdmin = false;
return this.userRepository.save(newUser);
return this.userRepository.save(user);
}
async update(user: UserEntity, updateUserDto: UpdateUserDto): Promise<UserEntity> {
user.lastName = updateUserDto.lastName || user.lastName;
user.firstName = updateUserDto.firstName || user.firstName;
user.profileImagePath = updateUserDto.profileImagePath || user.profileImagePath;
user.shouldChangePassword =
updateUserDto.shouldChangePassword != undefined ? updateUserDto.shouldChangePassword : user.shouldChangePassword;
public async update(id: string, user: Partial<UserEntity>): Promise<UserEntity> {
user.id = id;
// If payload includes password - Create new password for user
if (updateUserDto.password) {
if (user.password) {
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)
if (updateUserDto.isAdmin) {
if (user.isAdmin) {
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
if (adminUser) {
@ -91,19 +87,18 @@ export class UserRepository implements IUserRepository {
return this.userRepository.save(user);
}
async delete(user: UserEntity): Promise<UserEntity> {
public async delete(user: UserEntity): Promise<UserEntity> {
if (user.isAdmin) {
throw new BadRequestException('Cannot delete admin user! stay sane!');
}
return this.userRepository.softRemove(user);
}
async restore(user: UserEntity): Promise<UserEntity> {
public async restore(user: UserEntity): Promise<UserEntity> {
return this.userRepository.recover(user);
}
async createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity> {
user.profileImagePath = fileInfo.path;
return this.userRepository.save(user);
private async hashPassword(password: string, salt: string): Promise<string> {
return bcrypt.hash(password, salt);
}
}

View File

@ -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 { 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 { 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 { UserController } from './user.controller';
import { UserService } from './user.service';
const USER_REPOSITORY_PROVIDER = {
provide: USER_REPOSITORY,
useClass: UserRepository,
};
@Module({
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
controllers: [UserController],
providers: [
UserService,
ImmichJwtService,
{
provide: USER_REPOSITORY,
useClass: UserRepository,
},
],
providers: [UserService, ImmichJwtService, USER_REPOSITORY_PROVIDER],
exports: [USER_REPOSITORY_PROVIDER],
})
export class UserModule {}

View File

@ -1,5 +1,6 @@
import { UserEntity } from '@app/database/entities/user.entity';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { newUserRepositoryMock } from '../../../test/test-utils';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { IUserRepository } from './user-repository';
import { UserService } from './user.service';
@ -58,16 +59,7 @@ describe('UserService', () => {
});
beforeAll(() => {
userRepositoryMock = {
create: jest.fn(),
createProfileImage: jest.fn(),
get: jest.fn(),
getByEmail: jest.fn(),
getList: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
};
userRepositoryMock = newUserRepositoryMock();
sui = new UserService(userRepositoryMock);
});

View File

@ -9,17 +9,17 @@ import {
StreamableFile,
UnauthorizedException,
} from '@nestjs/common';
import { Response as Res } from 'express';
import { createReadStream } from 'fs';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateUserDto } from './dto/create-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 {
CreateProfileImageResponseDto,
mapCreateProfileImageResponse,
} 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';
@Injectable()
@ -98,7 +98,7 @@ export class UserService {
throw new NotFoundException('User not found');
}
try {
const updatedUser = await this.userRepository.update(user, updateUserDto);
const updatedUser = await this.userRepository.update(user.id, updateUserDto);
return mapUser(updatedUser);
} catch (e) {
@ -159,7 +159,7 @@ export class UserService {
}
try {
await this.userRepository.createProfileImage(user, fileInfo);
await this.userRepository.update(user.id, { profileImagePath: fileInfo.path });
return mapCreateProfileImageResponse(authUser.id, fileInfo.path);
} catch (e) {

View File

@ -16,6 +16,7 @@ import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
import { DatabaseModule } from '@app/database';
import { JobModule } from './api-v1/job/job.module';
import { OAuthModule } from './api-v1/oauth/oauth.module';
@Module({
imports: [
@ -27,6 +28,7 @@ import { JobModule } from './api-v1/job/job.module';
AssetModule,
AuthModule,
OAuthModule,
ImmichJwtModule,

View File

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

View File

@ -1,53 +1,96 @@
import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
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';
describe('ImmichJwtService', () => {
let jwtService: JwtService;
let service: ImmichJwtService;
let jwtServiceMock: jest.Mocked<JwtService>;
let sut: ImmichJwtService;
beforeEach(() => {
jwtService = new JwtService();
service = new ImmichJwtService(jwtService);
jwtServiceMock = {
sign: jest.fn(),
verifyAsync: jest.fn(),
} as unknown as jest.Mocked<JwtService>;
sut = new ImmichJwtService(jwtServiceMock);
});
afterEach(() => {
jest.resetModules();
});
describe('generateToken', () => {
it('should generate the token', async () => {
const spy = jest.spyOn(jwtService, 'sign');
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('getCookieNames', () => {
it('should return the cookie names', async () => {
expect(sut.getCookieNames()).toEqual(['immich_access_token', 'immich_auth_type']);
});
});
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', () => {
it('should validate the token', async () => {
const dto = { userId: 'test-user', email: 'test-user@immich.com' };
const spy = jest.spyOn(jwtService, 'verifyAsync');
spy.mockImplementation(() => dto as any);
const response = await service.validateToken('access-token');
jwtServiceMock.verifyAsync.mockImplementation(() => dto as any);
const response = await sut.validateToken('access-token');
expect(spy).toHaveBeenCalledTimes(1);
expect(jwtServiceMock.verifyAsync).toHaveBeenCalledTimes(1);
expect(response).toEqual({ userId: 'test-user', status: true });
});
it('should handle an invalid token', async () => {
const verifyAsync = jest.spyOn(jwtService, 'verifyAsync');
verifyAsync.mockImplementation(() => {
jwtServiceMock.verifyAsync.mockImplementation(() => {
throw new Error('Invalid token!');
});
const error = jest.spyOn(Logger, 'error');
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(response).toEqual({ userId: null, status: false });
});
@ -58,7 +101,7 @@ describe('ImmichJwtService', () => {
const request = {
headers: {},
} as Request;
const token = service.extractJwtFromHeader(request);
const token = sut.extractJwtFromHeader(request);
expect(token).toBe(null);
});
@ -75,15 +118,15 @@ describe('ImmichJwtService', () => {
},
} as Request;
expect(service.extractJwtFromHeader(upper)).toBe('token');
expect(service.extractJwtFromHeader(lower)).toBe('token');
expect(sut.extractJwtFromHeader(upper)).toBe('token');
expect(sut.extractJwtFromHeader(lower)).toBe('token');
});
});
describe('extracJwtFromCookie', () => {
it('should handle no cookie', () => {
const request = {} as Request;
const token = service.extractJwtFromCookie(request);
const token = sut.extractJwtFromCookie(request);
expect(token).toBe(null);
});
@ -93,7 +136,7 @@ describe('ImmichJwtService', () => {
immich_access_token: 'cookie',
},
} as Request;
const token = service.extractJwtFromCookie(request);
const token = sut.extractJwtFromCookie(request);
expect(token).toBe('cookie');
});
});

View File

@ -1,8 +1,10 @@
import { UserEntity } from '@app/database/entities/user.entity';
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
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 = {
status: boolean;
@ -13,10 +15,24 @@ export type JwtValidationResult = {
export class ImmichJwtService {
constructor(private jwtService: JwtService) {}
public async generateToken(payload: JwtPayloadDto) {
return this.jwtService.sign({
...payload,
});
public getCookieNames() {
return [IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE];
}
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> {
@ -48,10 +64,12 @@ export class ImmichJwtService {
}
public extractJwtFromCookie(req: Request) {
if (req.cookies?.immich_access_token) {
return req.cookies.immich_access_token;
return req.cookies?.[IMMICH_ACCESS_COOKIE] || null;
}
return null;
private async generateToken(payload: JwtPayloadDto) {
return this.jwtService.sign({
...payload,
});
}
}

View File

@ -1,6 +1,7 @@
import { DataSource } from 'typeorm';
import { CanActivate, ExecutionContext } from '@nestjs/common';
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 { 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 {
return {
id: '3108ac14-8afb-4b7e-87fd-39ebb6b79750',

File diff suppressed because one or more lines are too long

View File

@ -16,6 +16,12 @@ const jwtSecretValidator: Joi.CustomValidator<string> = (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 = {
envFilePath: '.env',
isGlobal: true,
@ -28,5 +34,12 @@ export const immichAppConfig: ConfigModuleOptions = {
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3),
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').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,
}),
};

View File

@ -1,4 +1,4 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, DeleteDateColumn } from 'typeorm';
import { Column, CreateDateColumn, DeleteDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('users')
export class UserEntity {
@ -17,10 +17,10 @@ export class UserEntity {
@Column()
email!: string;
@Column({ select: false })
@Column({ default: '', select: false })
password?: string;
@Column({ select: false })
@Column({ default: '', select: false })
salt?: string;
@Column({ default: '' })

View File

@ -41,6 +41,7 @@
"lodash": "^4.17.21",
"luxon": "^3.0.3",
"nest-commander": "^3.3.0",
"openid-client": "^5.2.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.0",
"pg": "^8.7.1",
@ -7449,6 +7450,14 @@
"@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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -8439,6 +8448,14 @@
"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": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -8472,6 +8489,28 @@
"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": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
@ -17131,6 +17170,11 @@
"@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": {
"version": "4.0.0",
"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",
"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": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -17963,6 +18012,24 @@
"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": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",

View File

@ -62,6 +62,7 @@
"local-reverse-geocoder": "^0.12.5",
"lodash": "^4.17.21",
"luxon": "^3.0.3",
"openid-client": "^5.2.1",
"nest-commander": "^3.3.0",
"passport": "^0.6.0",
"passport-jwt": "^4.0.0",

View File

@ -6,6 +6,7 @@ import {
Configuration,
DeviceInfoApi,
JobApi,
OAuthApi,
ServerInfoApi,
UserApi
} from './open-api';
@ -15,6 +16,7 @@ class ImmichApi {
public albumApi: AlbumApi;
public assetApi: AssetApi;
public authenticationApi: AuthenticationApi;
public oauthApi: OAuthApi;
public deviceInfoApi: DeviceInfoApi;
public serverInfoApi: ServerInfoApi;
public jobApi: JobApi;
@ -26,6 +28,7 @@ class ImmichApi {
this.albumApi = new AlbumApi(this.config);
this.assetApi = new AssetApi(this.config);
this.authenticationApi = new AuthenticationApi(this.config);
this.oauthApi = new OAuthApi(this.config);
this.deviceInfoApi = new DeviceInfoApi(this.config);
this.serverInfoApi = new ServerInfoApi(this.config);
this.jobApi = new JobApi(this.config);

View File

@ -1125,6 +1125,63 @@ export interface LogoutResponseDto {
* @memberof LogoutResponseDto
*/
'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
* @export

View File

@ -1,17 +1,49 @@
<script lang="ts">
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { loginPageMessage } from '$lib/constants';
import { api } from '@api';
import { createEventDispatcher } from 'svelte';
import { api, OAuthConfigResponseDto } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
let error: string;
let email = '';
let password = '';
let oauthError: string;
let oauthConfig: OAuthConfigResponseDto = { enabled: false };
let loading = true;
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 () => {
try {
error = '';
loading = true;
const { data } = await api.authenticationApi.login({
email,
@ -27,6 +59,7 @@
return;
} catch (e) {
error = 'Incorrect email or password';
loading = false;
return;
}
};
@ -48,6 +81,11 @@
</p>
{/if}
{#if loading}
<div class="flex place-items-center place-content-center">
<LoadingSpinner />
</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>
@ -80,9 +118,28 @@
<div class="flex w-full">
<button
type="submit"
disabled={loading}
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>
{#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>

View File

@ -38,8 +38,11 @@
};
const logOut = async () => {
const { data } = await api.authenticationApi.logout();
await fetch('auth/logout', { method: 'POST' });
goto('/auth/login');
goto(data.redirectUri || '/auth/login');
};
</script>

View File

@ -10,7 +10,7 @@ export const POST: RequestHandler = async () => {
headers.append(
'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(
'set-cookie',