195 lines
5.2 KiB
Go
195 lines
5.2 KiB
Go
package authService
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"git.ma-al.com/goc_daniel/b2b/app/config"
|
|
"git.ma-al.com/goc_daniel/b2b/app/model"
|
|
"git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors"
|
|
"git.ma-al.com/goc_daniel/b2b/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
|
|
}
|