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
51 changed files with 1997 additions and 253 deletions

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 };
}
}
return { successful: true, redirectUri: '/auth/login' };
}
// !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 } });
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(hasedPassword: string, inputPassword: string, salt: string): Promise<boolean> {
const hash = await bcrypt.hash(inputPassword, salt);
return hash === hasedPassword;
private async validatePassword(inputPassword: string, user: UserEntity): Promise<boolean> {
if (!user || !user.password) {
return false;
}
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',