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

@@ -13,15 +13,16 @@ import (
// Config holds all configuration for the application // Config holds all configuration for the application
type Config struct { type Config struct {
Server ServerConfig Server ServerConfig
Database DatabaseConfig Database DatabaseConfig
Redis RedisConfig Redis RedisConfig
Email EmailConfig Email EmailConfig
Push PushConfig Push PushConfig
Worker WorkerConfig Worker WorkerConfig
Security SecurityConfig Security SecurityConfig
Storage StorageConfig Storage StorageConfig
AppleAuth AppleAuthConfig AppleAuth AppleAuthConfig
GoogleAuth GoogleAuthConfig
} }
type ServerConfig struct { type ServerConfig struct {
@@ -78,6 +79,12 @@ type AppleAuthConfig struct {
TeamID string // Apple Developer Team ID TeamID string // Apple Developer Team ID
} }
type GoogleAuthConfig struct {
ClientID string // Web client ID for token verification
AndroidClientID string // Android client ID (optional, for audience verification)
IOSClientID string // iOS client ID (optional, for audience verification)
}
type WorkerConfig struct { type WorkerConfig struct {
// Scheduled job times (UTC) // Scheduled job times (UTC)
TaskReminderHour int TaskReminderHour int
@@ -194,6 +201,11 @@ func Load() (*Config, error) {
ClientID: viper.GetString("APPLE_CLIENT_ID"), ClientID: viper.GetString("APPLE_CLIENT_ID"),
TeamID: viper.GetString("APPLE_TEAM_ID"), TeamID: viper.GetString("APPLE_TEAM_ID"),
}, },
GoogleAuth: GoogleAuthConfig{
ClientID: viper.GetString("GOOGLE_CLIENT_ID"),
AndroidClientID: viper.GetString("GOOGLE_ANDROID_CLIENT_ID"),
IOSClientID: viper.GetString("GOOGLE_IOS_CLIENT_ID"),
},
} }
// Validate required fields // Validate required fields

View File

@@ -124,6 +124,7 @@ func Migrate() error {
&models.ConfirmationCode{}, &models.ConfirmationCode{},
&models.PasswordResetCode{}, &models.PasswordResetCode{},
&models.AppleSocialAuth{}, &models.AppleSocialAuth{},
&models.GoogleSocialAuth{},
// Admin users (separate from app users) // Admin users (separate from app users)
&models.AdminUser{}, &models.AdminUser{},

View File

@@ -58,3 +58,8 @@ type AppleSignInRequest struct {
FirstName *string `json:"first_name"` FirstName *string `json:"first_name"`
LastName *string `json:"last_name"` LastName *string `json:"last_name"`
} }
// GoogleSignInRequest represents the Google Sign In request body
type GoogleSignInRequest struct {
IDToken string `json:"id_token" binding:"required"` // Google ID token from Credential Manager
}

View File

@@ -171,3 +171,19 @@ func NewAppleSignInResponse(token string, user *models.User, isNewUser bool) App
IsNewUser: isNewUser, IsNewUser: isNewUser,
} }
} }
// GoogleSignInResponse represents the Google Sign In response
type GoogleSignInResponse struct {
Token string `json:"token"`
User UserResponse `json:"user"`
IsNewUser bool `json:"is_new_user"`
}
// NewGoogleSignInResponse creates a GoogleSignInResponse
func NewGoogleSignInResponse(token string, user *models.User, isNewUser bool) GoogleSignInResponse {
return GoogleSignInResponse{
Token: token,
User: NewUserResponse(user),
IsNewUser: isNewUser,
}
}

View File

@@ -16,10 +16,11 @@ import (
// AuthHandler handles authentication endpoints // AuthHandler handles authentication endpoints
type AuthHandler struct { type AuthHandler struct {
authService *services.AuthService authService *services.AuthService
emailService *services.EmailService emailService *services.EmailService
cache *services.CacheService cache *services.CacheService
appleAuthService *services.AppleAuthService appleAuthService *services.AppleAuthService
googleAuthService *services.GoogleAuthService
} }
// NewAuthHandler creates a new auth handler // NewAuthHandler creates a new auth handler
@@ -36,6 +37,11 @@ func (h *AuthHandler) SetAppleAuthService(appleAuth *services.AppleAuthService)
h.appleAuthService = appleAuth h.appleAuthService = appleAuth
} }
// SetGoogleAuthService sets the Google auth service (called after initialization)
func (h *AuthHandler) SetGoogleAuthService(googleAuth *services.GoogleAuthService) {
h.googleAuthService = googleAuth
}
// Login handles POST /api/auth/login/ // Login handles POST /api/auth/login/
func (h *AuthHandler) Login(c *gin.Context) { func (h *AuthHandler) Login(c *gin.Context) {
var req requests.LoginRequest var req requests.LoginRequest
@@ -427,3 +433,52 @@ func (h *AuthHandler) AppleSignIn(c *gin.Context) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
// GoogleSignIn handles POST /api/auth/google-sign-in/
func (h *AuthHandler) GoogleSignIn(c *gin.Context) {
var req requests.GoogleSignInRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
}
if h.googleAuthService == nil {
log.Error().Msg("Google auth service not configured")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.google_signin_not_configured"),
})
return
}
response, err := h.authService.GoogleSignIn(c.Request.Context(), h.googleAuthService, &req)
if err != nil {
status := http.StatusUnauthorized
message := i18n.LocalizedMessage(c, "error.google_signin_failed")
if errors.Is(err, services.ErrUserInactive) {
message = i18n.LocalizedMessage(c, "error.account_inactive")
} else if errors.Is(err, services.ErrGoogleSignInFailed) {
message = i18n.LocalizedMessage(c, "error.invalid_google_token")
}
log.Debug().Err(err).Msg("Google Sign In failed")
c.JSON(status, responses.ErrorResponse{Error: message})
return
}
// Send welcome email for new users (async)
if response.IsNewUser && h.emailService != nil && response.User.Email != "" {
go func() {
if err := h.emailService.SendGoogleWelcomeEmail(response.User.Email, response.User.FirstName); err != nil {
log.Error().Err(err).Str("email", response.User.Email).Msg("Failed to send Google welcome email")
}
}()
}
c.JSON(http.StatusOK, response)
}

View File

@@ -21,6 +21,9 @@
"error.apple_signin_not_configured": "Apple-Anmeldung ist nicht konfiguriert", "error.apple_signin_not_configured": "Apple-Anmeldung ist nicht konfiguriert",
"error.apple_signin_failed": "Apple-Anmeldung fehlgeschlagen", "error.apple_signin_failed": "Apple-Anmeldung fehlgeschlagen",
"error.invalid_apple_token": "Ungultiger Apple-Identitats-Token", "error.invalid_apple_token": "Ungultiger Apple-Identitats-Token",
"error.google_signin_not_configured": "Google-Anmeldung ist nicht konfiguriert",
"error.google_signin_failed": "Google-Anmeldung fehlgeschlagen",
"error.invalid_google_token": "Ungultiger Google-Identitats-Token",
"error.invalid_task_id": "Ungultige Aufgaben-ID", "error.invalid_task_id": "Ungultige Aufgaben-ID",
"error.invalid_residence_id": "Ungultige Immobilien-ID", "error.invalid_residence_id": "Ungultige Immobilien-ID",

View File

@@ -21,6 +21,9 @@
"error.apple_signin_not_configured": "Apple Sign In is not configured", "error.apple_signin_not_configured": "Apple Sign In is not configured",
"error.apple_signin_failed": "Apple Sign In failed", "error.apple_signin_failed": "Apple Sign In failed",
"error.invalid_apple_token": "Invalid Apple identity token", "error.invalid_apple_token": "Invalid Apple identity token",
"error.google_signin_not_configured": "Google Sign In is not configured",
"error.google_signin_failed": "Google Sign In failed",
"error.invalid_google_token": "Invalid Google identity token",
"error.invalid_task_id": "Invalid task ID", "error.invalid_task_id": "Invalid task ID",
"error.invalid_residence_id": "Invalid residence ID", "error.invalid_residence_id": "Invalid residence ID",

View File

@@ -21,6 +21,9 @@
"error.apple_signin_not_configured": "El inicio de sesion con Apple no esta configurado", "error.apple_signin_not_configured": "El inicio de sesion con Apple no esta configurado",
"error.apple_signin_failed": "Error en el inicio de sesion con Apple", "error.apple_signin_failed": "Error en el inicio de sesion con Apple",
"error.invalid_apple_token": "Token de identidad de Apple no valido", "error.invalid_apple_token": "Token de identidad de Apple no valido",
"error.google_signin_not_configured": "El inicio de sesion con Google no esta configurado",
"error.google_signin_failed": "Error en el inicio de sesion con Google",
"error.invalid_google_token": "Token de identidad de Google no valido",
"error.invalid_task_id": "ID de tarea no valido", "error.invalid_task_id": "ID de tarea no valido",
"error.invalid_residence_id": "ID de propiedad no valido", "error.invalid_residence_id": "ID de propiedad no valido",

View File

@@ -21,6 +21,9 @@
"error.apple_signin_not_configured": "La connexion Apple n'est pas configuree", "error.apple_signin_not_configured": "La connexion Apple n'est pas configuree",
"error.apple_signin_failed": "Echec de la connexion Apple", "error.apple_signin_failed": "Echec de la connexion Apple",
"error.invalid_apple_token": "Jeton d'identite Apple non valide", "error.invalid_apple_token": "Jeton d'identite Apple non valide",
"error.google_signin_not_configured": "La connexion Google n'est pas configuree",
"error.google_signin_failed": "Echec de la connexion Google",
"error.invalid_google_token": "Jeton d'identite Google non valide",
"error.invalid_task_id": "ID de tache non valide", "error.invalid_task_id": "ID de tache non valide",
"error.invalid_residence_id": "ID de propriete non valide", "error.invalid_residence_id": "ID de propriete non valide",

View File

@@ -21,6 +21,9 @@
"error.apple_signin_not_configured": "L'accesso con Apple non è configurato", "error.apple_signin_not_configured": "L'accesso con Apple non è configurato",
"error.apple_signin_failed": "Accesso con Apple fallito", "error.apple_signin_failed": "Accesso con Apple fallito",
"error.invalid_apple_token": "Token di identità Apple non valido", "error.invalid_apple_token": "Token di identità Apple non valido",
"error.google_signin_not_configured": "L'accesso con Google non è configurato",
"error.google_signin_failed": "Accesso con Google fallito",
"error.invalid_google_token": "Token di identità Google non valido",
"error.invalid_task_id": "ID attività non valido", "error.invalid_task_id": "ID attività non valido",
"error.invalid_residence_id": "ID immobile non valido", "error.invalid_residence_id": "ID immobile non valido",

View File

@@ -21,6 +21,9 @@
"error.apple_signin_not_configured": "Apple サインインが設定されていません", "error.apple_signin_not_configured": "Apple サインインが設定されていません",
"error.apple_signin_failed": "Apple サインインに失敗しました", "error.apple_signin_failed": "Apple サインインに失敗しました",
"error.invalid_apple_token": "無効な Apple ID トークンです", "error.invalid_apple_token": "無効な Apple ID トークンです",
"error.google_signin_not_configured": "Google サインインが設定されていません",
"error.google_signin_failed": "Google サインインに失敗しました",
"error.invalid_google_token": "無効な Google ID トークンです",
"error.invalid_task_id": "無効なタスクIDです", "error.invalid_task_id": "無効なタスクIDです",
"error.invalid_residence_id": "無効な物件IDです", "error.invalid_residence_id": "無効な物件IDです",

View File

@@ -21,6 +21,9 @@
"error.apple_signin_not_configured": "Apple 로그인이 설정되지 않았습니다", "error.apple_signin_not_configured": "Apple 로그인이 설정되지 않았습니다",
"error.apple_signin_failed": "Apple 로그인에 실패했습니다", "error.apple_signin_failed": "Apple 로그인에 실패했습니다",
"error.invalid_apple_token": "유효하지 않은 Apple 인증 토큰입니다", "error.invalid_apple_token": "유효하지 않은 Apple 인증 토큰입니다",
"error.google_signin_not_configured": "Google 로그인이 설정되지 않았습니다",
"error.google_signin_failed": "Google 로그인에 실패했습니다",
"error.invalid_google_token": "유효하지 않은 Google 인증 토큰입니다",
"error.invalid_task_id": "유효하지 않은 작업 ID입니다", "error.invalid_task_id": "유효하지 않은 작업 ID입니다",
"error.invalid_residence_id": "유효하지 않은 주거지 ID입니다", "error.invalid_residence_id": "유효하지 않은 주거지 ID입니다",

View File

@@ -21,6 +21,9 @@
"error.apple_signin_not_configured": "Apple Sign In is niet geconfigureerd", "error.apple_signin_not_configured": "Apple Sign In is niet geconfigureerd",
"error.apple_signin_failed": "Apple Sign In mislukt", "error.apple_signin_failed": "Apple Sign In mislukt",
"error.invalid_apple_token": "Ongeldig Apple identiteitstoken", "error.invalid_apple_token": "Ongeldig Apple identiteitstoken",
"error.google_signin_not_configured": "Google Sign In is niet geconfigureerd",
"error.google_signin_failed": "Google Sign In mislukt",
"error.invalid_google_token": "Ongeldig Google identiteitstoken",
"error.invalid_task_id": "Ongeldig taak-ID", "error.invalid_task_id": "Ongeldig taak-ID",
"error.invalid_residence_id": "Ongeldig woning-ID", "error.invalid_residence_id": "Ongeldig woning-ID",

View File

@@ -21,6 +21,9 @@
"error.apple_signin_not_configured": "O login com Apple nao esta configurado", "error.apple_signin_not_configured": "O login com Apple nao esta configurado",
"error.apple_signin_failed": "Falha no login com Apple", "error.apple_signin_failed": "Falha no login com Apple",
"error.invalid_apple_token": "Token de identidade Apple invalido", "error.invalid_apple_token": "Token de identidade Apple invalido",
"error.google_signin_not_configured": "O login com Google nao esta configurado",
"error.google_signin_failed": "Falha no login com Google",
"error.invalid_google_token": "Token de identidade Google invalido",
"error.invalid_task_id": "ID da tarefa invalido", "error.invalid_task_id": "ID da tarefa invalido",
"error.invalid_residence_id": "ID da propriedade invalido", "error.invalid_residence_id": "ID da propriedade invalido",

View File

@@ -21,6 +21,9 @@
"error.apple_signin_not_configured": "未配置 Apple 登录", "error.apple_signin_not_configured": "未配置 Apple 登录",
"error.apple_signin_failed": "Apple 登录失败", "error.apple_signin_failed": "Apple 登录失败",
"error.invalid_apple_token": "Apple 身份令牌无效", "error.invalid_apple_token": "Apple 身份令牌无效",
"error.google_signin_not_configured": "未配置 Google 登录",
"error.google_signin_failed": "Google 登录失败",
"error.invalid_google_token": "Google 身份令牌无效",
"error.invalid_task_id": "任务 ID 无效", "error.invalid_task_id": "任务 ID 无效",
"error.invalid_residence_id": "房产 ID 无效", "error.invalid_residence_id": "房产 ID 无效",

View File

