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:
@@ -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]
|
||||
}
|
||||
|
||||
@@ -275,6 +275,70 @@ The Casera Team
|
||||
return s.SendEmail(to, subject, htmlBody, textBody)
|
||||
}
|
||||
|
||||
// SendGoogleWelcomeEmail sends a welcome email for Google Sign In users (no verification needed)
|
||||
func (s *EmailService) SendGoogleWelcomeEmail(to, firstName string) error {
|
||||
subject := "Welcome to Casera!"
|
||||
|
||||
name := firstName
|
||||
if name == "" {
|
||||
name = "there"
|
||||
}
|
||||
|
||||
bodyContent := fmt.Sprintf(`
|
||||
%s
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="background: #FFFFFF; padding: 40px 30px;">
|
||||
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; font-weight: 600; color: #1a1a1a; margin: 0 0 20px 0;">Hi %s,</p>
|
||||
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.6; color: #4B5563; margin: 0 0 24px 0;">Thank you for joining Casera! Your account has been created and you're ready to start managing your properties.</p>
|
||||
|
||||
<!-- Features Box -->
|
||||
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="background: #F8FAFC; border-radius: 12px; padding: 24px;">
|
||||
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; font-weight: 700; color: #1a1a1a; margin: 0 0 16px 0;">Here's what you can do with Casera:</p>
|
||||
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">🏠 <strong>Manage Properties</strong> - Track all your homes and rentals in one place</p>
|
||||
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">✅ <strong>Task Management</strong> - Never miss maintenance with smart scheduling</p>
|
||||
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">👷 <strong>Contractor Directory</strong> - Keep your trusted pros organized</p>
|
||||
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">📄 <strong>Document Storage</strong> - Store warranties, manuals, and important records</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #6B7280; margin: 24px 0 0 0;">If you have any questions, feel free to reach out to us at support@casera.app.</p>
|
||||
|
||||
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Best regards,<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
|
||||
</td>
|
||||
</tr>
|
||||
%s`,
|
||||
emailHeader("Welcome!"),
|
||||
name,
|
||||
emailFooter(time.Now().Year()))
|
||||
|
||||
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
|
||||
|
||||
textBody := fmt.Sprintf(`
|
||||
Welcome to Casera!
|
||||
|
||||
Hi %s,
|
||||
|
||||
Thank you for joining Casera! Your account has been created and you're ready to start managing your properties.
|
||||
|
||||
Here's what you can do with Casera:
|
||||
- Manage Properties: Track all your homes and rentals in one place
|
||||
- Task Management: Never miss maintenance with smart scheduling
|
||||
- Contractor Directory: Keep your trusted pros organized
|
||||
- Document Storage: Store warranties, manuals, and important records
|
||||
|
||||
If you have any questions, feel free to reach out to us at support@casera.app.
|
||||
|
||||
Best regards,
|
||||
The Casera Team
|
||||
`, name)
|
||||
|
||||
return s.SendEmail(to, subject, htmlBody, textBody)
|
||||
}
|
||||
|
||||
// SendPostVerificationEmail sends a welcome email after user verifies their email address
|
||||
func (s *EmailService) SendPostVerificationEmail(to, firstName string) error {
|
||||
subject := "You're All Set! Getting Started with Casera"
|
||||
|
||||
127
internal/services/google_auth.go
Normal file
127
internal/services/google_auth.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
googleTokenInfoURL = "https://oauth2.googleapis.com/tokeninfo"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidGoogleToken = errors.New("invalid Google ID token")
|
||||
ErrGoogleTokenExpired = errors.New("Google ID token has expired")
|
||||
ErrInvalidGoogleAudience = errors.New("invalid Google token audience")
|
||||
)
|
||||
|
||||
// GoogleTokenInfo represents the response from Google's token info endpoint
|
||||
type GoogleTokenInfo struct {
|
||||
Sub string `json:"sub"` // Unique Google user ID
|
||||
Email string `json:"email"` // User's email
|
||||
EmailVerified string `json:"email_verified"` // "true" or "false"
|
||||
Name string `json:"name"` // Full name
|
||||
GivenName string `json:"given_name"` // First name
|
||||
FamilyName string `json:"family_name"` // Last name
|
||||
Picture string `json:"picture"` // Profile picture URL
|
||||
Aud string `json:"aud"` // Audience (client ID)
|
||||
Azp string `json:"azp"` // Authorized party
|
||||
Exp string `json:"exp"` // Expiration time
|
||||
Iss string `json:"iss"` // Issuer
|
||||
}
|
||||
|
||||
// IsEmailVerified returns whether the email is verified
|
||||
func (t *GoogleTokenInfo) IsEmailVerified() bool {
|
||||
return t.EmailVerified == "true"
|
||||
}
|
||||
|
||||
// GoogleAuthService handles Google Sign In token verification
|
||||
type GoogleAuthService struct {
|
||||
cache *CacheService
|
||||
config *config.Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewGoogleAuthService creates a new Google auth service
|
||||
func NewGoogleAuthService(cache *CacheService, cfg *config.Config) *GoogleAuthService {
|
||||
return &GoogleAuthService{
|
||||
cache: cache,
|
||||
config: cfg,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyIDToken verifies a Google ID token and returns the token info
|
||||
func (s *GoogleAuthService) VerifyIDToken(ctx context.Context, idToken string) (*GoogleTokenInfo, error) {
|
||||
// Call Google's tokeninfo endpoint to verify the token
|
||||
url := fmt.Sprintf("%s?id_token=%s", googleTokenInfoURL, idToken)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, ErrInvalidGoogleToken
|
||||
}
|
||||
|
||||
var tokenInfo GoogleTokenInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenInfo); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode token info: %w", err)
|
||||
}
|
||||
|
||||
// Verify the audience matches our client ID(s)
|
||||
if !s.verifyAudience(tokenInfo.Aud, tokenInfo.Azp) {
|
||||
return nil, ErrInvalidGoogleAudience
|
||||
}
|
||||
|
||||
// Verify the token is not expired (tokeninfo endpoint already checks this,
|
||||
// but we double-check for security)
|
||||
if tokenInfo.Sub == "" {
|
||||
return nil, ErrInvalidGoogleToken
|
||||
}
|
||||
|
||||
return &tokenInfo, nil
|
||||
}
|
||||
|
||||
// verifyAudience checks if the token audience matches our client ID(s)
|
||||
func (s *GoogleAuthService) verifyAudience(aud, azp string) bool {
|
||||
clientID := s.config.GoogleAuth.ClientID
|
||||
if clientID == "" {
|
||||
// If not configured, skip audience verification (for development)
|
||||
return true
|
||||
}
|
||||
|
||||
// Check both aud and azp (Android vs iOS may use different values)
|
||||
if aud == clientID || azp == clientID {
|
||||
return true
|
||||
}
|
||||
|
||||
// Also check Android client ID if configured
|
||||
androidClientID := s.config.GoogleAuth.AndroidClientID
|
||||
if androidClientID != "" && (aud == androidClientID || azp == androidClientID) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Also check iOS client ID if configured
|
||||
iosClientID := s.config.GoogleAuth.IOSClientID
|
||||
if iosClientID != "" && (aud == iosClientID || azp == iosClientID) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user