Initial commit: NestJS backend for Child Gold Land
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { Inject, Injectable, Logger, OnModuleInit, forwardRef } from '@nestjs/common'
|
||||
import { AuthService } from './auth.service'
|
||||
|
||||
@Injectable()
|
||||
export class AuthSeedService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AuthSeedService.name)
|
||||
|
||||
constructor(@Inject(forwardRef(() => AuthService)) private readonly auth: AuthService) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.auth.seedDemoAccount()
|
||||
this.logger.log('Auth seed completed (demo account if missing)')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Body, Controller, HttpCode, Inject, Post, forwardRef } from '@nestjs/common'
|
||||
import { Public } from '../common/decorators/public.decorator'
|
||||
import { strictBodyPipe } from '../common/pipes/strict-body.pipe'
|
||||
import { AuthService } from './auth.service'
|
||||
import { LoginDto } from './dto/login.dto'
|
||||
import { RegisterDto } from './dto/register.dto'
|
||||
|
||||
@Controller('api/auth')
|
||||
export class AuthController {
|
||||
constructor(@Inject(forwardRef(() => AuthService)) private readonly auth: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@Post('register')
|
||||
register(@Body(strictBodyPipe(RegisterDto)) dto: RegisterDto) {
|
||||
return this.auth.register(dto.username, dto.password, dto.name)
|
||||
}
|
||||
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@Post('login')
|
||||
login(@Body(strictBodyPipe(LoginDto)) dto: LoginDto) {
|
||||
return this.auth.login(dto.username, dto.password)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Module, forwardRef } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { APP_GUARD } from '@nestjs/core'
|
||||
import { JwtModule } from '@nestjs/jwt'
|
||||
import { UsersModule } from '../users/users.module'
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'
|
||||
import { AuthController } from './auth.controller'
|
||||
import { AuthService } from './auth.service'
|
||||
import { AuthSeedService } from './auth-seed.service'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
forwardRef(() => UsersModule),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_SECRET'),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
AuthSeedService,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
} from '@nestjs/common'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { DatabaseService } from '../database/database.service'
|
||||
import { createDefaultUserState } from '../shared/catalog/default-user-state'
|
||||
import type { AuthResponse } from '../shared/types/user.types'
|
||||
import { UsersService } from '../users/users.service'
|
||||
|
||||
export interface AuthAccount {
|
||||
id: string
|
||||
username: string
|
||||
passwordHash: string
|
||||
userId: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@Inject(DatabaseService) private readonly database: DatabaseService,
|
||||
@Inject(forwardRef(() => UsersService))
|
||||
private readonly users: UsersService,
|
||||
@Inject(JwtService) private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
register(username: string, password: string, name?: string): AuthResponse {
|
||||
const account = this.createAuthAccount(username, password, name ?? username)
|
||||
return this.buildAuthResponse(account.userId, account.username)
|
||||
}
|
||||
|
||||
login(username: string, password: string): AuthResponse {
|
||||
const account = this.verifyLogin(username, password)
|
||||
return this.buildAuthResponse(account.userId, account.username)
|
||||
}
|
||||
|
||||
updatePassword(userId: string, currentPassword: string, newPassword: string): void {
|
||||
const account = this.findAccountByUserId(userId)
|
||||
if (!account) {
|
||||
throw new NotFoundException('حساب کاربری پیدا نشد')
|
||||
}
|
||||
if (!bcrypt.compareSync(currentPassword, account.passwordHash)) {
|
||||
throw new UnauthorizedException('رمز عبور فعلی اشتباه است')
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
throw new BadRequestException('رمز عبور جدید باید حداقل ۶ کاراکتر باشد')
|
||||
}
|
||||
|
||||
this.database
|
||||
.getDb()
|
||||
.prepare('UPDATE auth_accounts SET password_hash = ? WHERE user_id = ?')
|
||||
.run(bcrypt.hashSync(newPassword, 10), userId)
|
||||
}
|
||||
|
||||
findAccountByUsername(username: string): AuthAccount | null {
|
||||
const row = this.database
|
||||
.getDb()
|
||||
.prepare(
|
||||
'SELECT id, username, password_hash, user_id, created_at FROM auth_accounts WHERE username = ?',
|
||||
)
|
||||
.get(username.trim().toLowerCase()) as
|
||||
| {
|
||||
id: string
|
||||
username: string
|
||||
password_hash: string
|
||||
user_id: string
|
||||
created_at: string
|
||||
}
|
||||
| undefined
|
||||
|
||||
return row ? this.rowToAccount(row) : null
|
||||
}
|
||||
|
||||
findAccountByUserId(userId: string): AuthAccount | null {
|
||||
const row = this.database
|
||||
.getDb()
|
||||
.prepare(
|
||||
'SELECT id, username, password_hash, user_id, created_at FROM auth_accounts WHERE user_id = ?',
|
||||
)
|
||||
.get(userId) as
|
||||
| {
|
||||
id: string
|
||||
username: string
|
||||
password_hash: string
|
||||
user_id: string
|
||||
created_at: string
|
||||
}
|
||||
| undefined
|
||||
|
||||
return row ? this.rowToAccount(row) : null
|
||||
}
|
||||
|
||||
seedDemoAccount(): void {
|
||||
const demo = this.findAccountByUsername('demo')
|
||||
if (demo) return
|
||||
|
||||
this.users.ensureUser('demo')
|
||||
const account: AuthAccount = {
|
||||
id: randomUUID(),
|
||||
username: 'demo',
|
||||
passwordHash: bcrypt.hashSync('123456', 10),
|
||||
userId: 'demo',
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
this.database
|
||||
.getDb()
|
||||
.prepare(
|
||||
'INSERT INTO auth_accounts (id, username, password_hash, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
)
|
||||
.run(account.id, account.username, account.passwordHash, account.userId, account.createdAt)
|
||||
}
|
||||
|
||||
private createAuthAccount(
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
): AuthAccount {
|
||||
const normalized = username.trim().toLowerCase()
|
||||
if (normalized.length < 3) {
|
||||
throw new BadRequestException('نام کاربری باید حداقل ۳ کاراکتر باشد')
|
||||
}
|
||||
if (password.length < 6) {
|
||||
throw new BadRequestException('رمز عبور باید حداقل ۶ کاراکتر باشد')
|
||||
}
|
||||
if (this.findAccountByUsername(normalized)) {
|
||||
throw new ConflictException('این نام کاربری قبلاً ثبت شده')
|
||||
}
|
||||
|
||||
const userId = randomUUID()
|
||||
const state = createDefaultUserState(userId)
|
||||
state.name = displayName.trim() || username.trim()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const account: AuthAccount = {
|
||||
id: randomUUID(),
|
||||
username: normalized,
|
||||
passwordHash: bcrypt.hashSync(password, 10),
|
||||
userId,
|
||||
createdAt: now,
|
||||
}
|
||||
|
||||
this.database.transaction(() => {
|
||||
this.database
|
||||
.getDb()
|
||||
.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)')
|
||||
.run(userId, JSON.stringify(state), now)
|
||||
|
||||
this.database
|
||||
.getDb()
|
||||
.prepare(
|
||||
'INSERT INTO auth_accounts (id, username, password_hash, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
)
|
||||
.run(account.id, account.username, account.passwordHash, account.userId, account.createdAt)
|
||||
})
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
private verifyLogin(username: string, password: string): AuthAccount {
|
||||
const account = this.findAccountByUsername(username)
|
||||
if (!account || !bcrypt.compareSync(password, account.passwordHash)) {
|
||||
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است')
|
||||
}
|
||||
this.users.ensureUser(account.userId)
|
||||
return account
|
||||
}
|
||||
|
||||
private buildAuthResponse(userId: string, username: string): AuthResponse {
|
||||
const state = this.users.getUserState(userId)
|
||||
return {
|
||||
token: this.jwt.sign({ userId }),
|
||||
user: {
|
||||
id: state.id,
|
||||
name: state.name,
|
||||
username,
|
||||
},
|
||||
me: this.users.toMeResponse(state),
|
||||
}
|
||||
}
|
||||
|
||||
private rowToAccount(row: {
|
||||
id: string
|
||||
username: string
|
||||
password_hash: string
|
||||
user_id: string
|
||||
created_at: string
|
||||
}): AuthAccount {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
userId: row.user_id,
|
||||
createdAt: row.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsString } from 'class-validator'
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
username!: string
|
||||
|
||||
@IsString()
|
||||
password!: string
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsOptional, IsString } from 'class-validator'
|
||||
|
||||
export class RegisterDto {
|
||||
@IsString()
|
||||
username!: string
|
||||
|
||||
@IsString()
|
||||
password!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string
|
||||
}
|
||||
Reference in New Issue
Block a user