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, }) }