package authService import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "time" "git.ma-al.com/goc_marek/timetracker/app/config" "git.ma-al.com/goc_marek/timetracker/app/model" "git.ma-al.com/goc_marek/timetracker/app/view" "golang.org/x/oauth2" "golang.org/x/oauth2/google" ) const googleUserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo" // GoogleUserInfo represents the user info returned by Google type GoogleUserInfo struct { ID string `json:"id"` Email string `json:"email"` VerifiedEmail bool `json:"verified_email"` Name string `json:"name"` GivenName string `json:"given_name"` FamilyName string `json:"family_name"` Picture string `json:"picture"` } // googleOAuthConfig returns the OAuth2 config for Google func googleOAuthConfig() *oauth2.Config { cfg := config.Get().OAuth.Google scopes := cfg.Scopes if len(scopes) == 0 { scopes = []string{ "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", } } return &oauth2.Config{ ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, RedirectURL: cfg.RedirectURL, Scopes: scopes, Endpoint: google.Endpoint, } } // GenerateOAuthState generates a random state token for CSRF protection func (s *AuthService) GenerateOAuthState() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // GetGoogleAuthURL returns the Google OAuth2 authorization URL with a state token func (s *AuthService) GetGoogleAuthURL(state string) string { return googleOAuthConfig().AuthCodeURL(state, oauth2.AccessTypeOnline) } // HandleGoogleCallback exchanges the code for a token, fetches user info, // and either logs in or registers the user, returning an AuthResponse and raw refresh token. func (s *AuthService) HandleGoogleCallback(code string) (*model.AuthResponse, string, error) { oauthCfg := googleOAuthConfig() // Exchange authorization code for token token, err := oauthCfg.Exchange(context.Background(), code) if err != nil { return nil, "", fmt.Errorf("failed to exchange code: %w", err) } // Fetch user info from Google userInfo, err := fetchGoogleUserInfo(oauthCfg.Client(context.Background(), token)) if err != nil { return nil, "", fmt.Errorf("failed to fetch user info: %w", err) } if !userInfo.VerifiedEmail { return nil, "", view.ErrEmailNotVerified } // Find or create user user, err := s.findOrCreateGoogleUser(userInfo) if err != nil { return nil, "", err } // Update last login now := time.Now() user.LastLoginAt = &now s.db.Save(user) // Generate access token (JWT) accessToken, err := s.generateAccessToken(user) if err != nil { return nil, "", fmt.Errorf("failed to generate access token: %w", err) } // Generate opaque refresh token and store in DB rawRefreshToken, err := s.createRefreshToken(user.ID) if err != nil { return nil, "", fmt.Errorf("failed to create refresh token: %w", err) } return &model.AuthResponse{ AccessToken: accessToken, TokenType: "Bearer", ExpiresIn: s.config.JWTExpiration, User: user.ToSession(), }, rawRefreshToken, nil } // findOrCreateGoogleUser finds an existing user by Google provider ID or email, // or creates a new one. func (s *AuthService) findOrCreateGoogleUser(info *GoogleUserInfo) (*model.Customer, error) { var user model.Customer // Try to find by provider + provider_id err := s.db.Where("provider = ? AND provider_id = ?", model.ProviderGoogle, info.ID).First(&user).Error if err == nil { // Update avatar in case it changed user.AvatarURL = info.Picture s.db.Save(&user) return &user, nil } // Try to find by email (user may have registered locally before) err = s.db.Where("email = ?", info.Email).First(&user).Error if err == nil { // Link Google provider to existing account user.Provider = model.ProviderGoogle user.ProviderID = info.ID user.AvatarURL = info.Picture user.IsActive = true s.db.Save(&user) // If email has not been verified yet, send email to admin. if !user.EmailVerified { baseURL := config.Get().App.BaseURL if err := s.email.SendNewUserAdminNotification(info.Email, info.Name, baseURL); err != nil { // Log error but don't fail registration _ = err } } user.EmailVerified = true return &user, nil } // Create new user newUser := model.Customer{ Email: info.Email, FirstName: info.GivenName, LastName: info.FamilyName, Provider: model.ProviderGoogle, ProviderID: info.ID, AvatarURL: info.Picture, Role: model.RoleUser, IsActive: true, EmailVerified: true, Lang: "en", } if err := s.db.Create(&newUser).Error; err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } // If everything succeeded, send email to admin. if !user.EmailVerified { baseURL := config.Get().App.BaseURL if err := s.email.SendNewUserAdminNotification(info.Email, info.Name, baseURL); err != nil { // Log error but don't fail registration _ = err } } return &newUser, nil } // fetchGoogleUserInfo fetches user info from Google using the provided HTTP client func fetchGoogleUserInfo(client *http.Client) (*GoogleUserInfo, error) { resp, err := client.Get(googleUserInfoURL) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var userInfo GoogleUserInfo if err := json.Unmarshal(body, &userInfo); err != nil { return nil, err } return &userInfo, nil }