Add Google OAuth authentication support

- Add Google OAuth token verification and user lookup/creation
- Add GoogleAuthRequest and GoogleAuthResponse DTOs
- Add GoogleLogin handler in auth_handler.go
- Add google_auth.go service for token verification
- Add FindByGoogleID repository method for user lookup
- Add GoogleID field to User model
- Add Google OAuth configuration (client ID, enabled flag)
- Add i18n translations for Google auth error messages
- Add Google verification email template support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-13 00:51:44 -06:00
parent 684856e0e9
commit 780e699463
20 changed files with 531 additions and 13 deletions

View File

@@ -29,6 +29,7 @@ var (
ErrRateLimitExceeded = errors.New("too many requests, please try again later")
ErrInvalidResetToken = errors.New("invalid or expired reset token")
ErrAppleSignInFailed = errors.New("Apple Sign In failed")
ErrGoogleSignInFailed = errors.New("Google Sign In failed")
)
// AuthService handles authentication business logic
@@ -572,6 +573,151 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
}, nil
}
// GoogleSignIn handles Google Sign In authentication
func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthService, req *requests.GoogleSignInRequest) (*responses.GoogleSignInResponse, error) {
// 1. Verify the Google ID token
tokenInfo, err := googleAuth.VerifyIDToken(ctx, req.IDToken)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrGoogleSignInFailed, err)
}
googleID := tokenInfo.Sub
if googleID == "" {
return nil, fmt.Errorf("%w: missing subject claim", ErrGoogleSignInFailed)
}
// 2. Check if this Google ID is already linked to an account
existingAuth, err := s.userRepo.FindByGoogleID(googleID)
if err == nil && existingAuth != nil {
// User already linked with this Google ID - log them in
user, err := s.userRepo.FindByIDWithProfile(existingAuth.UserID)
if err != nil {
return nil, fmt.Errorf("failed to find user: %w", err)
}
if !user.IsActive {
return nil, ErrUserInactive
}
// Get or create token
token, err := s.userRepo.GetOrCreateToken(user.ID)
if err != nil {
return nil, fmt.Errorf("failed to create token: %w", err)
}
// Update last login
_ = s.userRepo.UpdateLastLogin(user.ID)
return &responses.GoogleSignInResponse{
Token: token.Key,
User: responses.NewUserResponse(user),
IsNewUser: false,
}, nil
}
// 3. Check if email matches an existing user (for account linking)
email := tokenInfo.Email
if email != "" {
existingUser, err := s.userRepo.FindByEmail(email)
if err == nil && existingUser != nil {
// Link Google ID to existing account
googleAuthRecord := &models.GoogleSocialAuth{
UserID: existingUser.ID,
GoogleID: googleID,
Email: email,
Name: tokenInfo.Name,
Picture: tokenInfo.Picture,
}
if err := s.userRepo.CreateGoogleSocialAuth(googleAuthRecord); err != nil {
return nil, fmt.Errorf("failed to link Google ID: %w", err)
}
// Mark as verified since Google verified the email
if tokenInfo.IsEmailVerified() {
_ = s.userRepo.SetProfileVerified(existingUser.ID, true)
}
// Get or create token
token, err := s.userRepo.GetOrCreateToken(existingUser.ID)
if err != nil {
return nil, fmt.Errorf("failed to create token: %w", err)
}
// Update last login
_ = s.userRepo.UpdateLastLogin(existingUser.ID)
// Reload user with profile
existingUser, _ = s.userRepo.FindByIDWithProfile(existingUser.ID)
return &responses.GoogleSignInResponse{
Token: token.Key,
User: responses.NewUserResponse(existingUser),
IsNewUser: false,
}, nil
}
}
// 4. Create new user
username := generateGoogleUsername(email, tokenInfo.GivenName)
user := &models.User{
Username: username,
Email: email,
FirstName: tokenInfo.GivenName,
LastName: tokenInfo.FamilyName,
IsActive: true,
}
// Set a random password (user won't use it since they log in with Google)
randomPassword := generateResetToken()
_ = user.SetPassword(randomPassword)
if err := s.userRepo.Create(user); err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
// Create profile (already verified if Google verified email)
profile, _ := s.userRepo.GetOrCreateProfile(user.ID)
if profile != nil && tokenInfo.IsEmailVerified() {
_ = s.userRepo.SetProfileVerified(user.ID, true)
}
// Create notification preferences with all options enabled
if s.notificationRepo != nil {
if _, err := s.notificationRepo.GetOrCreatePreferences(user.ID); err != nil {
// Log error but don't fail registration
fmt.Printf("Failed to create notification preferences: %v\n", err)
}
}
// Link Google ID
googleAuthRecord := &models.GoogleSocialAuth{
UserID: user.ID,
GoogleID: googleID,
Email: email,
Name: tokenInfo.Name,
Picture: tokenInfo.Picture,
}
if err := s.userRepo.CreateGoogleSocialAuth(googleAuthRecord); err != nil {
return nil, fmt.Errorf("failed to create Google auth: %w", err)
}
// Create token
token, err := s.userRepo.GetOrCreateToken(user.ID)
if err != nil {
return nil, fmt.Errorf("failed to create token: %w", err)
}
// Reload user with profile
user, _ = s.userRepo.FindByIDWithProfile(user.ID)
return &responses.GoogleSignInResponse{
Token: token.Key,
User: responses.NewUserResponse(user),
IsNewUser: true,
}, nil
}
// Helper functions
func generateSixDigitCode() string {
@@ -637,3 +783,22 @@ func generateUniqueUsername(email string, firstName *string) string {
// Fallback to random username
return "user_" + generateResetToken()[:10]
}
func generateGoogleUsername(email string, firstName string) string {
// Try using first part of email
if email != "" {
parts := strings.Split(email, "@")
if len(parts) > 0 && parts[0] != "" {
// Add random suffix to ensure uniqueness
return parts[0] + "_" + generateResetToken()[:6]
}
}
// Try using first name
if firstName != "" {
return strings.ToLower(firstName) + "_" + generateResetToken()[:6]
}
// Fallback to random username
return "google_" + generateResetToken()[:10]
}