feat(auth): implement complete Google OAuth authentication system
- 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>
This commit is contained in:
parent
e7e09d5e2c
commit
9514a2d0a3
20 changed files with 1833 additions and 41 deletions
235
packages/api/src/auth/auth.controller.ts
Normal file
235
packages/api/src/auth/auth.controller.ts
Normal file
|
@ -0,0 +1,235 @@
|
|||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue