diff --git a/internal/config/config.go b/internal/config/config.go index 29d61b5..f21c1e1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,15 +13,16 @@ import ( // Config holds all configuration for the application type Config struct { - Server ServerConfig - Database DatabaseConfig - Redis RedisConfig - Email EmailConfig - Push PushConfig - Worker WorkerConfig - Security SecurityConfig - Storage StorageConfig - AppleAuth AppleAuthConfig + Server ServerConfig + Database DatabaseConfig + Redis RedisConfig + Email EmailConfig + Push PushConfig + Worker WorkerConfig + Security SecurityConfig + Storage StorageConfig + AppleAuth AppleAuthConfig + GoogleAuth GoogleAuthConfig } type ServerConfig struct { @@ -78,6 +79,12 @@ type AppleAuthConfig struct { 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 { // Scheduled job times (UTC) TaskReminderHour int @@ -194,6 +201,11 @@ func Load() (*Config, error) { ClientID: viper.GetString("APPLE_CLIENT_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 diff --git a/internal/database/database.go b/internal/database/database.go index 7d34ebb..a8e6377 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -124,6 +124,7 @@ func Migrate() error { &models.ConfirmationCode{}, &models.PasswordResetCode{}, &models.AppleSocialAuth{}, + &models.GoogleSocialAuth{}, // Admin users (separate from app users) &models.AdminUser{}, diff --git a/internal/dto/requests/auth.go b/internal/dto/requests/auth.go index f1f4221..e1ec040 100644 --- a/internal/dto/requests/auth.go +++ b/internal/dto/requests/auth.go @@ -58,3 +58,8 @@ type AppleSignInRequest struct { FirstName *string `json:"first_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 +} diff --git a/internal/dto/responses/auth.go b/internal/dto/responses/auth.go index f1b8f7b..3d12bde 100644 --- a/internal/dto/responses/auth.go +++ b/internal/dto/responses/auth.go @@ -171,3 +171,19 @@ func NewAppleSignInResponse(token string, user *models.User, isNewUser bool) App 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, + } +} diff --git a/internal/handlers/auth_handler.go b/internal/handlers/auth_handler.go index 12dc3d6..82c634b 100644 --- a/internal/handlers/auth_handler.go +++ b/internal/handlers/auth_handler.go @@ -16,10 +16,11 @@ import ( // AuthHandler handles authentication endpoints type AuthHandler struct { - authService *services.AuthService - emailService *services.EmailService - cache *services.CacheService - appleAuthService *services.AppleAuthService + authService *services.AuthService + emailService *services.EmailService + cache *services.CacheService + appleAuthService *services.AppleAuthService + googleAuthService *services.GoogleAuthService } // NewAuthHandler creates a new auth handler @@ -36,6 +37,11 @@ func (h *AuthHandler) SetAppleAuthService(appleAuth *services.AppleAuthService) 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/ func (h *AuthHandler) Login(c *gin.Context) { var req requests.LoginRequest @@ -427,3 +433,52 @@ func (h *AuthHandler) AppleSignIn(c *gin.Context) { 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) +} diff --git a/internal/i18n/translations/de.json b/internal/i18n/translations/de.json index 14ac238..6213e50 100644 --- a/internal/i18n/translations/de.json +++ b/internal/i18n/translations/de.json @@ -21,6 +21,9 @@ "error.apple_signin_not_configured": "Apple-Anmeldung ist nicht konfiguriert", "error.apple_signin_failed": "Apple-Anmeldung fehlgeschlagen", "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_residence_id": "Ungultige Immobilien-ID", diff --git a/internal/i18n/translations/en.json b/internal/i18n/translations/en.json index 8e48aaf..65d6f08 100644 --- a/internal/i18n/translations/en.json +++ b/internal/i18n/translations/en.json @@ -21,6 +21,9 @@ "error.apple_signin_not_configured": "Apple Sign In is not configured", "error.apple_signin_failed": "Apple Sign In failed", "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_residence_id": "Invalid residence ID", diff --git a/internal/i18n/translations/es.json b/internal/i18n/translations/es.json index a1bb44e..39cd801 100644 --- a/internal/i18n/translations/es.json +++ b/internal/i18n/translations/es.json @@ -21,6 +21,9 @@ "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.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_residence_id": "ID de propiedad no valido", diff --git a/internal/i18n/translations/fr.json b/internal/i18n/translations/fr.json index 7477a7a..6ded796 100644 --- a/internal/i18n/translations/fr.json +++ b/internal/i18n/translations/fr.json @@ -21,6 +21,9 @@ "error.apple_signin_not_configured": "La connexion Apple n'est pas configuree", "error.apple_signin_failed": "Echec de la connexion Apple", "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_residence_id": "ID de propriete non valide", diff --git a/internal/i18n/translations/it.json b/internal/i18n/translations/it.json index ef9aa63..d239b38 100644 --- a/internal/i18n/translations/it.json +++ b/internal/i18n/translations/it.json @@ -21,6 +21,9 @@ "error.apple_signin_not_configured": "L'accesso con Apple non è configurato", "error.apple_signin_failed": "Accesso con Apple fallito", "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_residence_id": "ID immobile non valido", diff --git a/internal/i18n/translations/ja.json b/internal/i18n/translations/ja.json index a95e0a2..b653b51 100644 --- a/internal/i18n/translations/ja.json +++ b/internal/i18n/translations/ja.json @@ -21,6 +21,9 @@ "error.apple_signin_not_configured": "Apple サインインが設定されていません", "error.apple_signin_failed": "Apple サインインに失敗しました", "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_residence_id": "無効な物件IDです", diff --git a/internal/i18n/translations/ko.json b/internal/i18n/translations/ko.json index 06c39b6..48f8d31 100644 --- a/internal/i18n/translations/ko.json +++ b/internal/i18n/translations/ko.json @@ -21,6 +21,9 @@ "error.apple_signin_not_configured": "Apple 로그인이 설정되지 않았습니다", "error.apple_signin_failed": "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_residence_id": "유효하지 않은 주거지 ID입니다", diff --git a/internal/i18n/translations/nl.json b/internal/i18n/translations/nl.json index 989bb46..0af6e66 100644 --- a/internal/i18n/translations/nl.json +++ b/internal/i18n/translations/nl.json @@ -21,6 +21,9 @@ "error.apple_signin_not_configured": "Apple Sign In is niet geconfigureerd", "error.apple_signin_failed": "Apple Sign In mislukt", "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_residence_id": "Ongeldig woning-ID", diff --git a/internal/i18n/translations/pt.json b/internal/i18n/translations/pt.json index 46168bd..9a3166e 100644 --- a/internal/i18n/translations/pt.json +++ b/internal/i18n/translations/pt.json @@ -21,6 +21,9 @@ "error.apple_signin_not_configured": "O login com Apple nao esta configurado", "error.apple_signin_failed": "Falha no login com Apple", "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_residence_id": "ID da propriedade invalido", diff --git a/internal/i18n/translations/zh.json b/internal/i18n/translations/zh.json index b61f306..1878772 100644 --- a/internal/i18n/translations/zh.json +++ b/internal/i18n/translations/zh.json @@ -21,6 +21,9 @@ "error.apple_signin_not_configured": "未配置 Apple 登录", "error.apple_signin_failed": "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_residence_id": "房产 ID 无效", diff --git a/internal/models/user.go b/internal/models/user.go index aa7036e..91b293b 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -247,3 +247,21 @@ type AppleSocialAuth struct { func (AppleSocialAuth) TableName() string { 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" +} diff --git a/internal/repositories/user_repo.go b/internal/repositories/user_repo.go index f2b9270..be4ec2e 100644 --- a/internal/repositories/user_repo.go +++ b/internal/repositories/user_repo.go @@ -21,6 +21,7 @@ var ( ErrTooManyAttempts = errors.New("too many attempts") ErrRateLimitExceeded = errors.New("rate limit exceeded") ErrAppleAuthNotFound = errors.New("apple social auth not found") + ErrGoogleAuthNotFound = errors.New("google social auth not found") ) // 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 { 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 +} diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go index 4210d1d..2eea5cb 100644 --- a/internal/services/auth_service.go +++ b/internal/services/auth_service.go @@ -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] +} diff --git a/internal/services/email_service.go b/internal/services/email_service.go index 1776633..cb4b312 100644 --- a/internal/services/email_service.go +++ b/internal/services/email_service.go @@ -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 + +
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