feat(center-auth): implement register/login/refresh/logout with bcrypt and DTO validation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
140
Fabric.Backend.Center/src/auth/auth.service.ts
Normal file
140
Fabric.Backend.Center/src/auth/auth.service.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
10
Fabric.Backend.Center/src/auth/dto.login.dto.ts
Normal file
10
Fabric.Backend.Center/src/auth/dto.login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
7
Fabric.Backend.Center/src/auth/dto.logout.dto.ts
Normal file
7
Fabric.Backend.Center/src/auth/dto.logout.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LogoutDto {
|
||||
@IsString()
|
||||
@MinLength(16)
|
||||
refreshToken!: string;
|
||||
}
|
||||
7
Fabric.Backend.Center/src/auth/dto.refresh.dto.ts
Normal file
7
Fabric.Backend.Center/src/auth/dto.refresh.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class RefreshDto {
|
||||
@IsString()
|
||||
@MinLength(16)
|
||||
refreshToken!: string;
|
||||
}
|
||||
10
Fabric.Backend.Center/src/auth/dto.register.dto.ts
Normal file
10
Fabric.Backend.Center/src/auth/dto.register.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
@@ -11,6 +11,9 @@ export class User {
|
||||
@Column()
|
||||
passwordHash!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
refreshTokenHash!: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user