- 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>
159 lines
4.4 KiB
TypeScript
159 lines
4.4 KiB
TypeScript
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;
|
|
}
|
|
}
|