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:
2026-01-29 13:05:18 -03:00
parent fe2c861007
commit 130f35c4f8
32 changed files with 2477 additions and 98 deletions

View 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;
}
}