mirror of
https://github.com/immich-app/immich.git
synced 2025-01-09 21:36:46 +01:00
25a380d023
* feat(server): userinfo signing * chore: e2e tests
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Inject,
|
|
Injectable,
|
|
InternalServerErrorException,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { isNumber, isString } from 'class-validator';
|
|
import cookieParser from 'cookie';
|
|
import { DateTime } from 'luxon';
|
|
import { IncomingHttpHeaders } from 'node:http';
|
|
import { Issuer, UserinfoResponse, custom, generators } from 'openid-client';
|
|
import { SystemConfig } from 'src/config';
|
|
import { AuthType, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
|
|
import { SystemConfigCore } from 'src/cores/system-config.core';
|
|
import { UserCore } from 'src/cores/user.core';
|
|
import {
|
|
AuthDto,
|
|
ChangePasswordDto,
|
|
ImmichCookie,
|
|
ImmichHeader,
|
|
LoginCredentialDto,
|
|
LogoutResponseDto,
|
|
OAuthAuthorizeResponseDto,
|
|
OAuthCallbackDto,
|
|
OAuthConfigDto,
|
|
SignUpDto,
|
|
mapLoginResponse,
|
|
} from 'src/dtos/auth.dto';
|
|
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
|
import { UserEntity } from 'src/entities/user.entity';
|
|
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
|
import { ISessionRepository } from 'src/interfaces/session.interface';
|
|
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
|
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
|
import { IUserRepository } from 'src/interfaces/user.interface';
|
|
import { HumanReadableSize } from 'src/utils/bytes';
|
|
|
|
export interface LoginDetails {
|
|
isSecure: boolean;
|
|
clientIp: string;
|
|
deviceType: string;
|
|
deviceOS: string;
|
|
}
|
|
|
|
type OAuthProfile = UserinfoResponse;
|
|
|
|
interface ClaimOptions<T> {
|
|
key: string;
|
|
default: T;
|
|
isValid: (value: unknown) => boolean;
|
|
}
|
|
|
|
@Injectable()
|
|
export class AuthService {
|
|
private configCore: SystemConfigCore;
|
|
private userCore: UserCore;
|
|
|
|
constructor(
|
|
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
|
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
|
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
|
@Inject(IUserRepository) private userRepository: IUserRepository,
|
|
@Inject(ISessionRepository) private sessionRepository: ISessionRepository,
|
|
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
|
|
@Inject(IKeyRepository) private keyRepository: IKeyRepository,
|
|
) {
|
|
this.logger.setContext(AuthService.name);
|
|
this.configCore = SystemConfigCore.create(systemMetadataRepository, logger);
|
|
this.userCore = UserCore.create(cryptoRepository, userRepository);
|
|
|
|
custom.setHttpOptionsDefaults({ timeout: 30_000 });
|
|
}
|
|
|
|
async login(dto: LoginCredentialDto, details: LoginDetails) {
|
|
const config = await this.configCore.getConfig({ withCache: false });
|
|
if (!config.passwordLogin.enabled) {
|
|
throw new UnauthorizedException('Password login has been disabled');
|
|
}
|
|
|
|
let user = await this.userRepository.getByEmail(dto.email, true);
|
|
if (user) {
|
|
const isAuthenticated = this.validatePassword(dto.password, user);
|
|
if (!isAuthenticated) {
|
|
user = null;
|
|
}
|
|
}
|
|
|
|
if (!user) {
|
|
this.logger.warn(`Failed login attempt for user ${dto.email} from ip address ${details.clientIp}`);
|
|
throw new UnauthorizedException('Incorrect email or password');
|
|
}
|
|
|
|
return this.createLoginResponse(user, details);
|
|
}
|
|
|
|
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
|
|
if (auth.session) {
|
|
await this.sessionRepository.delete(auth.session.id);
|
|
}
|
|
|
|
return {
|
|
successful: true,
|
|
redirectUri: await this.getLogoutEndpoint(authType),
|
|
};
|
|
}
|
|
|
|
async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
|
|
const { password, newPassword } = dto;
|
|
const user = await this.userRepository.getByEmail(auth.user.email, true);
|
|
if (!user) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
|
|
const valid = this.validatePassword(password, user);
|
|
if (!valid) {
|
|
throw new BadRequestException('Wrong password');
|
|
}
|
|
|
|
const hashedPassword = await this.cryptoRepository.hashBcrypt(newPassword, SALT_ROUNDS);
|
|
|
|
const updatedUser = await this.userRepository.update(user.id, { password: hashedPassword });
|
|
|
|
return mapUserAdmin(updatedUser);
|
|
}
|
|
|
|
async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
|
|
const adminUser = await this.userRepository.getAdmin();
|
|
if (adminUser) {
|
|
throw new BadRequestException('The server already has an admin');
|
|
}
|
|
|
|
const admin = await this.userCore.createUser({
|
|
isAdmin: true,
|
|
email: dto.email,
|
|
name: dto.name,
|
|
password: dto.password,
|
|
storageLabel: 'admin',
|
|
});
|
|
|
|
return mapUserAdmin(admin);
|
|
}
|
|
|
|
async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> {
|
|
const shareKey = (headers[ImmichHeader.SHARED_LINK_KEY] || params.key) as string;
|
|
const session = (headers[ImmichHeader.USER_TOKEN] ||
|
|
headers[ImmichHeader.SESSION_TOKEN] ||
|
|
params.sessionKey ||
|
|
this.getBearerToken(headers) ||
|
|
this.getCookieToken(headers)) as string;
|
|
const apiKey = (headers[ImmichHeader.API_KEY] || params.apiKey) as string;
|
|
|
|
if (shareKey) {
|
|
return this.validateSharedLink(shareKey);
|
|
}
|
|
|
|
if (session) {
|
|
return this.validateSession(session);
|
|
}
|
|
|
|
if (apiKey) {
|
|
return this.validateApiKey(apiKey);
|
|
}
|
|
|
|
throw new UnauthorizedException('Authentication required');
|
|
}
|
|
|
|
getMobileRedirect(url: string) {
|
|
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
|
|
}
|
|
|
|
async authorize(dto: OAuthConfigDto): Promise<OAuthAuthorizeResponseDto> {
|
|
const config = await this.configCore.getConfig({ withCache: false });
|
|
if (!config.oauth.enabled) {
|
|
throw new BadRequestException('OAuth is not enabled');
|
|
}
|
|
|
|
const client = await this.getOAuthClient(config);
|
|
const url = client.authorizationUrl({
|
|
redirect_uri: this.normalize(config, dto.redirectUri),
|
|
scope: config.oauth.scope,
|
|
state: generators.state(),
|
|
});
|
|
|
|
return { url };
|
|
}
|
|
|
|
async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) {
|
|
const config = await this.configCore.getConfig({ withCache: false });
|
|
const profile = await this.getOAuthProfile(config, dto.url);
|
|
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = config.oauth;
|
|
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
|
let user = await this.userRepository.getByOAuthId(profile.sub);
|
|
|
|
// link by email
|
|
if (!user && profile.email) {
|
|
const emailUser = await this.userRepository.getByEmail(profile.email);
|
|
if (emailUser) {
|
|
if (emailUser.oauthId) {
|
|
throw new BadRequestException('User already exists, but is linked to another account.');
|
|
}
|
|
user = await this.userRepository.update(emailUser.id, { oauthId: profile.sub });
|
|
}
|
|
}
|
|
|
|
// register new user
|
|
if (!user) {
|
|
if (!autoRegister) {
|
|
this.logger.warn(
|
|
`Unable to register ${profile.sub}/${profile.email || '(no email)'}. To enable set OAuth Auto Register to true in admin settings.`,
|
|
);
|
|
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
|
|
}
|
|
|
|
if (!profile.email) {
|
|
throw new BadRequestException('OAuth profile does not have an email address');
|
|
}
|
|
|
|
this.logger.log(`Registering new user: ${profile.sub}/${profile.email}`);
|
|
|
|
const storageLabel = this.getClaim(profile, {
|
|
key: storageLabelClaim,
|
|
default: '',
|
|
isValid: isString,
|
|
});
|
|
const storageQuota = this.getClaim(profile, {
|
|
key: storageQuotaClaim,
|
|
default: defaultStorageQuota,
|
|
isValid: (value: unknown) => isNumber(value) && value >= 0,
|
|
});
|
|
|
|
const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`;
|
|
user = await this.userCore.createUser({
|
|
name: userName,
|
|
email: profile.email,
|
|
oauthId: profile.sub,
|
|
quotaSizeInBytes: storageQuota * HumanReadableSize.GiB || null,
|
|
storageLabel: storageLabel || null,
|
|
});
|
|
}
|
|
|
|
return this.createLoginResponse(user, loginDetails);
|
|
}
|
|
|
|
async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserAdminResponseDto> {
|
|
const config = await this.configCore.getConfig({ withCache: false });
|
|
const { sub: oauthId } = await this.getOAuthProfile(config, dto.url);
|
|
const duplicate = await this.userRepository.getByOAuthId(oauthId);
|
|
if (duplicate && duplicate.id !== auth.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.');
|
|
}
|
|
|
|
const user = await this.userRepository.update(auth.user.id, { oauthId });
|
|
return mapUserAdmin(user);
|
|
}
|
|
|
|
async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
|
|
const user = await this.userRepository.update(auth.user.id, { oauthId: '' });
|
|
return mapUserAdmin(user);
|
|
}
|
|
|
|
private async getLogoutEndpoint(authType: AuthType): Promise<string> {
|
|
if (authType !== AuthType.OAUTH) {
|
|
return LOGIN_URL;
|
|
}
|
|
|
|
const config = await this.configCore.getConfig({ withCache: false });
|
|
if (!config.oauth.enabled) {
|
|
return LOGIN_URL;
|
|
}
|
|
|
|
const client = await this.getOAuthClient(config);
|
|
return client.issuer.metadata.end_session_endpoint || LOGIN_URL;
|
|
}
|
|
|
|
private async getOAuthProfile(config: SystemConfig, url: string): Promise<OAuthProfile> {
|
|
const redirectUri = this.normalize(config, url.split('?')[0]);
|
|
const client = await this.getOAuthClient(config);
|
|
const params = client.callbackParams(url);
|
|
try {
|
|
const tokens = await client.callback(redirectUri, params, { state: params.state });
|
|
return client.userinfo<OAuthProfile>(tokens.access_token || '');
|
|
} catch (error: Error | any) {
|
|
if (error.message.includes('unexpected JWT alg received')) {
|
|
this.logger.warn(
|
|
[
|
|
'Algorithm mismatch. Make sure the signing algorithm is set correctly in the OAuth settings.',
|
|
'Or, that you have specified a signing key in your OAuth provider.',
|
|
].join(' '),
|
|
);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async getOAuthClient(config: SystemConfig) {
|
|
const { enabled, clientId, clientSecret, issuerUrl, signingAlgorithm, profileSigningAlgorithm } = config.oauth;
|
|
|
|
if (!enabled) {
|
|
throw new BadRequestException('OAuth2 is not enabled');
|
|
}
|
|
|
|
try {
|
|
const issuer = await Issuer.discover(issuerUrl);
|
|
return new issuer.Client({
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
response_types: ['code'],
|
|
userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm,
|
|
id_token_signed_response_alg: signingAlgorithm,
|
|
});
|
|
} catch (error: any | AggregateError) {
|
|
this.logger.error(`Error in OAuth discovery: ${error}`, error?.stack, error?.errors);
|
|
throw new InternalServerErrorException(`Error in OAuth discovery: ${error}`, { cause: error });
|
|
}
|
|
}
|
|
|
|
private normalize(config: SystemConfig, redirectUri: string) {
|
|
const isMobile = redirectUri.startsWith(MOBILE_REDIRECT);
|
|
const { mobileRedirectUri, mobileOverrideEnabled } = config.oauth;
|
|
if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
|
|
return mobileRedirectUri;
|
|
}
|
|
return redirectUri;
|
|
}
|
|
|
|
private getBearerToken(headers: IncomingHttpHeaders): string | null {
|
|
const [type, token] = (headers.authorization || '').split(' ');
|
|
if (type.toLowerCase() === 'bearer') {
|
|
return token;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private getCookieToken(headers: IncomingHttpHeaders): string | null {
|
|
const cookies = cookieParser.parse(headers.cookie || '');
|
|
return cookies[ImmichCookie.ACCESS_TOKEN] || null;
|
|
}
|
|
|
|
async validateSharedLink(key: string | string[]): Promise<AuthDto> {
|
|
key = Array.isArray(key) ? key[0] : key;
|
|
|
|
const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url');
|
|
const sharedLink = await this.sharedLinkRepository.getByKey(bytes);
|
|
if (sharedLink && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date())) {
|
|
const user = sharedLink.user;
|
|
if (user) {
|
|
return { user, sharedLink };
|
|
}
|
|
}
|
|
throw new UnauthorizedException('Invalid share key');
|
|
}
|
|
|
|
private async validateApiKey(key: string): Promise<AuthDto> {
|
|
const hashedKey = this.cryptoRepository.hashSha256(key);
|
|
const apiKey = await this.keyRepository.getKey(hashedKey);
|
|
if (apiKey?.user) {
|
|
return { user: apiKey.user, apiKey };
|
|
}
|
|
|
|
throw new UnauthorizedException('Invalid API key');
|
|
}
|
|
|
|
private validatePassword(inputPassword: string, user: UserEntity): boolean {
|
|
if (!user || !user.password) {
|
|
return false;
|
|
}
|
|
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
|
|
}
|
|
|
|
private async validateSession(tokenValue: string): Promise<AuthDto> {
|
|
const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
|
|
const session = await this.sessionRepository.getByToken(hashedToken);
|
|
|
|
if (session?.user) {
|
|
const now = DateTime.now();
|
|
const updatedAt = DateTime.fromJSDate(session.updatedAt);
|
|
const diff = now.diff(updatedAt, ['hours']);
|
|
if (diff.hours > 1) {
|
|
await this.sessionRepository.update({ id: session.id, updatedAt: new Date() });
|
|
}
|
|
|
|
return { user: session.user, session: session };
|
|
}
|
|
|
|
throw new UnauthorizedException('Invalid user token');
|
|
}
|
|
|
|
private async createLoginResponse(user: UserEntity, loginDetails: LoginDetails) {
|
|
const key = this.cryptoRepository.newPassword(32);
|
|
const token = this.cryptoRepository.hashSha256(key);
|
|
|
|
await this.sessionRepository.create({
|
|
token,
|
|
user,
|
|
deviceOS: loginDetails.deviceOS,
|
|
deviceType: loginDetails.deviceType,
|
|
});
|
|
|
|
return mapLoginResponse(user, key);
|
|
}
|
|
|
|
private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T {
|
|
const value = profile[options.key as keyof OAuthProfile];
|
|
return options.isValid(value) ? (value as T) : options.default;
|
|
}
|
|
}
|