174 lines
5.1 KiB
TypeScript
174 lines
5.1 KiB
TypeScript
import {
|
|
ConflictException,
|
|
Injectable,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import * as jwt from 'jsonwebtoken';
|
|
import { User } from '../entities/user.entity';
|
|
import { RegisterDto } from './dto.register.dto';
|
|
import { LoginDto } from './dto.login.dto';
|
|
import { AuditService } from '../audit/audit.service';
|
|
|
|
function parseDurationToSeconds(input: string, fallbackSeconds: number): number {
|
|
const raw = input.trim();
|
|
if (/^\d+$/.test(raw)) return Number(raw);
|
|
|
|
const m = raw.match(/^(\d+)([smhd])$/i);
|
|
if (!m) return fallbackSeconds;
|
|
|
|
const value = Number(m[1]);
|
|
const unit = m[2].toLowerCase();
|
|
if (unit === 's') return value;
|
|
if (unit === 'm') return value * 60;
|
|
if (unit === 'h') return value * 3600;
|
|
if (unit === 'd') return value * 86400;
|
|
return fallbackSeconds;
|
|
}
|
|
|
|
function signAccessToken(userId: string, email: string): string {
|
|
const secret = process.env.JWT_ACCESS_SECRET as string;
|
|
const expiresIn = parseDurationToSeconds(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m', 900);
|
|
return jwt.sign({ sub: userId, email }, secret, { expiresIn });
|
|
}
|
|
|
|
function signRefreshToken(userId: string, email: string): string {
|
|
const secret = process.env.JWT_REFRESH_SECRET as string;
|
|
const expiresIn = parseDurationToSeconds(process.env.JWT_REFRESH_EXPIRES_IN ?? '30d', 2592000);
|
|
return jwt.sign({ sub: userId, email, typ: 'refresh' }, secret, { expiresIn });
|
|
}
|
|
|
|
@Injectable()
|
|
export class AuthService {
|
|
constructor(
|
|
@InjectRepository(User)
|
|
private readonly userRepo: Repository<User>,
|
|
private readonly audit: AuditService,
|
|
) {}
|
|
|
|
async register(input: RegisterDto) {
|
|
const exists = await this.userRepo.findOne({ where: { email: input.email } });
|
|
if (exists) {
|
|
throw new ConflictException('email already exists');
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(input.password, 10);
|
|
const user = this.userRepo.create({
|
|
email: input.email,
|
|
passwordHash,
|
|
refreshTokenHash: null,
|
|
});
|
|
const saved = await this.userRepo.save(user);
|
|
|
|
await this.audit.write({
|
|
action: 'auth.register',
|
|
actorId: saved.id,
|
|
targetType: 'user',
|
|
targetId: saved.id,
|
|
detail: JSON.stringify({ email: saved.email }),
|
|
});
|
|
|
|
return {
|
|
id: saved.id,
|
|
email: saved.email,
|
|
createdAt: saved.createdAt,
|
|
};
|
|
}
|
|
|
|
async login(input: LoginDto) {
|
|
const user = await this.userRepo.findOne({ where: { email: input.email } });
|
|
if (!user) throw new UnauthorizedException('invalid credentials');
|
|
|
|
const ok = await bcrypt.compare(input.password, user.passwordHash);
|
|
if (!ok) throw new UnauthorizedException('invalid credentials');
|
|
|
|
const accessToken = signAccessToken(user.id, user.email);
|
|
const refreshToken = signRefreshToken(user.id, user.email);
|
|
user.refreshTokenHash = await bcrypt.hash(refreshToken, 10);
|
|
await this.userRepo.save(user);
|
|
|
|
await this.audit.write({
|
|
action: 'auth.login',
|
|
actorId: user.id,
|
|
targetType: 'user',
|
|
targetId: user.id,
|
|
detail: JSON.stringify({ email: user.email }),
|
|
});
|
|
|
|
return {
|
|
accessToken,
|
|
refreshToken,
|
|
tokenType: 'Bearer',
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
},
|
|
};
|
|
}
|
|
|
|
async refresh(refreshToken: string) {
|
|
let payload: jwt.JwtPayload;
|
|
try {
|
|
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET as string) as jwt.JwtPayload;
|
|
} catch {
|
|
throw new UnauthorizedException('invalid refresh token');
|
|
}
|
|
|
|
const userId = String(payload.sub ?? '');
|
|
const user = await this.userRepo.findOne({ where: { id: userId } });
|
|
if (!user || !user.refreshTokenHash) {
|
|
throw new UnauthorizedException('invalid refresh token');
|
|
}
|
|
|
|
const tokenOk = await bcrypt.compare(refreshToken, user.refreshTokenHash);
|
|
if (!tokenOk) throw new UnauthorizedException('invalid refresh token');
|
|
|
|
const newAccessToken = signAccessToken(user.id, user.email);
|
|
const newRefreshToken = signRefreshToken(user.id, user.email);
|
|
user.refreshTokenHash = await bcrypt.hash(newRefreshToken, 10);
|
|
await this.userRepo.save(user);
|
|
|
|
await this.audit.write({
|
|
action: 'auth.refresh',
|
|
actorId: user.id,
|
|
targetType: 'user',
|
|
targetId: user.id,
|
|
detail: null,
|
|
});
|
|
|
|
return {
|
|
accessToken: newAccessToken,
|
|
refreshToken: newRefreshToken,
|
|
tokenType: 'Bearer',
|
|
};
|
|
}
|
|
|
|
async logout(refreshToken: string) {
|
|
let payload: jwt.JwtPayload;
|
|
try {
|
|
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET as string) as jwt.JwtPayload;
|
|
} catch {
|
|
return { status: 'ok' };
|
|
}
|
|
|
|
const userId = String(payload.sub ?? '');
|
|
if (!userId) return { status: 'ok' };
|
|
|
|
const user = await this.userRepo.findOne({ where: { id: userId } });
|
|
if (!user) return { status: 'ok' };
|
|
|
|
user.refreshTokenHash = null;
|
|
await this.userRepo.save(user);
|
|
await this.audit.write({
|
|
action: 'auth.logout',
|
|
actorId: user.id,
|
|
targetType: 'user',
|
|
targetId: user.id,
|
|
detail: null,
|
|
});
|
|
return { status: 'ok' };
|
|
}
|
|
}
|