initial commit. Cloned timetracker repository
This commit is contained in:
204
app/service/authService/google_oauth.go
Normal file
204
app/service/authService/google_oauth.go
Normal file
@@ -0,0 +1,204 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user