Files
corrosion-admin-panel/backend-nest/src/modules/auth/auth.service.ts
Vantz Stockwell e849d7803c
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
fix: JWT tokens expire instantly + double /api prefix in analytics
- Auth service used flat env var names (JWT_SECRET, JWT_ACCESS_EXPIRY_SECONDS)
  but @nestjs/config nests them under jwt.* — configService.get() returned
  undefined, so expiresIn was 0 and tokens expired on issue (iat === exp)
- JWT strategy had same bug for secretOrKey
- AnalyticsView passed /api/analytics/... to useApi which already prepends /api,
  resulting in /api/api/analytics/... (404)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 21:40:32 -05:00

365 lines
9.4 KiB
TypeScript

import {
Injectable,
UnauthorizedException,
ConflictException,
BadRequestException,
NotFoundException,
} 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 {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
@InjectRepository(License)
private licenseRepository: Repository<License>,
private jwtService: JwtService,
private configService: ConfigService,
) {}
async register(dto: RegisterDto) {
// Check if user already exists
const existingUser = await this.userRepository.findOne({
where: [{ email: dto.email }, { username: dto.username }],
});
if (existingUser) {
if (existingUser.email === dto.email) {
throw new ConflictException('Email already registered');
}
throw new ConflictException('Username already taken');
}
// Hash password
const password_hash = await argon2.hash(dto.password);
// Create user
const user = this.userRepository.create({
email: dto.email,
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_key: licenseKey,
},
};
}
async login(dto: LoginDto) {
// Find user by email
const user = await this.userRepository.findOne({
where: { email: dto.email },
});
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_key: license?.license_key,
},
};
}
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 access token
const accessToken = await this.jwtService.signAsync(
{
sub: user.id,
email: user.email,
username: user.username,
is_super_admin: user.is_super_admin,
},
{
secret: this.configService.get<string>('jwt.secret'),
expiresIn: this.configService.get<number>('jwt.accessExpirySeconds') || 900,
},
);
return {
access_token: accessToken,
};
} 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) {
// Stub - SMTP integration later
console.log(`Password reset requested for: ${email}`);
// In production, generate reset token, save to DB, send email
return {
message: 'If an account with that email exists, a password reset link has been sent.',
};
}
async resetPassword(token: string, password: string) {
// Stub - SMTP integration later
console.log(`Password reset with token: ${token}`);
// In production, validate token, update password
return {
message: 'Password has been reset successfully.',
};
}
// 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}`;
}
}