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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user