@@ -247,3 +247,21 @@ type AppleSocialAuth struct {
func (AppleSocialAuth) TableName() string { func (AppleSocialAuth) TableName() string {
return "user_applesocialauth" return "user_applesocialauth"
} }
// GoogleSocialAuth represents a user's linked Google account for Sign in with Google
type GoogleSocialAuth struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
User User `gorm:"foreignKey:UserID" json:"-"`
GoogleID string `gorm:"column:google_id;size:255;uniqueIndex;not null" json:"google_id"` // Google's unique subject ID
Email string `gorm:"column:email;size:254" json:"email"`
Name string `gorm:"column:name;size:255" json:"name"`
Picture string `gorm:"column:picture;size:512" json:"picture"` // Profile picture URL
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
}
// TableName returns the table name for GORM
func (GoogleSocialAuth) TableName() string {
return "user_googlesocialauth"
}

View File

@@ -21,6 +21,7 @@ var (
ErrTooManyAttempts = errors.New("too many attempts") ErrTooManyAttempts = errors.New("too many attempts")
ErrRateLimitExceeded = errors.New("rate limit exceeded") ErrRateLimitExceeded = errors.New("rate limit exceeded")
ErrAppleAuthNotFound = errors.New("apple social auth not found") ErrAppleAuthNotFound = errors.New("apple social auth not found")
ErrGoogleAuthNotFound = errors.New("google social auth not found")
) )
// UserRepository handles user-related database operations // UserRepository handles user-related database operations
@@ -511,3 +512,27 @@ func (r *UserRepository) CreateAppleSocialAuth(auth *models.AppleSocialAuth) err
func (r *UserRepository) UpdateAppleSocialAuth(auth *models.AppleSocialAuth) error { func (r *UserRepository) UpdateAppleSocialAuth(auth *models.AppleSocialAuth) error {
return r.db.Save(auth).Error return r.db.Save(auth).Error
} }
// --- Google Social Auth Methods ---
// FindByGoogleID finds a Google social auth by Google ID
func (r *UserRepository) FindByGoogleID(googleID string) (*models.GoogleSocialAuth, error) {
var auth models.GoogleSocialAuth
if err := r.db.Where("google_id = ?", googleID).First(&auth).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrGoogleAuthNotFound
}
return nil, err
}
return &auth, nil
}
// CreateGoogleSocialAuth creates a new Google social auth record
func (r *UserRepository) CreateGoogleSocialAuth(auth *models.GoogleSocialAuth) error {
return r.db.Create(auth).Error
}
// UpdateGoogleSocialAuth updates a Google social auth record
func (r *UserRepository) UpdateGoogleSocialAuth(auth *models.GoogleSocialAuth) error {
return r.db.Save(auth).Error
}

View File

@@ -29,6 +29,7 @@ var (
ErrRateLimitExceeded = errors.New("too many requests, please try again later") ErrRateLimitExceeded = errors.New("too many requests, please try again later")
ErrInvalidResetToken = errors.New("invalid or expired reset token") ErrInvalidResetToken = errors.New("invalid or expired reset token")
ErrAppleSignInFailed = errors.New("Apple Sign In failed") ErrAppleSignInFailed = errors.New("Apple Sign In failed")
ErrGoogleSignInFailed = errors.New("Google Sign In failed")
) )
// AuthService handles authentication business logic // AuthService handles authentication business logic
@@ -572,6 +573,151 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
}, nil }, 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 // Helper functions
func generateSixDigitCode() string { func generateSixDigitCode() string {
@@ -637,3 +783,22 @@ func generateUniqueUsername(email string, firstName *string) string {
// Fallback to random username // Fallback to random username
return "user_" + generateResetToken()[:10] 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]
}

View File

@@ -275,6 +275,70 @@ The Casera Team
return s.SendEmail(to, subject, htmlBody, textBody) 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;">&#127968; <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;">&#9989; <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;">&#128119; <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;">&#128196; <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 // SendPostVerificationEmail sends a welcome email after user verifies their email address
func (s *EmailService) SendPostVerificationEmail(to, firstName string) error { func (s *EmailService) SendPostVerificationEmail(to, firstName string) error {
subject := "You're All Set! Getting Started with Casera" subject := "You're All Set! Getting Started with Casera"

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