feat(center-auth): implement register/login/refresh/logout with bcrypt and DTO validation

This commit is contained in:
nav
2026-05-12 08:47:44 +00:00
parent 97528ce2c5
commit 3ad8cc3a56
12 changed files with 404 additions and 13 deletions

View File

@@ -1,19 +1,31 @@
import { Body, Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto.register.dto';
import { LoginDto } from './dto.login.dto';
import { RefreshDto } from './dto.refresh.dto';
import { LogoutDto } from './dto.logout.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
register(@Body() body: Record<string, unknown>) {
return { status: 'todo', action: 'register', received: body };
register(@Body() body: RegisterDto) {
return this.authService.register(body);
}
@Post('login')
login(@Body() body: Record<string, unknown>) {
return { status: 'todo', action: 'login', received: body };
login(@Body() body: LoginDto) {
return this.authService.login(body);
}
@Post('refresh')
refresh(@Body() body: Record<string, unknown>) {
return { status: 'todo', action: 'refresh', received: body };
refresh(@Body() body: RefreshDto) {
return this.authService.refresh(body.refreshToken);
}
@Post('logout')
logout(@Body() body: LogoutDto) {
return this.authService.logout(body.refreshToken);
}
}

View File

@@ -1,7 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { User } from '../entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,140 @@
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';
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>,
) {}
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);
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);
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);
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);
return { status: 'ok' };
}
}

View File

@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class LogoutDto {
@IsString()
@MinLength(16)
refreshToken!: string;
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class RefreshDto {
@IsString()
@MinLength(16)
refreshToken!: string;
}

View File

@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class RegisterDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}

View File

@@ -11,6 +11,9 @@ export class User {
@Column()
passwordHash!: string;
@Column({ type: 'varchar', length: 255, nullable: true })
refreshTokenHash!: string | null;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -1,4 +1,5 @@
import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
@@ -26,6 +27,13 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
const port = process.env.PORT ? Number(process.env.PORT) : 7001;
await app.listen(port);
console.log(`Fabric.Backend.Center listening on :${port}`);