Initial AI manga platform
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { Body, Controller, Get, Inject, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentUser } from './current-user.decorator';
|
||||
import { LoginDto, RegisterDto } from './auth.dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import type { AuthRequestUser } from './auth.types';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(@Inject(AuthService) private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
register(@Body() dto: RegisterDto) {
|
||||
return this.authService.register(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
logout() {
|
||||
return { logged_out: true };
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
profile(@CurrentUser() user: AuthRequestUser) {
|
||||
return this.authService.getProfile(user);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class ProfileController {
|
||||
constructor(@Inject(AuthService) private readonly authService: AuthService) {}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
profile(@CurrentUser() user: AuthRequestUser) {
|
||||
return this.authService.getProfile(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export class RegisterDto {
|
||||
email?: string;
|
||||
password?: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule, type JwtSignOptions } from '@nestjs/jwt';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthController, ProfileController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
const jwtExpiresIn = (process.env.JWT_EXPIRES_IN ?? '7d') as JwtSignOptions['expiresIn'];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET ?? 'dev_only_change_me',
|
||||
signOptions: {
|
||||
expiresIn: jwtExpiresIn
|
||||
}
|
||||
})
|
||||
],
|
||||
controllers: [AuthController, ProfileController],
|
||||
providers: [AuthService, JwtAuthGuard],
|
||||
exports: [AuthService, JwtAuthGuard, JwtModule]
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthService } from './auth.service';
|
||||
import type { SafeUser } from '../users/user.types';
|
||||
import type { UsersService } from '../users/users.service';
|
||||
|
||||
const safeUser: SafeUser = {
|
||||
id: '1',
|
||||
email: 'user@example.com',
|
||||
phone: null,
|
||||
nickname: 'User',
|
||||
avatar_url: null,
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
wechat_openid: null,
|
||||
created_at: '2026-05-31T00:00:00.000Z'
|
||||
};
|
||||
|
||||
function createPrismaUser(passwordHash: string) {
|
||||
return {
|
||||
id: 1n,
|
||||
email: 'user@example.com',
|
||||
phone: null,
|
||||
password_hash: passwordHash,
|
||||
nickname: 'User',
|
||||
avatar_url: null,
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
wechat_openid: null,
|
||||
last_login_at: null,
|
||||
created_at: new Date('2026-05-31T00:00:00.000Z'),
|
||||
updated_at: new Date('2026-05-31T00:00:00.000Z')
|
||||
};
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
let usersService: Pick<
|
||||
UsersService,
|
||||
'findByEmail' | 'findById' | 'createUser' | 'toSafeUser'
|
||||
>;
|
||||
let jwtService: Pick<JwtService, 'sign'>;
|
||||
let service: AuthService;
|
||||
|
||||
beforeEach(() => {
|
||||
usersService = {
|
||||
findByEmail: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
toSafeUser: vi.fn()
|
||||
};
|
||||
jwtService = {
|
||||
sign: vi.fn(() => 'signed.jwt.token')
|
||||
};
|
||||
service = new AuthService(usersService as UsersService, jwtService as JwtService);
|
||||
});
|
||||
|
||||
it('registers an active user and returns a token', async () => {
|
||||
vi.mocked(usersService.findByEmail).mockResolvedValue(null);
|
||||
vi.mocked(usersService.createUser).mockResolvedValue(safeUser);
|
||||
|
||||
const result = await service.register({
|
||||
email: ' USER@example.com ',
|
||||
password: 'password123',
|
||||
nickname: 'User'
|
||||
});
|
||||
|
||||
expect(usersService.findByEmail).toHaveBeenCalledWith('user@example.com');
|
||||
expect(usersService.createUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: 'user@example.com',
|
||||
nickname: 'User'
|
||||
})
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
access_token: 'signed.jwt.token',
|
||||
token_type: 'Bearer',
|
||||
user: safeUser
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects duplicate email registration', async () => {
|
||||
vi.mocked(usersService.findByEmail).mockResolvedValue(createPrismaUser('hash'));
|
||||
|
||||
await expect(
|
||||
service.register({
|
||||
email: 'user@example.com',
|
||||
password: 'password123'
|
||||
})
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('logs in with a valid password', async () => {
|
||||
vi.mocked(usersService.findByEmail).mockResolvedValue(null);
|
||||
vi.mocked(usersService.createUser).mockResolvedValue(safeUser);
|
||||
|
||||
const registered = await service.register({
|
||||
email: 'user@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
const passwordHash = vi.mocked(usersService.createUser).mock.calls[0]?.[0]
|
||||
.password_hash;
|
||||
|
||||
expect(registered.access_token).toBe('signed.jwt.token');
|
||||
|
||||
vi.mocked(usersService.findByEmail).mockResolvedValue(
|
||||
createPrismaUser(passwordHash)
|
||||
);
|
||||
vi.mocked(usersService.toSafeUser).mockReturnValue(safeUser);
|
||||
|
||||
const result = await service.login({
|
||||
email: 'user@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(result.user).toEqual(safeUser);
|
||||
});
|
||||
|
||||
it('rejects invalid login credentials', async () => {
|
||||
vi.mocked(usersService.findByEmail).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.login({
|
||||
email: 'user@example.com',
|
||||
password: 'password123'
|
||||
})
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { compare, hash } from 'bcryptjs';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import type { LoginDto, RegisterDto } from './auth.dto';
|
||||
import type { AuthResult, AuthRequestUser, JwtPayload } from './auth.types';
|
||||
|
||||
const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@Inject(UsersService)
|
||||
private readonly usersService: UsersService,
|
||||
@Inject(JwtService)
|
||||
private readonly jwtService: JwtService
|
||||
) {}
|
||||
|
||||
async register(dto: RegisterDto): Promise<AuthResult> {
|
||||
const email = this.normalizeEmail(dto.email);
|
||||
const password = this.validatePassword(dto.password);
|
||||
const existing = await this.usersService.findByEmail(email);
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Email already registered');
|
||||
}
|
||||
|
||||
const passwordHash = await hash(password, 12);
|
||||
const user = await this.usersService.createUser({
|
||||
email,
|
||||
password_hash: passwordHash,
|
||||
nickname: this.normalizeOptionalText(dto.nickname)
|
||||
});
|
||||
|
||||
return this.createAuthResult(user);
|
||||
}
|
||||
|
||||
async login(dto: LoginDto): Promise<AuthResult> {
|
||||
const email = this.normalizeEmail(dto.email);
|
||||
const password = this.validatePassword(dto.password);
|
||||
const user = await this.usersService.findByEmail(email);
|
||||
|
||||
if (!user || user.status !== 'active') {
|
||||
throw new UnauthorizedException('Invalid email or password');
|
||||
}
|
||||
|
||||
const passwordMatches = await compare(password, user.password_hash);
|
||||
if (!passwordMatches) {
|
||||
throw new UnauthorizedException('Invalid email or password');
|
||||
}
|
||||
|
||||
return this.createAuthResult(this.usersService.toSafeUser(user));
|
||||
}
|
||||
|
||||
async getProfile(currentUser: AuthRequestUser) {
|
||||
const user = await this.usersService.findById(currentUser.id);
|
||||
|
||||
if (!user || user.status !== 'active') {
|
||||
throw new UnauthorizedException('User is unavailable');
|
||||
}
|
||||
|
||||
return this.usersService.toSafeUser(user);
|
||||
}
|
||||
|
||||
private createAuthResult(user: AuthResult['user']): AuthResult {
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
};
|
||||
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
token_type: 'Bearer',
|
||||
expires_in: process.env.JWT_EXPIRES_IN ?? '7d',
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeEmail(email: string | undefined) {
|
||||
const value = email?.trim().toLowerCase();
|
||||
|
||||
if (!value || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
throw new BadRequestException('Valid email is required');
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private validatePassword(password: string | undefined) {
|
||||
if (!password || password.length < PASSWORD_MIN_LENGTH) {
|
||||
throw new BadRequestException(
|
||||
`Password must be at least ${PASSWORD_MIN_LENGTH} characters`
|
||||
);
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
private normalizeOptionalText(value: string | undefined) {
|
||||
const normalized = value?.trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { SafeUser } from '../users/user.types';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface AuthRequestUser {
|
||||
id: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
access_token: string;
|
||||
token_type: 'Bearer';
|
||||
expires_in: string;
|
||||
user: SafeUser;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { AuthRequestUser } from './auth.types';
|
||||
|
||||
interface RequestWithUser {
|
||||
user?: AuthRequestUser;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
return request.user;
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
function createContext(headers: Record<string, string | undefined>) {
|
||||
const request = { headers };
|
||||
|
||||
return {
|
||||
request,
|
||||
context: {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
it('attaches user payload for valid bearer tokens', async () => {
|
||||
const jwtService = {
|
||||
verifyAsync: vi.fn().mockResolvedValue({
|
||||
sub: '1',
|
||||
email: 'user@example.com',
|
||||
role: 'user'
|
||||
})
|
||||
};
|
||||
const guard = new JwtAuthGuard(jwtService as unknown as JwtService);
|
||||
const { context, request } = createContext({
|
||||
authorization: 'Bearer valid-token'
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context as never)).resolves.toBe(true);
|
||||
expect(request).toMatchObject({
|
||||
user: {
|
||||
id: '1',
|
||||
email: 'user@example.com',
|
||||
role: 'user'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects missing bearer tokens', async () => {
|
||||
const guard = new JwtAuthGuard({ verifyAsync: vi.fn() } as unknown as JwtService);
|
||||
const { context } = createContext({});
|
||||
|
||||
await expect(guard.canActivate(context as never)).rejects.toBeInstanceOf(
|
||||
UnauthorizedException
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { AuthRequestUser, JwtPayload } from './auth.types';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
user?: AuthRequestUser;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(@Inject(JwtService) private readonly jwtService: JwtService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const token = this.extractToken(request.headers.authorization);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Missing bearer token');
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
|
||||
request.user = {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role
|
||||
};
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
|
||||
private extractToken(authorization: string | string[] | undefined) {
|
||||
const header = Array.isArray(authorization) ? authorization[0] : authorization;
|
||||
|
||||
if (!header) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [type, token] = header.split(' ');
|
||||
return type?.toLowerCase() === 'bearer' && token ? token : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import type { AuthRequestUser } from './auth.types';
|
||||
|
||||
export const ADMIN_PERMISSIONS = [
|
||||
'admin:read',
|
||||
'projects:write',
|
||||
'users:read',
|
||||
'users:write',
|
||||
'billing:read',
|
||||
'billing:write',
|
||||
'reviews:read',
|
||||
'reviews:write',
|
||||
'tasks:read',
|
||||
'tasks:write',
|
||||
'providers:read',
|
||||
'providers:write',
|
||||
'costs:read',
|
||||
'settings:read',
|
||||
'settings:write',
|
||||
'audit:read',
|
||||
'audit:export'
|
||||
] as const;
|
||||
|
||||
export type AdminPermission = (typeof ADMIN_PERMISSIONS)[number];
|
||||
|
||||
const ROLE_PERMISSIONS: Record<string, readonly AdminPermission[] | '*'> = {
|
||||
admin: '*',
|
||||
operator: [
|
||||
'admin:read',
|
||||
'projects:write',
|
||||
'users:read',
|
||||
'billing:read',
|
||||
'reviews:read',
|
||||
'reviews:write',
|
||||
'tasks:read',
|
||||
'tasks:write',
|
||||
'providers:read',
|
||||
'costs:read',
|
||||
'audit:read'
|
||||
],
|
||||
finance: [
|
||||
'admin:read',
|
||||
'users:read',
|
||||
'billing:read',
|
||||
'billing:write',
|
||||
'tasks:read',
|
||||
'costs:read',
|
||||
'audit:read',
|
||||
'audit:export'
|
||||
],
|
||||
auditor: [
|
||||
'admin:read',
|
||||
'users:read',
|
||||
'billing:read',
|
||||
'reviews:read',
|
||||
'tasks:read',
|
||||
'providers:read',
|
||||
'costs:read',
|
||||
'settings:read',
|
||||
'audit:read',
|
||||
'audit:export'
|
||||
]
|
||||
};
|
||||
|
||||
export function permissionsForRole(role: string) {
|
||||
const permissions = ROLE_PERMISSIONS[role];
|
||||
|
||||
return permissions === '*' ? [...ADMIN_PERMISSIONS] : [...(permissions ?? [])];
|
||||
}
|
||||
|
||||
export function hasPermission(user: AuthRequestUser, permission: AdminPermission) {
|
||||
return permissionsForRole(user.role).includes(permission);
|
||||
}
|
||||
|
||||
export function assertPermission(user: AuthRequestUser, permission: AdminPermission) {
|
||||
if (!hasPermission(user, permission)) {
|
||||
throw new ForbiddenException(`Permission required: ${permission}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user