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:
@@ -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
|
||||||
|
|||||||
@@ -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{},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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です",
|
||||||
|
|||||||
@@ -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입니다",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 无效",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;">🏠 <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
|
// 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"
|
||||||
|
|||||||
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