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

+ + + %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" diff --git a/internal/services/google_auth.go b/internal/services/google_auth.go new file mode 100644 index 0000000..b854859 --- /dev/null +++ b/internal/services/google_auth.go @@ -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 +}