mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-12-08 20:29:05 +00:00
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:
@@ -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 {}
|
||||
|
||||
39
server/apps/cli/src/commands/password-login.ts
Normal file
39
server/apps/cli/src/commands/password-login.ts
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,8 @@ describe('Album service', () => {
|
||||
getById: jest.fn(),
|
||||
getByKey: jest.fn(),
|
||||
save: jest.fn(),
|
||||
hasAssetAccess: jest.fn(),
|
||||
getByIdAndUserId: jest.fn(),
|
||||
};
|
||||
|
||||
downloadServiceMock = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ export class SystemConfigOAuthDto {
|
||||
@IsBoolean()
|
||||
autoRegister!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
autoLaunch!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
mobileOverrideEnabled!: boolean;
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class SystemConfigPasswordLoginDto {
|
||||
@IsBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user