first commit
This commit is contained in:
823
app/accounts/controllers/user.go
Normal file
823
app/accounts/controllers/user.go
Normal file
@@ -0,0 +1,823 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/oauth2"
|
||||
githuboauth "golang.org/x/oauth2/github"
|
||||
"golang.org/x/oauth2/google"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"goaresv3/app/accounts/models"
|
||||
"goaresv3/config"
|
||||
jwtHelper "goaresv3/pkg/jwt"
|
||||
"goaresv3/pkg/mailer"
|
||||
)
|
||||
|
||||
const googleOAuthStateCookieName = "google_oauth_state"
|
||||
const githubOAuthStateCookieName = "github_oauth_state"
|
||||
|
||||
type socialUserProfile struct {
|
||||
ProviderID string
|
||||
Email string
|
||||
Name string
|
||||
AvatarURL string
|
||||
EmailVerified bool
|
||||
}
|
||||
|
||||
type googleUserInfo struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
}
|
||||
|
||||
var exchangeGoogleCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) {
|
||||
return cfg.Exchange(ctx, code)
|
||||
}
|
||||
|
||||
var fetchGoogleUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*googleUserInfo, error) {
|
||||
client := cfg.Client(ctx, token)
|
||||
resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("userinfo status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var info googleUserInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
type githubUserInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
type githubEmailInfo struct {
|
||||
Email string `json:"email"`
|
||||
Verified bool `json:"verified"`
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
|
||||
var exchangeGitHubCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) {
|
||||
return cfg.Exchange(ctx, code)
|
||||
}
|
||||
|
||||
var fetchGitHubUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*socialUserProfile, error) {
|
||||
client := cfg.Client(ctx, token)
|
||||
|
||||
userResp, err := client.Get("https://api.github.com/user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer userResp.Body.Close()
|
||||
if userResp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("github userinfo status: %d", userResp.StatusCode)
|
||||
}
|
||||
|
||||
var user githubUserInfo
|
||||
if err := json.NewDecoder(userResp.Body).Decode(&user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(user.Email)
|
||||
verifiedFromProvider := email != ""
|
||||
|
||||
emailsResp, err := client.Get("https://api.github.com/user/emails")
|
||||
if err == nil {
|
||||
defer emailsResp.Body.Close()
|
||||
if emailsResp.StatusCode == http.StatusOK {
|
||||
var emails []githubEmailInfo
|
||||
if err := json.NewDecoder(emailsResp.Body).Decode(&emails); err == nil {
|
||||
for _, e := range emails {
|
||||
if e.Primary && e.Verified && strings.TrimSpace(e.Email) != "" {
|
||||
email = strings.TrimSpace(e.Email)
|
||||
verifiedFromProvider = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if email == "" {
|
||||
for _, e := range emails {
|
||||
if e.Verified && strings.TrimSpace(e.Email) != "" {
|
||||
email = strings.TrimSpace(e.Email)
|
||||
verifiedFromProvider = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if user.ID == 0 || email == "" {
|
||||
return nil, fmt.Errorf("github profile is missing required fields")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(user.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(user.Login)
|
||||
}
|
||||
if name == "" {
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) > 0 {
|
||||
name = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
return &socialUserProfile{
|
||||
ProviderID: fmt.Sprintf("%d", user.ID),
|
||||
Email: email,
|
||||
Name: name,
|
||||
AvatarURL: strings.TrimSpace(user.AvatarURL),
|
||||
EmailVerified: verifiedFromProvider,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
|
||||
// ── Request DTOs ─────────────────────────────────────────────────────────────
|
||||
|
||||
type RegisterRequest struct {
|
||||
UserName string `json:"username" binding:"required,min=3,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
ConfirmPassword string `json:"confirm_password" binding:"required,min=8"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
func generateEmailVerifyToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func generateOAuthStateToken() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func envValue(key string) string {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
return strings.Trim(v, "'\"")
|
||||
}
|
||||
|
||||
func fallbackRedirectURL(path string) string {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:8080"
|
||||
}
|
||||
return baseURL + path
|
||||
}
|
||||
|
||||
func getGoogleOAuthConfig() (*oauth2.Config, error) {
|
||||
clientID := envValue("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY")
|
||||
clientSecret := envValue("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET")
|
||||
redirectURL := envValue("SOCIAL_AUTH_GOOGLE_REDIRECT_URL")
|
||||
if redirectURL == "" {
|
||||
redirectURL = fallbackRedirectURL("/api/v1/auth/google/callback")
|
||||
}
|
||||
|
||||
if clientID == "" || clientSecret == "" || redirectURL == "" {
|
||||
return nil, fmt.Errorf("google oauth configuration is incomplete")
|
||||
}
|
||||
|
||||
scopes := []string{"openid", "email", "profile"}
|
||||
if rawScopes := envValue("SOCIAL_AUTH_GOOGLE_SCOPES"); rawScopes != "" {
|
||||
scopes = scopes[:0]
|
||||
for _, scope := range strings.Split(rawScopes, ",") {
|
||||
s := strings.TrimSpace(scope)
|
||||
if s != "" {
|
||||
scopes = append(scopes, s)
|
||||
}
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{"openid", "email", "profile"}
|
||||
}
|
||||
}
|
||||
|
||||
return &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: scopes,
|
||||
Endpoint: google.Endpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getGitHubOAuthConfig() (*oauth2.Config, error) {
|
||||
clientID := envValue("SOCIAL_AUTH_GITHUB_KEY")
|
||||
clientSecret := envValue("SOCIAL_AUTH_GITHUB_SECRET")
|
||||
redirectURL := envValue("SOCIAL_AUTH_GITHUB_REDIRECT_URL")
|
||||
if redirectURL == "" {
|
||||
redirectURL = fallbackRedirectURL("/api/v1/auth/github/callback")
|
||||
}
|
||||
|
||||
if clientID == "" || clientSecret == "" || redirectURL == "" {
|
||||
return nil, fmt.Errorf("github oauth configuration is incomplete")
|
||||
}
|
||||
|
||||
scopes := []string{"read:user", "user:email"}
|
||||
if rawScopes := envValue("SOCIAL_AUTH_GITHUB_SCOPES"); rawScopes != "" {
|
||||
scopes = scopes[:0]
|
||||
for _, scope := range strings.Split(rawScopes, ",") {
|
||||
s := strings.TrimSpace(scope)
|
||||
if s != "" {
|
||||
scopes = append(scopes, s)
|
||||
}
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{"read:user", "user:email"}
|
||||
}
|
||||
}
|
||||
|
||||
return &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: scopes,
|
||||
Endpoint: githuboauth.Endpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveUserNameFromGoogleProfile(profile *googleUserInfo) string {
|
||||
if profile == nil {
|
||||
return ""
|
||||
}
|
||||
if profile.Name != "" {
|
||||
return profile.Name
|
||||
}
|
||||
if profile.GivenName != "" {
|
||||
return profile.GivenName
|
||||
}
|
||||
if profile.Email != "" {
|
||||
parts := strings.Split(profile.Email, "@")
|
||||
if len(parts) > 0 {
|
||||
return parts[0]
|
||||
}
|
||||
}
|
||||
return "google-user"
|
||||
}
|
||||
|
||||
func completeSocialLogin(c *gin.Context, provider string, profile *socialUserProfile) {
|
||||
if profile == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "provider profile is missing"})
|
||||
return
|
||||
}
|
||||
if profile.ProviderID == "" || profile.Email == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "provider profile is missing required fields"})
|
||||
return
|
||||
}
|
||||
if !profile.EmailVerified {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "provider email is not verified"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
err := config.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var social models.SocialAccount
|
||||
err := tx.Where("provider = ? AND provider_id = ?", provider, profile.ProviderID).First(&social).Error
|
||||
if err == nil {
|
||||
if err := tx.First(&user, social.UserID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err == gorm.ErrRecordNotFound {
|
||||
err = tx.Where("email = ?", profile.Email).First(&user).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
verified := true
|
||||
user = models.User{
|
||||
UserName: profile.Name,
|
||||
Email: profile.Email,
|
||||
EmailVerified: &verified,
|
||||
EmailVerifiedAt: ptrTime(time.Now()),
|
||||
}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
social = models.SocialAccount{
|
||||
UserID: uint64(user.ID),
|
||||
Provider: provider,
|
||||
ProviderID: profile.ProviderID,
|
||||
Email: profile.Email,
|
||||
Name: profile.Name,
|
||||
AvatarURL: profile.AvatarURL,
|
||||
}
|
||||
if err := tx.Create(&social).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
|
||||
if !user.IsEmailVerified() {
|
||||
verified := true
|
||||
now := time.Now()
|
||||
if err := tx.Model(&user).Updates(map[string]any{
|
||||
"email_verified": &verified,
|
||||
"email_verified_at": &now,
|
||||
"email_verify_token": "",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
user.EmailVerified = &verified
|
||||
user.EmailVerifiedAt = &now
|
||||
user.EmailVerifyToken = ""
|
||||
}
|
||||
|
||||
return tx.Model(&models.SocialAccount{}).
|
||||
Where("user_id = ? AND provider = ? AND provider_id = ?", user.ID, provider, profile.ProviderID).
|
||||
Updates(map[string]any{
|
||||
"email": profile.Email,
|
||||
"name": profile.Name,
|
||||
"avatar_url": profile.AvatarURL,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process social login"})
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := jwtHelper.GenerateAccessToken(user.ID, user.Email, user.UserName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate access token"})
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken, err := jwtHelper.GenerateRefreshToken(user.ID, user.Email, user.UserName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
"token_type": "Bearer",
|
||||
"provider": provider,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Register godoc
|
||||
// @Summary Register a new user
|
||||
// @Description Creates a user and sends an email verification link.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body RegisterRequest true "register payload"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/v1/auth/register [post]
|
||||
func Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Password != req.ConfirmPassword {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "password and confirm_password do not match"})
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not process password"})
|
||||
return
|
||||
}
|
||||
|
||||
verifyToken, err := generateEmailVerifyToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate verification token"})
|
||||
return
|
||||
}
|
||||
|
||||
verified := false
|
||||
|
||||
user := models.User{
|
||||
UserName: req.UserName,
|
||||
Email: req.Email,
|
||||
Password: string(hashed),
|
||||
EmailVerified: &verified,
|
||||
EmailVerifyToken: verifyToken,
|
||||
}
|
||||
|
||||
if result := config.DB.Create(&user); result.Error != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "email already in use"})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(os.Getenv("APP_BASE_URL"), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:8080"
|
||||
}
|
||||
verifyURL := fmt.Sprintf("%s/api/v1/auth/verify-email?token=%s", baseURL, verifyToken)
|
||||
|
||||
body := fmt.Sprintf("Hello %s,\n\nPlease verify your email by clicking the link below:\n%s\n\nIf you did not create this account, you can ignore this email.", user.UserName, verifyURL)
|
||||
if err := mailer.Send(user.Email, "Verify your email", body); err != nil {
|
||||
_ = config.DB.Delete(&user).Error
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send verification email"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "user created. please verify your email",
|
||||
"user_id": user.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyEmail godoc
|
||||
// @Summary Verify email address
|
||||
// @Description Activates account using email verification token.
|
||||
// @Tags Auth
|
||||
// @Produce json
|
||||
// @Param token query string true "email verification token"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/auth/verify-email [get]
|
||||
func VerifyEmail(c *gin.Context) {
|
||||
token := strings.TrimSpace(c.Query("token"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "verification token is required"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
verified := true
|
||||
|
||||
result := config.DB.Model(&models.User{}).
|
||||
Where("email_verify_token = ?", token).
|
||||
Updates(map[string]interface{}{
|
||||
"email_verified": &verified,
|
||||
"email_verify_token": "",
|
||||
"email_verified_at": &now,
|
||||
})
|
||||
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not verify email"})
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired verification token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "email verified successfully"})
|
||||
}
|
||||
|
||||
// Login godoc
|
||||
// @Summary Login with email/password
|
||||
// @Description Returns access and refresh tokens.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body LoginRequest true "login payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/v1/auth/login [post]
|
||||
func Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if result := config.DB.Where("email = ?", req.Email).First(&user); result.Error != nil {
|
||||
// Return generic message to avoid user enumeration
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid email or password"})
|
||||
return
|
||||
}
|
||||
|
||||
if !user.IsEmailVerified() {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "email is not verified"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid email or password"})
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := jwtHelper.GenerateAccessToken(user.ID, user.Email, user.UserName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate access token"})
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken, err := jwtHelper.GenerateRefreshToken(user.ID, user.Email, user.UserName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}
|
||||
|
||||
// GoogleLogin godoc
|
||||
// @Summary Start Google OAuth login
|
||||
// @Description Returns Google authorization URL and sets state cookie for CSRF protection.
|
||||
// @Tags Auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/auth/google/login [get]
|
||||
func GoogleLogin(c *gin.Context) {
|
||||
cfg, err := getGoogleOAuthConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "google oauth is not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
state, err := generateOAuthStateToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not initialize google login"})
|
||||
return
|
||||
}
|
||||
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(googleOAuthStateCookieName, state, 600, "/", "", false, true)
|
||||
|
||||
authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||
c.JSON(http.StatusOK, gin.H{"auth_url": authURL})
|
||||
}
|
||||
|
||||
// GoogleCallback godoc
|
||||
// @Summary Google OAuth callback
|
||||
// @Description Exchanges Google code and returns local access/refresh tokens.
|
||||
// @Tags Auth
|
||||
// @Produce json
|
||||
// @Param state query string true "oauth state"
|
||||
// @Param code query string true "authorization code"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/auth/google/callback [get]
|
||||
func GoogleCallback(c *gin.Context) {
|
||||
cfg, err := getGoogleOAuthConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "google oauth is not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
if oauthErr := strings.TrimSpace(c.Query("error")); oauthErr != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "google authorization failed"})
|
||||
return
|
||||
}
|
||||
|
||||
state := strings.TrimSpace(c.Query("state"))
|
||||
code := strings.TrimSpace(c.Query("code"))
|
||||
if state == "" || code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "state and code are required"})
|
||||
return
|
||||
}
|
||||
|
||||
storedState, err := c.Cookie(googleOAuthStateCookieName)
|
||||
if err != nil || storedState == "" || storedState != state {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid oauth state"})
|
||||
return
|
||||
}
|
||||
c.SetCookie(googleOAuthStateCookieName, "", -1, "/", "", false, true)
|
||||
|
||||
token, err := exchangeGoogleCode(c.Request.Context(), cfg, code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to exchange google code"})
|
||||
return
|
||||
}
|
||||
|
||||
profile, err := fetchGoogleUserInfo(c.Request.Context(), cfg, token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to fetch google profile"})
|
||||
return
|
||||
}
|
||||
|
||||
if profile.Sub == "" || profile.Email == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "google profile is missing required fields"})
|
||||
return
|
||||
}
|
||||
|
||||
completeSocialLogin(c, "google", &socialUserProfile{
|
||||
ProviderID: profile.Sub,
|
||||
Email: profile.Email,
|
||||
Name: resolveUserNameFromGoogleProfile(profile),
|
||||
AvatarURL: profile.Picture,
|
||||
EmailVerified: profile.EmailVerified,
|
||||
})
|
||||
}
|
||||
|
||||
// GitHubLogin godoc
|
||||
// @Summary Start GitHub OAuth login
|
||||
// @Description Returns GitHub authorization URL and sets state cookie for CSRF protection.
|
||||
// @Tags Auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/auth/github/login [get]
|
||||
func GitHubLogin(c *gin.Context) {
|
||||
cfg, err := getGitHubOAuthConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "github oauth is not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
state, err := generateOAuthStateToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not initialize github login"})
|
||||
return
|
||||
}
|
||||
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(githubOAuthStateCookieName, state, 600, "/", "", false, true)
|
||||
|
||||
authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||
c.JSON(http.StatusOK, gin.H{"auth_url": authURL})
|
||||
}
|
||||
|
||||
// GitHubCallback godoc
|
||||
// @Summary GitHub OAuth callback
|
||||
// @Description Exchanges GitHub code and returns local access/refresh tokens.
|
||||
// @Tags Auth
|
||||
// @Produce json
|
||||
// @Param state query string true "oauth state"
|
||||
// @Param code query string true "authorization code"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/auth/github/callback [get]
|
||||
func GitHubCallback(c *gin.Context) {
|
||||
cfg, err := getGitHubOAuthConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "github oauth is not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
if oauthErr := strings.TrimSpace(c.Query("error")); oauthErr != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "github authorization failed"})
|
||||
return
|
||||
}
|
||||
|
||||
state := strings.TrimSpace(c.Query("state"))
|
||||
code := strings.TrimSpace(c.Query("code"))
|
||||
if state == "" || code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "state and code are required"})
|
||||
return
|
||||
}
|
||||
|
||||
storedState, err := c.Cookie(githubOAuthStateCookieName)
|
||||
if err != nil || storedState == "" || storedState != state {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid oauth state"})
|
||||
return
|
||||
}
|
||||
c.SetCookie(githubOAuthStateCookieName, "", -1, "/", "", false, true)
|
||||
|
||||
token, err := exchangeGitHubCode(c.Request.Context(), cfg, code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to exchange github code"})
|
||||
return
|
||||
}
|
||||
|
||||
profile, err := fetchGitHubUserInfo(c.Request.Context(), cfg, token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to fetch github profile"})
|
||||
return
|
||||
}
|
||||
|
||||
completeSocialLogin(c, "github", profile)
|
||||
}
|
||||
|
||||
func ptrTime(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
// RefreshToken godoc
|
||||
// @Summary Refresh access token
|
||||
// @Description Exchanges a valid refresh token for a new access token.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body RefreshRequest true "refresh payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/v1/auth/refresh [post]
|
||||
func RefreshToken(c *gin.Context) {
|
||||
var req RefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := jwtHelper.ValidateToken(req.RefreshToken, os.Getenv("JWT_REFRESH_SECRET"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if result := config.DB.Select("email", "user_name", "email_verified").First(&user, claims.UserID); result.Error != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user"})
|
||||
return
|
||||
}
|
||||
if !user.IsEmailVerified() {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "email is not verified"})
|
||||
return
|
||||
}
|
||||
|
||||
userName := claims.UserName
|
||||
if userName == "" {
|
||||
if result := config.DB.Select("user_name").First(&user, claims.UserID); result.Error == nil {
|
||||
userName = user.UserName
|
||||
}
|
||||
}
|
||||
if userName == "" {
|
||||
userName = user.UserName
|
||||
}
|
||||
|
||||
accessToken, err := jwtHelper.GenerateAccessToken(claims.UserID, user.Email, userName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate access token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": accessToken,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}
|
||||
|
||||
// Me godoc
|
||||
// @Summary Get current user info
|
||||
// @Description Returns user_id, email and username from the authenticated user.
|
||||
// @Tags User
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/v1/me [get]
|
||||
func Me(c *gin.Context) {
|
||||
userName := c.GetString("username")
|
||||
if userName == "" {
|
||||
var user models.User
|
||||
if result := config.DB.Select("user_name").First(&user, c.GetUint("user_id")); result.Error == nil {
|
||||
userName = user.UserName
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": c.GetUint("user_id"),
|
||||
"email": c.GetString("email"),
|
||||
"username": userName,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user