AI Implementation feature(823): Landing Page and Login/Register Modal (#11)
This commit was merged in pull request #11.
This commit is contained in:
@@ -13,7 +13,7 @@ import { Request, Response } from 'express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { LoginDtoSchema, RefreshDtoSchema } from './dto/auth.dto';
|
||||
import { LoginDtoSchema, RefreshDtoSchema, RegisterDtoSchema } from './dto/auth.dto';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CsrfMiddleware } from '../../common/middleware/csrf.middleware';
|
||||
import { setRefreshCookie, clearRefreshCookie } from './cookie';
|
||||
@@ -27,6 +27,29 @@ export class AuthController {
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Public player self-registration' })
|
||||
@ApiResponse({ status: 201, description: 'Registration successful, returns login session' })
|
||||
async register(
|
||||
@Body(new ZodValidationPipe(RegisterDtoSchema)) body: {
|
||||
username: string;
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
},
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const ip = (req.ip || req.socket.remoteAddress || 'unknown') as string;
|
||||
const session = await this.auth.registerPlayer(body, ip);
|
||||
setRefreshCookie(res, session.refreshToken, this.config);
|
||||
return {
|
||||
accessToken: session.accessToken,
|
||||
expiresIn: session.expiresIn,
|
||||
user: session.user,
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Login with username and password' })
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -22,6 +23,7 @@ import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
signOptions: { expiresIn: config.get<string>('JWT_ACCESS_TTL', '15m') },
|
||||
}),
|
||||
}),
|
||||
SettingsModule,
|
||||
],
|
||||
providers: [AuthService, JwtStrategy, AdminGuard],
|
||||
controllers: [AuthController],
|
||||
|
||||
@@ -16,8 +16,10 @@ import { ApiError } from '../../common/errors/api-error';
|
||||
import { ERROR_CODES } from '../../common/errors/error-codes';
|
||||
import { LoginBackoffService } from '../../common/services/login-backoff.service';
|
||||
import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service';
|
||||
import { LoginDto, LoginResponse } from './dto/auth.dto';
|
||||
import { LoginDto, LoginResponse, RegisterDto } from './dto/auth.dto';
|
||||
import { validatePassword } from '../../common/utils/password-policy';
|
||||
import { SettingsService } from '../settings/settings.module';
|
||||
import { SETTINGS_KEYS } from '../../config/env.schema';
|
||||
|
||||
const REFRESH_TTL_SECONDS_DEFAULT = 7 * 24 * 60 * 60;
|
||||
|
||||
@@ -34,6 +36,7 @@ export class AuthService implements OnModuleInit {
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly backoff: LoginBackoffService,
|
||||
private readonly registrationRateLimit: RegistrationRateLimitService,
|
||||
private readonly settings: SettingsService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
@@ -130,6 +133,52 @@ export class AuthService implements OnModuleInit {
|
||||
});
|
||||
}
|
||||
|
||||
async registerPlayer(dto: RegisterDto, ip: string): Promise<LoginResponse> {
|
||||
validatePassword(dto.password, this.config);
|
||||
|
||||
if (!this.registrationRateLimit.isAllowed(ip)) {
|
||||
throw new ApiError(
|
||||
ERROR_CODES.RATE_LIMITED,
|
||||
'Too many registration attempts; please wait a minute before trying again.',
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
const enabled = (await this.settings.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')) === 'true';
|
||||
if (!enabled) {
|
||||
throw new ApiError(
|
||||
ERROR_CODES.REGISTRATIONS_DISABLED,
|
||||
'Registrations are currently disabled',
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const existing = await manager.findOne(UserEntity, { where: { username: dto.username } });
|
||||
if (existing) {
|
||||
throw new ApiError(ERROR_CODES.USERNAME_TAKEN, 'Username already taken', 409);
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(dto.password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: this.config.get<number>('ARGON2_MEMORY_COST'),
|
||||
timeCost: this.config.get<number>('ARGON2_TIME_COST'),
|
||||
parallelism: this.config.get<number>('ARGON2_PARALLELISM'),
|
||||
});
|
||||
const user = manager.create(UserEntity, {
|
||||
id: uuid(),
|
||||
username: dto.username,
|
||||
passwordHash,
|
||||
role: 'player',
|
||||
status: 'enabled',
|
||||
});
|
||||
await manager.save(user);
|
||||
|
||||
this.registrationRateLimit.record(ip);
|
||||
return this.mintSession(manager, user);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a fresh access JWT and a new refresh token row inside the supplied
|
||||
* transaction. The refresh token (raw) is returned alongside the access
|
||||
|
||||
@@ -16,6 +16,22 @@ export const CsrfResponseDtoSchema = z.object({
|
||||
});
|
||||
export type CsrfResponseDto = z.infer<typeof CsrfResponseDtoSchema>;
|
||||
|
||||
export const RegisterDtoSchema = z
|
||||
.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(32)
|
||||
.regex(/^[a-zA-Z0-9_.-]+$/, 'Username may only contain letters, digits, dot, dash and underscore'),
|
||||
password: z.string().min(1).max(256),
|
||||
passwordConfirm: z.string().min(1).max(256),
|
||||
})
|
||||
.refine((d) => d.password === d.passwordConfirm, {
|
||||
path: ['passwordConfirm'],
|
||||
message: 'Passwords do not match',
|
||||
});
|
||||
export type RegisterDto = z.infer<typeof RegisterDtoSchema>;
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { BlogService } from './blog.service';
|
||||
import { PublicBlogList } from './dto/blog.dto';
|
||||
|
||||
@ApiTags('blog')
|
||||
@Controller('api/v1/blog')
|
||||
export class BlogController {
|
||||
constructor(private readonly blog: BlogService) {}
|
||||
|
||||
@Public()
|
||||
@Get('posts')
|
||||
@ApiOperation({ summary: 'Public list of published blog posts' })
|
||||
async listPosts(): Promise<PublicBlogList> {
|
||||
const posts = await this.blog.listPublished();
|
||||
return { posts };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BlogPostEntity } from '../../database/entities/blog-post.entity';
|
||||
import { BlogController } from './blog.controller';
|
||||
import { BlogService } from './blog.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([BlogPostEntity])],
|
||||
controllers: [BlogController],
|
||||
providers: [BlogService],
|
||||
})
|
||||
export class BlogModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BlogPostEntity } from '../../database/entities/blog-post.entity';
|
||||
import { PublicBlogPost } from './dto/blog.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BlogService {
|
||||
constructor(
|
||||
@InjectRepository(BlogPostEntity)
|
||||
private readonly posts: Repository<BlogPostEntity>,
|
||||
) {}
|
||||
|
||||
async listPublished(): Promise<PublicBlogPost[]> {
|
||||
const rows = await this.posts.find({
|
||||
where: { status: 'published' },
|
||||
order: { publishedAt: 'DESC' },
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
publishedAt: row.publishedAt ?? row.createdAt,
|
||||
bodyMd: row.bodyMd ?? '',
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PublicBlogPostSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
publishedAt: z.string(),
|
||||
bodyMd: z.string(),
|
||||
});
|
||||
export type PublicBlogPost = z.infer<typeof PublicBlogPostSchema>;
|
||||
|
||||
export const PublicBlogListSchema = z.object({
|
||||
posts: z.array(PublicBlogPostSchema),
|
||||
});
|
||||
export type PublicBlogList = z.infer<typeof PublicBlogListSchema>;
|
||||
Reference in New Issue
Block a user