feat: implement Google OAuth authentication
- Add Google OAuth 2.0 login flow with passport-google-oauth20 - Create User and RefreshToken entities for session management - Implement JWT access tokens (15min) + HttpOnly refresh cookies (7 days) - Add auth endpoints: /google, /google/callback, /refresh, /me, /logout - Create LoginPage with Google sign-in button (shadcn/ui) - Add AuthGuard for protected routes with redirect preservation - Implement silent token refresh on app mount - Add UserMenu component with avatar and sign-out Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ThumbnailsModule } from './modules/thumbnails/thumbnails.module';
|
||||
import { YouTubeModule } from './modules/youtube/youtube.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -25,6 +26,7 @@ import { YouTubeModule } from './modules/youtube/youtube.module';
|
||||
}),
|
||||
ThumbnailsModule,
|
||||
YouTubeModule,
|
||||
AuthModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
36
backend/src/entities/refresh-token.entity.ts
Normal file
36
backend/src/entities/refresh-token.entity.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity('refresh_tokens')
|
||||
export class RefreshToken {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.refreshTokens, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({ unique: true })
|
||||
@Index('idx_refresh_token_token')
|
||||
token: string;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
expiresAt: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
revokedAt: Date;
|
||||
}
|
||||
38
backend/src/entities/user.entity.ts
Normal file
38
backend/src/entities/user.entity.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { RefreshToken } from './refresh-token.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
@Index('idx_user_google_id')
|
||||
googleId: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
@Index('idx_user_email')
|
||||
email: string;
|
||||
|
||||
@Column()
|
||||
displayName: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
avatarUrl: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
||||
lastLoginAt: Date;
|
||||
|
||||
@OneToMany(() => RefreshToken, (refreshToken) => refreshToken.user)
|
||||
refreshTokens: RefreshToken[];
|
||||
}
|
||||
@@ -3,10 +3,14 @@ import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import { join } from 'path';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
// Cookie parser for refresh tokens
|
||||
app.use(cookieParser());
|
||||
|
||||
// Global prefix
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
@@ -19,8 +23,9 @@ async function bootstrap() {
|
||||
);
|
||||
|
||||
// CORS
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
app.enableCors({
|
||||
origin: ['http://localhost:3000', 'http://localhost:5173'],
|
||||
origin: [frontendUrl, 'http://localhost:3000', 'http://localhost:5173'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
|
||||
114
backend/src/modules/auth/auth.controller.ts
Normal file
114
backend/src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
UseGuards,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { GoogleAuthGuard } from './guards/google-auth.guard';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Get('google')
|
||||
@UseGuards(GoogleAuthGuard)
|
||||
googleAuth() {
|
||||
// Guard redirects to Google
|
||||
}
|
||||
|
||||
@Get('google/callback')
|
||||
@UseGuards(GoogleAuthGuard)
|
||||
async googleCallback(@Req() req: Request, @Res() res: Response) {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const { accessToken, refreshToken } =
|
||||
await this.authService.generateTokens(user);
|
||||
|
||||
// Set HttpOnly cookie for refresh token
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: this.configService.get('NODE_ENV') === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
|
||||
// Redirect to frontend with access token
|
||||
const frontendUrl = this.configService.get<string>(
|
||||
'FRONTEND_URL',
|
||||
'http://localhost:3000',
|
||||
);
|
||||
res.redirect(`${frontendUrl}/auth/callback?token=${accessToken}`);
|
||||
} catch {
|
||||
const frontendUrl = this.configService.get<string>(
|
||||
'FRONTEND_URL',
|
||||
'http://localhost:3000',
|
||||
);
|
||||
res.redirect(`${frontendUrl}/login?error=auth_failed`);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
async refresh(@Req() req: Request, @Res() res: Response) {
|
||||
const refreshToken = (req.cookies as Record<string, string>)?.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new UnauthorizedException('No refresh token provided');
|
||||
}
|
||||
|
||||
const result = await this.authService.refreshTokens(refreshToken);
|
||||
|
||||
// Set new HttpOnly cookie for refresh token
|
||||
res.cookie('refreshToken', result.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: this.configService.get('NODE_ENV') === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
|
||||
return res.json({
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
getMe(@Req() req: Request) {
|
||||
const user = req.user as User;
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async logout(@Req() req: Request, @Res() res: Response) {
|
||||
const refreshToken = (req.cookies as Record<string, string>)?.refreshToken;
|
||||
|
||||
if (refreshToken) {
|
||||
await this.authService.revokeRefreshToken(refreshToken);
|
||||
}
|
||||
|
||||
res.clearCookie('refreshToken', {
|
||||
httpOnly: true,
|
||||
secure: this.configService.get('NODE_ENV') === 'production',
|
||||
sameSite: 'strict',
|
||||
});
|
||||
|
||||
return res.json({ message: 'Successfully logged out' });
|
||||
}
|
||||
}
|
||||
34
backend/src/modules/auth/auth.module.ts
Normal file
34
backend/src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { GoogleStrategy } from './google.strategy';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { RefreshToken } from '../../entities/refresh-token.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService): JwtModuleOptions => {
|
||||
return {
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: '15m',
|
||||
},
|
||||
};
|
||||
},
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
TypeOrmModule.forFeature([User, RefreshToken]),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, GoogleStrategy, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
158
backend/src/modules/auth/auth.service.ts
Normal file
158
backend/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull, MoreThan } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { RefreshToken } from '../../entities/refresh-token.entity';
|
||||
|
||||
interface GoogleProfile {
|
||||
id: string;
|
||||
emails: Array<{ value: string; verified: boolean }>;
|
||||
displayName: string;
|
||||
photos: Array<{ value: string }>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(RefreshToken)
|
||||
private readonly refreshTokenRepository: Repository<RefreshToken>,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async validateOAuthUser(profile: GoogleProfile): Promise<User> {
|
||||
const email = profile.emails?.[0]?.value;
|
||||
const googleId = profile.id;
|
||||
const displayName = profile.displayName;
|
||||
const avatarUrl = profile.photos?.[0]?.value;
|
||||
|
||||
if (!email || !googleId) {
|
||||
throw new UnauthorizedException('Invalid Google profile');
|
||||
}
|
||||
|
||||
let user = await this.userRepository.findOne({
|
||||
where: { googleId },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
// Update last login and profile info
|
||||
user.lastLoginAt = new Date();
|
||||
user.displayName = displayName;
|
||||
user.avatarUrl = avatarUrl;
|
||||
await this.userRepository.save(user);
|
||||
} else {
|
||||
// Create new user
|
||||
user = this.userRepository.create({
|
||||
googleId,
|
||||
email,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
lastLoginAt: new Date(),
|
||||
});
|
||||
await this.userRepository.save(user);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async generateTokens(
|
||||
user: User,
|
||||
): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
// Generate access token
|
||||
const payload = { sub: user.id, email: user.email };
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
|
||||
// Generate refresh token
|
||||
const refreshTokenValue = this.generateRandomToken();
|
||||
const hashedToken = await bcrypt.hash(refreshTokenValue, 10);
|
||||
|
||||
// Calculate expiration (7 days)
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 7);
|
||||
|
||||
// Save refresh token to database
|
||||
const refreshTokenEntity = this.refreshTokenRepository.create({
|
||||
userId: user.id,
|
||||
token: hashedToken,
|
||||
expiresAt,
|
||||
});
|
||||
await this.refreshTokenRepository.save(refreshTokenEntity);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: `${refreshTokenEntity.id}:${refreshTokenValue}`,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshTokens(
|
||||
token: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: User }> {
|
||||
const [tokenId, tokenValue] = token.split(':');
|
||||
|
||||
if (!tokenId || !tokenValue) {
|
||||
throw new UnauthorizedException('Invalid refresh token format');
|
||||
}
|
||||
|
||||
const storedToken = await this.refreshTokenRepository.findOne({
|
||||
where: {
|
||||
id: tokenId,
|
||||
revokedAt: IsNull(),
|
||||
expiresAt: MoreThan(new Date()),
|
||||
},
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (!storedToken) {
|
||||
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(tokenValue, storedToken.token);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
// Revoke old token (rotation)
|
||||
await this.revokeRefreshToken(token);
|
||||
|
||||
// Generate new tokens
|
||||
const { accessToken, refreshToken } = await this.generateTokens(
|
||||
storedToken.user,
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: storedToken.user,
|
||||
};
|
||||
}
|
||||
|
||||
async revokeRefreshToken(token: string): Promise<void> {
|
||||
const [tokenId] = token.split(':');
|
||||
|
||||
if (tokenId) {
|
||||
await this.refreshTokenRepository.update(
|
||||
{ id: tokenId },
|
||||
{ revokedAt: new Date() },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async validateUserById(userId: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({ where: { id: userId } });
|
||||
}
|
||||
|
||||
private generateRandomToken(): string {
|
||||
const chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 64; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
28
backend/src/modules/auth/dto/auth-response.dto.ts
Normal file
28
backend/src/modules/auth/dto/auth-response.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { IsString, IsUUID, IsEmail, IsOptional, IsUrl } from 'class-validator';
|
||||
|
||||
export class UserResponseDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
displayName: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export class AuthResponseDto {
|
||||
@IsString()
|
||||
accessToken: string;
|
||||
|
||||
user: UserResponseDto;
|
||||
}
|
||||
|
||||
export class MessageResponseDto {
|
||||
@IsString()
|
||||
message: string;
|
||||
}
|
||||
40
backend/src/modules/auth/google.strategy.ts
Normal file
40
backend/src/modules/auth/google.strategy.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy, VerifyCallback, Profile, StrategyOptions } from 'passport-google-oauth20';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
) {
|
||||
const options: StrategyOptions = {
|
||||
clientID: configService.get<string>('GOOGLE_CLIENT_ID') || '',
|
||||
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET') || '',
|
||||
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL') || '',
|
||||
scope: ['email', 'profile'],
|
||||
};
|
||||
super(options);
|
||||
}
|
||||
|
||||
async validate(
|
||||
_accessToken: string,
|
||||
_refreshToken: string,
|
||||
profile: Profile,
|
||||
done: VerifyCallback,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const user = await this.authService.validateOAuthUser({
|
||||
id: profile.id,
|
||||
emails: profile.emails as Array<{ value: string; verified: boolean }>,
|
||||
displayName: profile.displayName,
|
||||
photos: profile.photos as Array<{ value: string }>,
|
||||
});
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
done(error as Error, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
5
backend/src/modules/auth/guards/google-auth.guard.ts
Normal file
5
backend/src/modules/auth/guards/google-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleAuthGuard extends AuthGuard('google') {}
|
||||
5
backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
5
backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
41
backend/src/modules/auth/jwt.strategy.ts
Normal file
41
backend/src/modules/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import {
|
||||
Strategy,
|
||||
ExtractJwt,
|
||||
StrategyOptionsWithoutRequest,
|
||||
} from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
) {
|
||||
const options: StrategyOptionsWithoutRequest = {
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET') || 'fallback-secret',
|
||||
};
|
||||
super(options);
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
const user = await this.authService.validateUserById(payload.sub);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user