SSO implementiert
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
GoneException,
|
||||
Injectable,
|
||||
Optional,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto';
|
||||
@@ -22,8 +21,8 @@ import {
|
||||
PublicUser,
|
||||
PublicUserSearchResult,
|
||||
} from './auth.types';
|
||||
import { AppEvents } from '../events/app-events';
|
||||
import type { TaskDigestPreference } from '../tasks/task-digest.types';
|
||||
import { OidcProfile, OidcService } from './oidc.service';
|
||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@@ -37,8 +36,8 @@ export class AuthService {
|
||||
process.env.JWT_REFRESH_SECRET ?? 'dev-refresh-secret';
|
||||
|
||||
constructor(
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly oidcService: OidcService,
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly usersRepository: Repository<UserEntity>,
|
||||
@InjectRepository(RefreshTokenEntity)
|
||||
@@ -50,145 +49,46 @@ export class AuthService {
|
||||
async register(
|
||||
registerDto: RegisterDto,
|
||||
): Promise<{ message: string; user: PublicUser }> {
|
||||
const email = this.normalizeEmail(registerDto.email);
|
||||
const password = this.requirePassword(registerDto.password);
|
||||
const name = this.normalizeName(registerDto.name);
|
||||
|
||||
const existingUser = await this.usersRepository.findOne({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new ConflictException('Email is already registered.');
|
||||
}
|
||||
|
||||
const verificationToken = this.createToken();
|
||||
const user = this.usersRepository.create({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
name,
|
||||
passwordHash: this.hashPassword(password),
|
||||
verificationToken,
|
||||
verified: false,
|
||||
});
|
||||
const savedUser = await this.usersRepository.save(user);
|
||||
|
||||
await this.auditLogService?.record({
|
||||
actorUserId: savedUser.id,
|
||||
actorEmail: savedUser.email,
|
||||
action: 'user.registered',
|
||||
entityType: 'user',
|
||||
entityId: savedUser.id,
|
||||
metadata: { verified: savedUser.verified },
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(AppEvents.UserRegistered, {
|
||||
email,
|
||||
verificationUrl: this.createVerificationUrl(verificationToken),
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Registration successful. Please verify your email address.',
|
||||
user: this.toPublicUser(savedUser),
|
||||
};
|
||||
void registerDto;
|
||||
throw new GoneException('Registration is handled by the SSO provider.');
|
||||
}
|
||||
|
||||
async verifyEmail(
|
||||
token?: string,
|
||||
): Promise<{ message: string; user: PublicUser }> {
|
||||
if (!token) {
|
||||
throw new BadRequestException('Verification token is required.');
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOne({
|
||||
where: { verificationToken: token },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Verification token is invalid.');
|
||||
}
|
||||
|
||||
user.verified = true;
|
||||
user.verificationToken = null;
|
||||
try {
|
||||
const savedUser = await this.usersRepository.save(user);
|
||||
|
||||
await this.auditLogService?.record({
|
||||
actorUserId: savedUser.id,
|
||||
actorEmail: savedUser.email,
|
||||
action: 'user.email_verified',
|
||||
entityType: 'user',
|
||||
entityId: savedUser.id,
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Email verified successfully.',
|
||||
user: this.toPublicUser(savedUser),
|
||||
};
|
||||
} catch {
|
||||
throw new BadRequestException('user not saved.');
|
||||
}
|
||||
void token;
|
||||
throw new GoneException('Email verification is no longer required.');
|
||||
}
|
||||
|
||||
async resendVerificationEmail(
|
||||
resendVerificationDto: ResendVerificationDto,
|
||||
): Promise<{ message: string }> {
|
||||
const email = this.normalizeEmail(resendVerificationDto.email);
|
||||
const message =
|
||||
'Falls ein unverifiziertes Konto mit dieser E-Mail existiert, wurde eine neue Verifizierungsmail versendet.';
|
||||
const user = await this.usersRepository.findOne({ where: { email } });
|
||||
|
||||
if (!user || user.verified) {
|
||||
return { message };
|
||||
}
|
||||
|
||||
user.verificationToken = this.createToken();
|
||||
const savedUser = await this.usersRepository.save(user);
|
||||
|
||||
await this.auditLogService?.record({
|
||||
actorUserId: savedUser.id,
|
||||
actorEmail: savedUser.email,
|
||||
action: 'user.verification_resent',
|
||||
entityType: 'user',
|
||||
entityId: savedUser.id,
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(AppEvents.UserRegistered, {
|
||||
email: savedUser.email,
|
||||
verificationUrl: this.createVerificationUrl(savedUser.verificationToken!),
|
||||
});
|
||||
|
||||
return { message };
|
||||
void resendVerificationDto;
|
||||
throw new GoneException('Email verification is no longer required.');
|
||||
}
|
||||
|
||||
async login(loginDto: LoginDto): Promise<AuthTokenResponse> {
|
||||
const email = this.normalizeEmail(loginDto.email);
|
||||
const password = this.requirePassword(loginDto.password);
|
||||
const user = await this.usersRepository.findOne({ where: { email } });
|
||||
void loginDto;
|
||||
throw new GoneException('Login is handled by SSO.');
|
||||
}
|
||||
|
||||
if (!user || !this.passwordMatches(password, user.passwordHash)) {
|
||||
await this.auditLogService?.record({
|
||||
actorEmail: email,
|
||||
action: 'user.login_failed',
|
||||
entityType: 'user',
|
||||
entityId: user?.id,
|
||||
metadata: { reason: 'invalid_credentials' },
|
||||
});
|
||||
throw new UnauthorizedException('Invalid email or password.');
|
||||
}
|
||||
|
||||
if (!user.verified) {
|
||||
await this.auditLogService?.record({
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
action: 'user.login_failed',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
metadata: { reason: 'email_not_verified' },
|
||||
});
|
||||
throw new UnauthorizedException('Please verify your email before login.');
|
||||
}
|
||||
startSsoLogin(): Promise<string> {
|
||||
return this.oidcService.createAuthorizationUrl();
|
||||
}
|
||||
|
||||
async completeSsoLogin(
|
||||
code?: string,
|
||||
state?: string,
|
||||
): Promise<AuthTokenResponse> {
|
||||
const profile = await this.oidcService.exchangeCallback(code, state);
|
||||
const existingUser =
|
||||
(await this.usersRepository.findOne({
|
||||
where: { oidcSubject: profile.subject },
|
||||
})) ??
|
||||
(await this.usersRepository.findOne({
|
||||
where: { email: this.normalizeEmail(profile.email) },
|
||||
}));
|
||||
const user = await this.syncOidcUser(profile, existingUser);
|
||||
const response = {
|
||||
...(await this.createAuthTokens(user)),
|
||||
user: this.toPublicUser(user),
|
||||
@@ -200,6 +100,7 @@ export class AuthService {
|
||||
action: 'user.login_succeeded',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
metadata: { directory: 'oidc', oidcSubject: user.oidcSubject },
|
||||
});
|
||||
|
||||
return response;
|
||||
@@ -227,7 +128,7 @@ export class AuthService {
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user || !user.verified) {
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Refresh token is invalid.');
|
||||
}
|
||||
|
||||
@@ -263,7 +164,7 @@ export class AuthService {
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user || !user.verified) {
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Access token is invalid.');
|
||||
}
|
||||
|
||||
@@ -309,10 +210,7 @@ export class AuthService {
|
||||
|
||||
const pattern = `%${normalizedQuery}%`;
|
||||
const users = await this.usersRepository.find({
|
||||
where: [
|
||||
{ verified: true, email: Like(pattern) },
|
||||
{ verified: true, name: Like(pattern) },
|
||||
],
|
||||
where: [{ email: Like(pattern) }, { name: Like(pattern) }],
|
||||
order: { email: 'ASC' },
|
||||
take: 10,
|
||||
});
|
||||
@@ -414,30 +312,35 @@ export class AuthService {
|
||||
throw new BadRequestException('Task digest preference is invalid.');
|
||||
}
|
||||
|
||||
private requirePassword(password?: string): string {
|
||||
if (!password || password.length < 8) {
|
||||
throw new BadRequestException(
|
||||
'Password must contain at least 8 characters.',
|
||||
);
|
||||
}
|
||||
private async syncOidcUser(
|
||||
profile: OidcProfile,
|
||||
existingUser?: UserEntity | null,
|
||||
): Promise<UserEntity> {
|
||||
const user =
|
||||
existingUser ??
|
||||
this.usersRepository.create({
|
||||
id: randomUUID(),
|
||||
onboardingCompleted: false,
|
||||
taskDigestPreference: 'both',
|
||||
});
|
||||
|
||||
return password;
|
||||
user.email = this.normalizeEmail(profile.email);
|
||||
user.name = this.normalizeName(profile.name);
|
||||
user.oidcSubject = profile.subject;
|
||||
user.onboardingCompleted = user.onboardingCompleted === true;
|
||||
user.taskDigestPreference = user.taskDigestPreference ?? 'both';
|
||||
|
||||
return this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
private hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const hash = scryptSync(password, salt, 64).toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
private passwordMatches(password: string, passwordHash: string): boolean {
|
||||
const [salt, storedHash] = passwordHash.split(':');
|
||||
private secretMatches(secret: string, storedSecretHash: string): boolean {
|
||||
const [salt, storedHash] = storedSecretHash.split(':');
|
||||
|
||||
if (!salt || !storedHash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attemptedHash = scryptSync(password, salt, 64);
|
||||
const attemptedHash = scryptSync(secret, salt, 64);
|
||||
const storedHashBuffer = Buffer.from(storedHash, 'hex');
|
||||
|
||||
return (
|
||||
@@ -513,16 +416,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
private tokenMatches(token: string, tokenHash: string): boolean {
|
||||
return this.passwordMatches(token, tokenHash);
|
||||
}
|
||||
|
||||
private createToken(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
private createVerificationUrl(token: string): string {
|
||||
const clientUrl = process.env.CLIENT_URL ?? 'http://localhost:4200';
|
||||
return `${clientUrl}/verify-email?token=${token}`;
|
||||
return this.secretMatches(token, tokenHash);
|
||||
}
|
||||
|
||||
private toPublicUser(user: UserEntity): PublicUser {
|
||||
@@ -530,7 +424,6 @@ export class AuthService {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name ?? undefined,
|
||||
verified: user.verified,
|
||||
onboardingCompleted: user.onboardingCompleted === true,
|
||||
taskDigestPreference: user.taskDigestPreference ?? 'both',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user