Files
b2b/app/service/authService/google_oauth.go
2026-03-11 09:33:36 +01:00

195 lines
5.3 KiB
Go

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/utils/responseErrors"
"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"
// 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, "", responseErrors.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 *view.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) (*view.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 view.GoogleUserInfo
if err := json.Unmarshal(body, &userInfo); err != nil {
return nil, err
}
return &userInfo, nil
}