All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- admin.service.ts: createLicense() now uses CORR-XXXX-XXXX-XXXX format instead of raw hex hash - admin.service.ts: getLicenses() flattens owner_email in response to match frontend expected shape - auth.service.ts: Login/register responses now include full license object so frontend can populate auth store - auth.service.ts: Email lookups are case-insensitive (LOWER()) to prevent duplicate accounts from case variations - LoginView/RegisterView: Call setLicense() after setAuth() - AdminLicenses: Handle null expires_at (was showing Dec 31, 1969), fix nullable types, fix query param name (per_page → limit) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
384 lines
10 KiB
TypeScript
384 lines
10 KiB
TypeScript
import {
|
|
Injectable,
|
|
UnauthorizedException,
|
|
ConflictException,
|
|
BadRequestException,
|
|
NotFoundException,
|
|
NotImplementedException,
|
|
Logger,
|
|
} from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import * as argon2 from 'argon2';
|
|
import * as OTPAuth from 'otpauth';
|
|
import * as QRCode from 'qrcode';
|
|
import { randomBytes } from 'crypto';
|
|
import { User } from '../../entities/user.entity';
|
|
import { License } from '../../entities/license.entity';
|
|
import { RegisterDto } from './dto/register.dto';
|
|
import { LoginDto } from './dto/login.dto';
|
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
|
|
|
@Injectable()
|
|
export class AuthService {
|
|
private readonly logger = new Logger(AuthService.name);
|
|
|
|
constructor(
|
|
@InjectRepository(User)
|
|
private userRepository: Repository<User>,
|
|
@InjectRepository(License)
|
|
private licenseRepository: Repository<License>,
|
|
private jwtService: JwtService,
|
|
private configService: ConfigService,
|
|
) {}
|
|
|
|
async register(dto: RegisterDto) {
|
|
// Normalize email to lowercase to prevent case-sensitive duplicates
|
|
const normalizedEmail = dto.email.toLowerCase();
|
|
|
|
// Check if user already exists
|
|
const existingUser = await this.userRepository
|
|
.createQueryBuilder('user')
|
|
.where('LOWER(user.email) = :email OR user.username = :username', {
|
|
email: normalizedEmail,
|
|
username: dto.username,
|
|
})
|
|
.getOne();
|
|
|
|
if (existingUser) {
|
|
if (existingUser.email.toLowerCase() === normalizedEmail) {
|
|
throw new ConflictException('Email already registered');
|
|
}
|
|
throw new ConflictException('Username already taken');
|
|
}
|
|
|
|
// Hash password
|
|
const password_hash = await argon2.hash(dto.password);
|
|
|
|
// Create user (email stored lowercase)
|
|
const user = this.userRepository.create({
|
|
email: normalizedEmail,
|
|
username: dto.username,
|
|
password_hash,
|
|
email_verified: false,
|
|
is_super_admin: false,
|
|
});
|
|
|
|
await this.userRepository.save(user);
|
|
|
|
// Create license for the user
|
|
const licenseKey = this.generateLicenseKey();
|
|
const license = this.licenseRepository.create({
|
|
license_key: licenseKey,
|
|
owner_user_id: user.id,
|
|
status: 'active',
|
|
modules_enabled: [],
|
|
webstore_active: false,
|
|
});
|
|
|
|
await this.licenseRepository.save(license);
|
|
|
|
// Generate tokens
|
|
const tokens = await this.generateTokens(user);
|
|
|
|
return {
|
|
...tokens,
|
|
requires_totp: false,
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
username: user.username,
|
|
is_super_admin: user.is_super_admin,
|
|
totp_enabled: user.totp_enabled,
|
|
},
|
|
license: {
|
|
id: license.id,
|
|
license_key: license.license_key,
|
|
status: license.status,
|
|
server_name: license.server_name ?? null,
|
|
subdomain: license.subdomain ?? null,
|
|
custom_domain: license.custom_domain ?? null,
|
|
modules_enabled: license.modules_enabled,
|
|
webstore_active: license.webstore_active,
|
|
created_at: license.created_at,
|
|
expires_at: license.expires_at ?? null,
|
|
},
|
|
};
|
|
}
|
|
|
|
async login(dto: LoginDto) {
|
|
// Find user by email (case-insensitive)
|
|
const user = await this.userRepository
|
|
.createQueryBuilder('user')
|
|
.where('LOWER(user.email) = :email', { email: dto.email.toLowerCase() })
|
|
.getOne();
|
|
|
|
if (!user) {
|
|
throw new UnauthorizedException('Invalid credentials');
|
|
}
|
|
|
|
// Verify password
|
|
const passwordValid = await argon2.verify(user.password_hash, dto.password);
|
|
if (!passwordValid) {
|
|
throw new UnauthorizedException('Invalid credentials');
|
|
}
|
|
|
|
// Check TOTP if enabled
|
|
if (user.totp_enabled) {
|
|
if (!dto.totp_code) {
|
|
return {
|
|
requires_totp: true,
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
username: user.username,
|
|
},
|
|
};
|
|
}
|
|
|
|
const totpValid = await this.verifyTotpCode(user, dto.totp_code);
|
|
if (!totpValid) {
|
|
throw new UnauthorizedException('Invalid TOTP code');
|
|
}
|
|
}
|
|
|
|
// Generate tokens
|
|
const tokens = await this.generateTokens(user);
|
|
|
|
// Get user's license
|
|
const license = await this.licenseRepository.findOne({
|
|
where: { owner_user_id: user.id },
|
|
});
|
|
|
|
return {
|
|
...tokens,
|
|
requires_totp: false,
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
username: user.username,
|
|
is_super_admin: user.is_super_admin,
|
|
totp_enabled: user.totp_enabled,
|
|
},
|
|
license: license ? {
|
|
id: license.id,
|
|
license_key: license.license_key,
|
|
status: license.status,
|
|
server_name: license.server_name,
|
|
subdomain: license.subdomain,
|
|
custom_domain: license.custom_domain,
|
|
modules_enabled: license.modules_enabled,
|
|
webstore_active: license.webstore_active,
|
|
created_at: license.created_at,
|
|
expires_at: license.expires_at,
|
|
} : null,
|
|
};
|
|
}
|
|
|
|
async refresh(refreshToken: string) {
|
|
try {
|
|
const payload = await this.jwtService.verifyAsync(refreshToken, {
|
|
secret: this.configService.get<string>('jwt.secret'),
|
|
});
|
|
|
|
const user = await this.userRepository.findOne({
|
|
where: { id: payload.sub },
|
|
});
|
|
|
|
if (!user) {
|
|
throw new UnauthorizedException('User not found');
|
|
}
|
|
|
|
// Generate new token pair (rotating refresh tokens)
|
|
const tokens = await this.generateTokens(user);
|
|
|
|
return {
|
|
access_token: tokens.access_token,
|
|
refresh_token: tokens.refresh_token,
|
|
};
|
|
} catch (error) {
|
|
throw new UnauthorizedException('Invalid refresh token');
|
|
}
|
|
}
|
|
|
|
async setupTotp(userId: string) {
|
|
const user = await this.userRepository.findOne({
|
|
where: { id: userId },
|
|
});
|
|
|
|
if (!user) {
|
|
throw new NotFoundException('User not found');
|
|
}
|
|
|
|
if (user.totp_enabled) {
|
|
throw new BadRequestException('TOTP already enabled');
|
|
}
|
|
|
|
// Generate TOTP secret
|
|
const secret = new OTPAuth.Secret({ size: 20 });
|
|
const totp = new OTPAuth.TOTP({
|
|
issuer: 'Corrosion',
|
|
label: user.email,
|
|
algorithm: 'SHA1',
|
|
digits: 6,
|
|
period: 30,
|
|
secret,
|
|
});
|
|
|
|
// Save secret to user (but don't enable yet)
|
|
await this.userRepository.update(user.id, {
|
|
totp_secret: secret.base32,
|
|
});
|
|
|
|
// Generate QR code
|
|
const qrCode = await QRCode.toDataURL(totp.toString());
|
|
|
|
return {
|
|
qr_code: qrCode,
|
|
secret: secret.base32,
|
|
};
|
|
}
|
|
|
|
async verifyTotp(userId: string, code: string) {
|
|
const user = await this.userRepository.findOne({
|
|
where: { id: userId },
|
|
});
|
|
|
|
if (!user) {
|
|
throw new NotFoundException('User not found');
|
|
}
|
|
|
|
if (!user.totp_secret) {
|
|
throw new BadRequestException('TOTP not set up');
|
|
}
|
|
|
|
const valid = await this.verifyTotpCode(user, code);
|
|
if (!valid) {
|
|
throw new UnauthorizedException('Invalid TOTP code');
|
|
}
|
|
|
|
// Enable TOTP
|
|
await this.userRepository.update(user.id, {
|
|
totp_enabled: true,
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
};
|
|
}
|
|
|
|
async getProfile(userId: string) {
|
|
const user = await this.userRepository.findOne({
|
|
where: { id: userId },
|
|
select: ['id', 'email', 'username', 'totp_enabled', 'is_super_admin', 'created_at', 'last_login_at'],
|
|
});
|
|
|
|
if (!user) {
|
|
throw new NotFoundException('User not found');
|
|
}
|
|
|
|
// Get user's license
|
|
const license = await this.licenseRepository.findOne({
|
|
where: { owner_user_id: user.id },
|
|
});
|
|
|
|
return {
|
|
...user,
|
|
license_key: license?.license_key,
|
|
};
|
|
}
|
|
|
|
async updateProfile(userId: string, dto: UpdateProfileDto) {
|
|
const user = await this.userRepository.findOne({
|
|
where: { id: userId },
|
|
});
|
|
|
|
if (!user) {
|
|
throw new NotFoundException('User not found');
|
|
}
|
|
|
|
// Check if username is already taken
|
|
if (dto.username && dto.username !== user.username) {
|
|
const existingUser = await this.userRepository.findOne({
|
|
where: { username: dto.username },
|
|
});
|
|
|
|
if (existingUser) {
|
|
throw new ConflictException('Username already taken');
|
|
}
|
|
|
|
await this.userRepository.update(user.id, {
|
|
username: dto.username,
|
|
});
|
|
}
|
|
|
|
return this.getProfile(userId);
|
|
}
|
|
|
|
async forgotPassword(email: string) {
|
|
// SMTP integration pending — returns 200 to avoid leaking account enumeration
|
|
this.logger.warn(`Password reset requested for ${email} — SMTP not yet configured`);
|
|
return {
|
|
message: 'Password reset is not yet configured. Contact your administrator.',
|
|
};
|
|
}
|
|
|
|
async resetPassword(token: string, _password: string) {
|
|
this.logger.warn(`Password reset attempted with token ${token} — SMTP not yet configured`);
|
|
throw new NotImplementedException('Password reset not yet configured');
|
|
}
|
|
|
|
// Helper methods
|
|
|
|
private async generateTokens(user: User) {
|
|
const payload = {
|
|
sub: user.id,
|
|
email: user.email,
|
|
username: user.username,
|
|
is_super_admin: user.is_super_admin,
|
|
};
|
|
|
|
const accessToken = await this.jwtService.signAsync(payload, {
|
|
secret: this.configService.get<string>('jwt.secret'),
|
|
expiresIn: this.configService.get<number>('jwt.accessExpirySeconds') || 900,
|
|
});
|
|
|
|
const refreshToken = await this.jwtService.signAsync(payload, {
|
|
secret: this.configService.get<string>('jwt.secret'),
|
|
expiresIn: this.configService.get<number>('jwt.refreshExpirySeconds') || 604800,
|
|
});
|
|
|
|
return {
|
|
access_token: accessToken,
|
|
refresh_token: refreshToken,
|
|
};
|
|
}
|
|
|
|
private async verifyTotpCode(user: User, code: string): Promise<boolean> {
|
|
if (!user.totp_secret) {
|
|
return false;
|
|
}
|
|
|
|
const totp = new OTPAuth.TOTP({
|
|
issuer: 'Corrosion',
|
|
label: user.email,
|
|
algorithm: 'SHA1',
|
|
digits: 6,
|
|
period: 30,
|
|
secret: OTPAuth.Secret.fromBase32(user.totp_secret),
|
|
});
|
|
|
|
const delta = totp.validate({ token: code, window: 1 });
|
|
return delta !== null;
|
|
}
|
|
|
|
private generateLicenseKey(): string {
|
|
const part1 = randomBytes(2).toString('hex').toUpperCase();
|
|
const part2 = randomBytes(2).toString('hex').toUpperCase();
|
|
const part3 = randomBytes(2).toString('hex').toUpperCase();
|
|
return `CORR-${part1}-${part2}-${part3}`;
|
|
}
|
|
}
|