1
0
forked from Cutlery/immich
Jason Rasmussen bd838a71d1
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
2023-01-09 16:32:58 -05:00

157 lines
5.5 KiB
TypeScript

import { SystemConfig } from '@app/database/entities/system-config.entity';
import { ImmichConfigService, INITIAL_SYSTEM_CONFIG } from '@app/immich-config';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
import { UserResponseDto } from '../user/response-dto/user-response.dto';
import { IUserRepository } from '../user/user-repository';
import { UserCore } from '../user/user.core';
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;
};
export const MOBILE_REDIRECT = 'app.immich:/';
@Injectable()
export class OAuthService {
private readonly userCore: UserCore;
private readonly logger = new Logger(OAuthService.name);
constructor(
private immichJwtService: ImmichJwtService,
immichConfigService: ImmichConfigService,
@Inject(IUserRepository) userRepository: IUserRepository,
@Inject(INITIAL_SYSTEM_CONFIG) private config: SystemConfig,
) {
this.userCore = new UserCore(userRepository);
custom.setHttpOptionsDefaults({
timeout: 30000,
});
immichConfigService.config$.subscribe((config) => (this.config = config));
}
public async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
const response = {
enabled: this.config.oauth.enabled,
passwordLoginEnabled: this.config.passwordLogin.enabled,
};
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 { ...response, buttonText, url, autoLaunch };
}
public async login(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
const profile = await this.callback(dto.url);
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user = await this.userCore.getByOAuthId(profile.sub);
// link existing user
if (!user) {
const emailUser = await this.userCore.getByEmail(profile.email);
if (emailUser) {
user = await this.userCore.updateUser(emailUser, emailUser.id, { oauthId: profile.sub });
}
}
// register new user
if (!user) {
if (!this.config.oauth.autoRegister) {
this.logger.warn(
`Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
);
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
}
this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
user = await this.userCore.createUser({
firstName: profile.given_name || '',
lastName: profile.family_name || '',
email: profile.email,
oauthId: profile.sub,
});
}
return this.immichJwtService.createLoginResponse(user);
}
public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
const { sub: oauthId } = await this.callback(dto.url);
const duplicate = await this.userCore.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== user.id) {
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
return this.userCore.updateUser(user, user.id, { oauthId });
}
public async unlink(user: AuthUserDto): Promise<UserResponseDto> {
return this.userCore.updateUser(user, user.id, { oauthId: '' });
}
public async getLogoutEndpoint(): Promise<string | null> {
if (!this.config.oauth.enabled) {
return null;
}
return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
}
private async callback(url: string): Promise<any> {
const redirectUri = this.normalize(url.split('?')[0]);
const client = await this.getClient();
const params = client.callbackParams(url);
const tokens = await client.callback(redirectUri, params, { state: params.state });
return await client.userinfo<OAuthProfile>(tokens.access_token || '');
}
private async getClient() {
const { enabled, clientId, clientSecret, issuerUrl } = this.config.oauth;
if (!enabled) {
throw new BadRequestException('OAuth2 is not enabled');
}
const metadata: ClientMetadata = {
client_id: clientId,
client_secret: clientSecret,
response_types: ['code'],
};
const issuer = await Issuer.discover(issuerUrl);
const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
if (algorithms[0] === 'HS256') {
metadata.id_token_signed_response_alg = algorithms[0];
}
return new issuer.Client(metadata);
}
private normalize(redirectUri: string) {
const isMobile = redirectUri === MOBILE_REDIRECT;
const { mobileRedirectUri, mobileOverrideEnabled } = this.config.oauth;
if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
return mobileRedirectUri;
}
return redirectUri;
}
}