
- Add authentication module with Google OAuth 2.0 and JWT strategies - Create secure user management with email hashing (SHA-256) - Implement rate limiting (10 requests/minute) for auth endpoints - Add CSRF protection and security middleware - Create user registration with Basic plan (50 quota default) - Add JWT-based session management with secure cookies - Implement protected routes with authentication guards - Add comprehensive API documentation with Swagger - Configure environment variables for OAuth and security - Add user profile management and quota tracking Resolves authentication requirements §18-20: - §18: Google OAuth 2.0 with email scope only - §19: Auto-create User record on first OAuth callback - §20: Store only Google UID, display name, and email hash 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
235 lines
No EOL
6.2 KiB
TypeScript
235 lines
No EOL
6.2 KiB
TypeScript
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
UseGuards,
|
|
Req,
|
|
Res,
|
|
HttpStatus,
|
|
HttpException,
|
|
Logger,
|
|
} from '@nestjs/common';
|
|
import { Request, Response } from 'express';
|
|
import {
|
|
ApiTags,
|
|
ApiOperation,
|
|
ApiResponse,
|
|
ApiBearerAuth,
|
|
ApiExcludeEndpoint,
|
|
} from '@nestjs/swagger';
|
|
import { User } from '@prisma/client';
|
|
|
|
import { AuthService } from './auth.service';
|
|
import { GoogleAuthGuard, JwtAuthGuard, Public } from './auth.guard';
|
|
import {
|
|
LoginResponseDto,
|
|
LogoutResponseDto,
|
|
AuthProfileDto
|
|
} from './dto/auth.dto';
|
|
|
|
export interface AuthenticatedRequest extends Request {
|
|
user: User;
|
|
}
|
|
|
|
@ApiTags('Authentication')
|
|
@Controller('auth')
|
|
export class AuthController {
|
|
private readonly logger = new Logger(AuthController.name);
|
|
|
|
constructor(private readonly authService: AuthService) {}
|
|
|
|
@Get('google')
|
|
@Public()
|
|
@UseGuards(GoogleAuthGuard)
|
|
@ApiOperation({
|
|
summary: 'Initiate Google OAuth authentication',
|
|
description: 'Redirects user to Google OAuth consent screen'
|
|
})
|
|
@ApiResponse({
|
|
status: 302,
|
|
description: 'Redirect to Google OAuth'
|
|
})
|
|
@ApiExcludeEndpoint() // Don't show in Swagger UI as it's a redirect
|
|
async googleAuth() {
|
|
// Guard handles the redirect to Google
|
|
// This method exists for the decorator
|
|
}
|
|
|
|
@Get('google/callback')
|
|
@Public()
|
|
@UseGuards(GoogleAuthGuard)
|
|
@ApiOperation({
|
|
summary: 'Google OAuth callback',
|
|
description: 'Handles the callback from Google OAuth and creates/logs in user'
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Authentication successful',
|
|
type: LoginResponseDto
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Authentication failed'
|
|
})
|
|
@ApiExcludeEndpoint() // Don't show in Swagger UI as it's a callback
|
|
async googleCallback(
|
|
@Req() req: AuthenticatedRequest,
|
|
@Res() res: Response,
|
|
) {
|
|
try {
|
|
if (!req.user) {
|
|
throw new HttpException('Authentication failed', HttpStatus.UNAUTHORIZED);
|
|
}
|
|
|
|
// Generate JWT tokens for the authenticated user
|
|
const tokenData = await this.authService.generateTokens(req.user);
|
|
|
|
// Get frontend URL from config
|
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
|
|
|
// Set secure HTTP-only cookie with the JWT token
|
|
res.cookie('access_token', tokenData.accessToken, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
maxAge: tokenData.expiresIn * 1000, // Convert to milliseconds
|
|
path: '/',
|
|
});
|
|
|
|
// Redirect to frontend with success indication
|
|
const redirectUrl = `${frontendUrl}/auth/success?user=${encodeURIComponent(
|
|
JSON.stringify({
|
|
id: tokenData.user.id,
|
|
email: tokenData.user.email,
|
|
plan: tokenData.user.plan,
|
|
quotaRemaining: tokenData.user.quotaRemaining,
|
|
})
|
|
)}`;
|
|
|
|
this.logger.log(`User ${req.user.email} authenticated successfully`);
|
|
|
|
return res.redirect(redirectUrl);
|
|
} catch (error) {
|
|
this.logger.error('OAuth callback error:', error);
|
|
|
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
|
return res.redirect(`${frontendUrl}/auth/error?message=${encodeURIComponent('Authentication failed')}`);
|
|
}
|
|
}
|
|
|
|
@Post('logout')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Logout user',
|
|
description: 'Invalidates the user session and clears authentication cookies'
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Successfully logged out',
|
|
type: LogoutResponseDto
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Unauthorized'
|
|
})
|
|
async logout(
|
|
@Req() req: AuthenticatedRequest,
|
|
@Res() res: Response,
|
|
): Promise<Response> {
|
|
try {
|
|
const result = await this.authService.logout(req.user.id);
|
|
|
|
// Clear the authentication cookie
|
|
res.clearCookie('access_token', {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
});
|
|
|
|
this.logger.log(`User ${req.user.email} logged out successfully`);
|
|
|
|
return res.status(HttpStatus.OK).json(result);
|
|
} catch (error) {
|
|
this.logger.error('Logout error:', error);
|
|
throw new HttpException('Logout failed', HttpStatus.INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
@Get('profile')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Get current user profile',
|
|
description: 'Returns the authenticated user\'s profile information'
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'User profile retrieved successfully',
|
|
type: AuthProfileDto
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Unauthorized'
|
|
})
|
|
async getProfile(@Req() req: AuthenticatedRequest): Promise<AuthProfileDto> {
|
|
try {
|
|
const user = await this.authService.getProfile(req.user.id);
|
|
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
plan: user.plan,
|
|
quotaRemaining: user.quotaRemaining,
|
|
quotaResetDate: user.quotaResetDate,
|
|
isActive: user.isActive,
|
|
createdAt: user.createdAt,
|
|
};
|
|
} catch (error) {
|
|
this.logger.error('Get profile error:', error);
|
|
throw new HttpException('Failed to retrieve profile', HttpStatus.INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
@Get('status')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Check authentication status',
|
|
description: 'Verifies if the current JWT token is valid'
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Token is valid',
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
authenticated: { type: 'boolean', example: true },
|
|
user: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
email: { type: 'string' },
|
|
plan: { type: 'string' },
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Token is invalid or expired'
|
|
})
|
|
async checkStatus(@Req() req: AuthenticatedRequest) {
|
|
return {
|
|
authenticated: true,
|
|
user: {
|
|
id: req.user.id,
|
|
email: req.user.email,
|
|
plan: req.user.plan,
|
|
quotaRemaining: req.user.quotaRemaining,
|
|
},
|
|
};
|
|
}
|
|
} |