feat(web,server): disable password login (#1223)

* feat(web,server): disable password login

* chore: unit tests

* chore: fix import

* chore: linting

* feat(cli): server command for enable/disable password login

* chore: update docs

* feat(web): confirm dialogue

* chore: linting

* chore: linting

* chore: linting

* chore: linting

* chore: linting

* chore: fix web test

* chore: server unit tests
This commit is contained in:
Jason Rasmussen
2023-01-09 16:32:58 -05:00
committed by GitHub
parent 5999af6c78
commit bd838a71d1
66 changed files with 861 additions and 167 deletions

View File

@@ -1,10 +1,16 @@
import { DatabaseModule, UserEntity } from '@app/database';
import { DatabaseModule, SystemConfigEntity, UserEntity } from '@app/database';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from './commands/password-login';
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from './commands/reset-admin-password.command';
@Module({
imports: [DatabaseModule, TypeOrmModule.forFeature([UserEntity])],
providers: [ResetAdminPasswordCommand, PromptPasswordQuestions],
imports: [DatabaseModule, TypeOrmModule.forFeature([UserEntity, SystemConfigEntity])],
providers: [
ResetAdminPasswordCommand,
PromptPasswordQuestions,
EnablePasswordLoginCommand,
DisablePasswordLoginCommand,
],
})
export class AppModule {}

View File

@@ -0,0 +1,39 @@
import { SystemConfigEntity, SystemConfigKey } from '@app/database';
import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios';
import { Command, CommandRunner } from 'nest-commander';
import { Repository } from 'typeorm';
@Command({
name: 'enable-password-login',
description: 'Enable password login',
})
export class EnablePasswordLoginCommand extends CommandRunner {
constructor(
@InjectRepository(SystemConfigEntity) private repository: Repository<SystemConfigEntity>, //
) {
super();
}
async run(): Promise<void> {
await this.repository.delete({ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED });
await axios.post('http://localhost:3001/refresh-config');
console.log('Password login has been enabled.');
}
}
@Command({
name: 'disable-password-login',
description: 'Disable password login',
})
export class DisablePasswordLoginCommand extends CommandRunner {
constructor(@InjectRepository(SystemConfigEntity) private repository: Repository<SystemConfigEntity>) {
super();
}
async run(): Promise<void> {
await this.repository.save({ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED, value: false });
await axios.post('http://localhost:3001/refresh-config');
console.log('Password login has been disabled.');
}
}

View File

@@ -136,6 +136,8 @@ describe('Album service', () => {
getById: jest.fn(),
getByKey: jest.fn(),
save: jest.fn(),
hasAssetAccess: jest.fn(),
getByIdAndUserId: jest.fn(),
};
downloadServiceMock = {

View File

@@ -130,7 +130,6 @@ describe('AssetService', () => {
getAssetWithNoSmartInfo: jest.fn(),
getExistingAssets: jest.fn(),
countByIdAndUser: jest.fn(),
getSharePermission: jest.fn(),
};
downloadServiceMock = {
@@ -144,6 +143,8 @@ describe('AssetService', () => {
getByKey: jest.fn(),
remove: jest.fn(),
save: jest.fn(),
hasAssetAccess: jest.fn(),
getByIdAndUserId: jest.fn(),
};
sui = new AssetService(

View File

@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { ImmichConfigModule } from '@app/immich-config';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { OAuthModule } from '../oauth/oauth.module';
import { UserModule } from '../user/user.module';
@@ -6,7 +7,7 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
imports: [UserModule, ImmichJwtModule, OAuthModule],
imports: [UserModule, ImmichJwtModule, OAuthModule, ImmichConfigModule],
controllers: [AuthController],
providers: [AuthService],
})

View File

@@ -1,6 +1,8 @@
import { UserEntity } from '@app/database';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { SystemConfig } from '@app/database/entities/system-config.entity';
import { ImmichConfigService } from '@app/immich-config';
import { AuthType } from '../../constants/jwt.constant';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { OAuthService } from '../oauth/oauth.service';
@@ -16,6 +18,19 @@ const fixtures = {
},
};
const config = {
enabled: {
passwordLogin: {
enabled: true,
},
} as SystemConfig,
disabled: {
passwordLogin: {
enabled: false,
},
} as SystemConfig,
};
const CLIENT_IP = '127.0.0.1';
jest.mock('bcrypt');
@@ -35,6 +50,7 @@ describe('AuthService', () => {
let sut: AuthService;
let userRepositoryMock: jest.Mocked<IUserRepository>;
let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
let immichConfigServiceMock: jest.Mocked<ImmichConfigService>;
let oauthServiceMock: jest.Mocked<OAuthService>;
let compare: jest.Mock;
@@ -71,14 +87,40 @@ describe('AuthService', () => {
getLogoutEndpoint: jest.fn(),
} as unknown as jest.Mocked<OAuthService>;
sut = new AuthService(oauthServiceMock, immichJwtServiceMock, userRepositoryMock);
immichConfigServiceMock = {
config$: { subscribe: jest.fn() },
} as unknown as jest.Mocked<ImmichConfigService>;
sut = new AuthService(
oauthServiceMock,
immichJwtServiceMock,
userRepositoryMock,
immichConfigServiceMock,
config.enabled,
);
});
it('should be defined', () => {
expect(sut).toBeDefined();
});
it('should subscribe to config changes', async () => {
expect(immichConfigServiceMock.config$.subscribe).toHaveBeenCalled();
});
describe('login', () => {
it('should throw an error if password login is disabled', async () => {
sut = new AuthService(
oauthServiceMock,
immichJwtServiceMock,
userRepositoryMock,
immichConfigServiceMock,
config.disabled,
);
await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should check the user exists', async () => {
userRepositoryMock.getByEmail.mockResolvedValue(null);
await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException);
@@ -170,7 +212,7 @@ describe('AuthService', () => {
it('should return the default redirect', async () => {
await expect(sut.logout(AuthType.PASSWORD)).resolves.toEqual({
successful: true,
redirectUri: '/auth/login',
redirectUri: '/auth/login?autoLaunch=0',
});
expect(oauthServiceMock.getLogoutEndpoint).not.toHaveBeenCalled();
});

View File

@@ -20,6 +20,8 @@ import { LoginResponseDto } from './response-dto/login-response.dto';
import { LogoutResponseDto } from './response-dto/logout-response.dto';
import { OAuthService } from '../oauth/oauth.service';
import { UserCore } from '../user/user.core';
import { ImmichConfigService, INITIAL_SYSTEM_CONFIG } from '@app/immich-config';
import { SystemConfig } from '@app/database/entities/system-config.entity';
@Injectable()
export class AuthService {
@@ -30,11 +32,18 @@ export class AuthService {
private oauthService: OAuthService,
private immichJwtService: ImmichJwtService,
@Inject(IUserRepository) userRepository: IUserRepository,
private configService: ImmichConfigService,
@Inject(INITIAL_SYSTEM_CONFIG) private config: SystemConfig,
) {
this.userCore = new UserCore(userRepository);
this.configService.config$.subscribe((config) => (this.config = config));
}
public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> {
if (!this.config.passwordLogin.enabled) {
throw new UnauthorizedException('Password login has been disabled');
}
let user = await this.userCore.getByEmail(loginCredential.email, true);
if (user) {
@@ -60,7 +69,7 @@ export class AuthService {
}
}
return { successful: true, redirectUri: '/auth/login' };
return { successful: true, redirectUri: '/auth/login?autoLaunch=0' };
}
public async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {

View File

@@ -17,29 +17,37 @@ const config = {
enabled: false,
buttonText: 'OAuth',
issuerUrl: 'http://issuer,',
autoLaunch: false,
},
passwordLogin: { enabled: true },
} as SystemConfig,
enabled: {
oauth: {
enabled: true,
autoRegister: true,
buttonText: 'OAuth',
autoLaunch: false,
},
passwordLogin: { enabled: true },
} as SystemConfig,
noAutoRegister: {
oauth: {
enabled: true,
autoRegister: false,
autoLaunch: false,
},
passwordLogin: { enabled: true },
} as SystemConfig,
override: {
oauth: {
enabled: true,
autoRegister: true,
autoLaunch: false,
buttonText: 'OAuth',
mobileOverrideEnabled: true,
mobileRedirectUri: 'http://mobile-redirect',
},
passwordLogin: { enabled: true },
} as SystemConfig,
};
@@ -124,7 +132,6 @@ describe('OAuthService', () => {
immichConfigServiceMock = {
config$: { subscribe: jest.fn() },
getConfig: jest.fn().mockResolvedValue({ oauth: { enabled: false } }),
} as unknown as jest.Mocked<ImmichConfigService>;
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.disabled);
@@ -136,7 +143,10 @@ describe('OAuthService', () => {
describe('generateConfig', () => {
it('should work when oauth is not configured', async () => {
await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({ enabled: false });
await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({
enabled: false,
passwordLoginEnabled: true,
});
});
it('should generate the config', async () => {
@@ -145,6 +155,8 @@ describe('OAuthService', () => {
enabled: true,
buttonText: 'OAuth',
url: 'http://authorization-url',
autoLaunch: false,
passwordLoginEnabled: true,
});
});
});

View File

@@ -39,19 +39,24 @@ export class OAuthService {
}
public async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
const { enabled, scope, buttonText } = this.config.oauth;
const redirectUri = this.normalize(dto.redirectUri);
const response = {
enabled: this.config.oauth.enabled,
passwordLoginEnabled: this.config.passwordLogin.enabled,
};
if (!enabled) {
return { enabled: false };
if (!response.enabled) {
return response;
}
const { scope, buttonText, autoLaunch } = this.config.oauth;
const redirectUri = this.normalize(dto.redirectUri);
const url = (await this.getClient()).authorizationUrl({
redirect_uri: redirectUri,
scope,
state: generators.state(),
});
return { enabled: true, buttonText, url };
return { ...response, buttonText, url, autoLaunch };
}
public async login(dto: OAuthCallbackDto): Promise<LoginResponseDto> {

View File

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

View File

@@ -31,6 +31,9 @@ export class SystemConfigOAuthDto {
@IsBoolean()
autoRegister!: boolean;
@IsBoolean()
autoLaunch!: boolean;
@IsBoolean()
mobileOverrideEnabled!: boolean;

View File

@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class SystemConfigPasswordLoginDto {
@IsBoolean()
enabled!: boolean;
}

View File

@@ -2,6 +2,7 @@ import { SystemConfig } from '@app/database';
import { ValidateNested } from 'class-validator';
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
import { SystemConfigOAuthDto } from './system-config-oauth.dto';
import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto';
import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto';
export class SystemConfigDto {
@@ -11,6 +12,9 @@ export class SystemConfigDto {
@ValidateNested()
oauth!: SystemConfigOAuthDto;
@ValidateNested()
passwordLogin!: SystemConfigPasswordLoginDto;
@ValidateNested()
storageTemplate!: SystemConfigStorageTemplateDto;
}

View File

@@ -1,3 +1,15 @@
import { Controller } from '@nestjs/common';
import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiExcludeEndpoint } from '@nestjs/swagger';
import { ImmichConfigService } from '@app/immich-config';
@Controller()
export class AppController {}
export class AppController {
constructor(private configService: ImmichConfigService) {}
@ApiExcludeEndpoint()
@Post('refresh-config')
@HttpCode(HttpStatus.OK)
public reloadConfig() {
return this.configService.refreshConfig();
}
}

View File

@@ -3,6 +3,7 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { UserModule } from './api-v1/user/user.module';
import { AssetModule } from './api-v1/asset/asset.module';
import { AuthModule } from './api-v1/auth/auth.module';
import { APIKeyModule } from './api-v1/api-key/api-key.module';
import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
import { ConfigModule } from '@nestjs/config';
@@ -19,8 +20,8 @@ import { JobModule } from './api-v1/job/job.module';
import { SystemConfigModule } from './api-v1/system-config/system-config.module';
import { OAuthModule } from './api-v1/oauth/oauth.module';
import { TagModule } from './api-v1/tag/tag.module';
import { ImmichConfigModule } from '@app/immich-config';
import { ShareModule } from './api-v1/share/share.module';
import { APIKeyModule } from './api-v1/api-key/api-key.module';
@Module({
imports: [
@@ -37,6 +38,7 @@ import { APIKeyModule } from './api-v1/api-key/api-key.module';
OAuthModule,
ImmichJwtModule,
ImmichConfigModule,
DeviceInfoModule,

View File

@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator';
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
import { APIKeyService } from '../../../api-v1/api-key/api-key.service';
import { AuthUserDto } from '../../../decorators/auth-user.decorator';
export const API_KEY_STRATEGY = 'api-key';

View File

@@ -1,13 +1,13 @@
import { UserEntity } from '@app/database';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt';
import { Repository } from 'typeorm';
import { JwtPayloadDto } from '../../../api-v1/auth/dto/jwt-payload.dto';
import { UserEntity } from '@app/database';
import { jwtSecret } from '../../../constants/jwt.constant';
import { AuthUserDto } from '../../../decorators/auth-user.decorator';
import { ImmichJwtService } from '../immich-jwt.service';
import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator';
export const JWT_STRATEGY = 'jwt';

View File

@@ -2,10 +2,10 @@ import { UserEntity } from '@app/database';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { ShareService } from '../../../api-v1/share/share.service';
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
import { Repository } from 'typeorm';
import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator';
import { ShareService } from '../../../api-v1/share/share.service';
import { AuthUserDto } from '../../../decorators/auth-user.decorator';
export const PUBLIC_SHARE_STRATEGY = 'public-share';