616 lines
17 KiB
Go
616 lines
17 KiB
Go
package controllers
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
database "goGin/app/database/config"
|
|
"goGin/app/database/models"
|
|
"goGin/app/middlewares"
|
|
"goGin/app/services"
|
|
configs "goGin/config"
|
|
utils "goGin/pkg/utis"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/oauth2"
|
|
"golang.org/x/oauth2/github"
|
|
"golang.org/x/oauth2/google"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// AuthResponse
|
|
type AuthResponse struct {
|
|
User UserResponse `json:"user"`
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
}
|
|
|
|
// RegisterPayload
|
|
type RegisterPayload struct {
|
|
UserName string `json:"username" binding:"required"`
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required,min=6"`
|
|
}
|
|
|
|
// LoginPayload
|
|
type LoginPayload struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required"`
|
|
}
|
|
|
|
// RefreshPayload
|
|
type RefreshPayload struct {
|
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
|
}
|
|
|
|
// Helper to generate secure token for email verification
|
|
func generateSecureToken() string {
|
|
b := make([]byte, 32)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// Register godoc
|
|
// @Summary Register a new user
|
|
// @Description Register a new user. Sends verification email. Does NOT return tokens.
|
|
// @Tags auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param register body RegisterPayload true "Register payload"
|
|
// @Success 201 {object} controllers.AuthResponse
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /api/v1/auth/register [post]
|
|
func Register(c *gin.Context) {
|
|
if database.DB == nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
|
return
|
|
}
|
|
|
|
var payload RegisterPayload
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Check existing email
|
|
var existing models.User
|
|
if err := database.DB.Where("email = ?", payload.Email).First(&existing).Error; err == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "email already registered"})
|
|
return
|
|
}
|
|
|
|
hashedPwd, err := utils.HashPassword(payload.Password)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
|
return
|
|
}
|
|
|
|
// Email Verification Token
|
|
verificationToken := generateSecureToken()
|
|
emailVerified := false
|
|
|
|
user := models.User{
|
|
UserName: payload.UserName,
|
|
Email: payload.Email,
|
|
Password: hashedPwd,
|
|
EmailVerified: &emailVerified,
|
|
EmailVerifyToken: verificationToken,
|
|
}
|
|
|
|
if err := database.DB.Create(&user).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Send Verification Email
|
|
go func() {
|
|
if err := utils.SendVerificationEmail(user.Email, verificationToken); err != nil {
|
|
fmt.Printf("Failed to send verification email to %s: %v\n", user.Email, err)
|
|
}
|
|
}()
|
|
|
|
// Response
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"message": "Registration successful. Please check your email to verify your account.",
|
|
"user": toUserResponse(user),
|
|
})
|
|
}
|
|
|
|
// VerifyEmail godoc
|
|
// @Summary Verify email address
|
|
// @Description Verify email using token
|
|
// @Tags auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param token query string true "Verification Token"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/auth/verify-email [get]
|
|
func VerifyEmail(c *gin.Context) {
|
|
token := c.Query("token")
|
|
if token == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
|
|
return
|
|
}
|
|
|
|
var user models.User
|
|
if err := database.DB.Where("email_verify_token = ?", token).First(&user).Error; err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired token"})
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
verified := true
|
|
user.EmailVerified = &verified
|
|
user.EmailVerifiedAt = &now
|
|
user.EmailVerifyToken = "" // Clear token
|
|
|
|
if err := database.DB.Save(&user).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify email"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Email verified successfully"})
|
|
}
|
|
|
|
// Login godoc
|
|
// @Summary Login user
|
|
// @Description Login with email and password, returns tokens
|
|
// @Tags auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param login body LoginPayload true "Login payload"
|
|
// @Success 200 {object} controllers.AuthResponse
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Router /api/v1/auth/login [post]
|
|
func Login(c *gin.Context) {
|
|
if database.DB == nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
|
return
|
|
}
|
|
|
|
var payload LoginPayload
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
var user models.User
|
|
if err := database.DB.Where("email = ?", payload.Email).First(&user).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if !utils.CheckPasswordHash(payload.Password, user.Password) {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
|
return
|
|
}
|
|
|
|
// Check if email is verified
|
|
if user.EmailVerified != nil && !*user.EmailVerified {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "email not verified"})
|
|
return
|
|
}
|
|
|
|
isAdmin := false
|
|
if user.IsAdmin != nil && *user.IsAdmin {
|
|
isAdmin = true
|
|
}
|
|
|
|
jwtService := services.NewJWTService()
|
|
accessToken, err := jwtService.GenerateToken(user.ID, isAdmin)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate access token"})
|
|
return
|
|
}
|
|
refreshToken, err := jwtService.GenerateRefreshToken(user.ID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate refresh token"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"user": toUserResponse(user),
|
|
"access_token": accessToken,
|
|
"refresh_token": refreshToken,
|
|
})
|
|
}
|
|
|
|
// Refresh godoc
|
|
// @Summary Refresh access token
|
|
// @Description usage: send refresh token to get new access token and refresh token
|
|
// @Tags auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param refresh body RefreshPayload true "Refresh token payload"
|
|
// @Success 200 {object} map[string]string "Returns both access_token and refresh_token"
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Router /api/v1/auth/refresh [post]
|
|
func Refresh(c *gin.Context) {
|
|
var payload RefreshPayload
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
jwtService := services.NewJWTService()
|
|
claims, err := jwtService.ValidateToken(payload.RefreshToken)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
|
return
|
|
}
|
|
|
|
if claims.TokenType != "refresh" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "not a refresh token"})
|
|
return
|
|
}
|
|
|
|
// Get User
|
|
var userID uint
|
|
switch v := claims.UserID.(type) {
|
|
case float64:
|
|
userID = uint(v)
|
|
case uint:
|
|
userID = v
|
|
default:
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user claim"})
|
|
return
|
|
}
|
|
|
|
var user models.User
|
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
|
return
|
|
}
|
|
|
|
isAdmin := false
|
|
if user.IsAdmin != nil && *user.IsAdmin {
|
|
isAdmin = true
|
|
}
|
|
|
|
newAccessToken, err := jwtService.GenerateToken(user.ID, isAdmin)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
|
return
|
|
}
|
|
|
|
newRefreshToken, err := jwtService.GenerateRefreshToken(user.ID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate refresh token"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"access_token": newAccessToken,
|
|
"refresh_token": newRefreshToken,
|
|
})
|
|
}
|
|
|
|
// Me godoc
|
|
// @Summary Get current user (me)
|
|
// @Description Get current authenticated user information
|
|
// @Tags auth
|
|
// @Security BearerAuth
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /api/v1/auth/me [get]
|
|
func Me(c *gin.Context) {
|
|
claims, ok := middlewares.GetAuthClaims(c)
|
|
if !ok {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
var userID uint
|
|
switch v := claims.UserID.(type) {
|
|
case float64:
|
|
userID = uint(v)
|
|
case uint:
|
|
userID = v
|
|
default:
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user claim"})
|
|
return
|
|
}
|
|
|
|
var user models.User
|
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
|
return
|
|
}
|
|
|
|
isAdmin := false
|
|
if user.IsAdmin != nil && *user.IsAdmin {
|
|
isAdmin = true
|
|
}
|
|
|
|
isVerified := false
|
|
if user.EmailVerified != nil && *user.EmailVerified {
|
|
isVerified = true
|
|
}
|
|
|
|
// Frontend'in beklediği formata göre response döndür
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"id": user.ID,
|
|
"username": user.UserName,
|
|
"email": user.Email,
|
|
"email_verified": isVerified,
|
|
"is_admin": isAdmin,
|
|
})
|
|
}
|
|
|
|
// OAuth Helpers
|
|
var (
|
|
googleOauthConfig = &oauth2.Config{
|
|
RedirectURL: "", // Will be set in init or handler
|
|
ClientID: "",
|
|
ClientSecret: "",
|
|
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"},
|
|
Endpoint: google.Endpoint,
|
|
}
|
|
githubOauthConfig = &oauth2.Config{
|
|
RedirectURL: "",
|
|
ClientID: "",
|
|
ClientSecret: "",
|
|
Scopes: []string{"user:email"},
|
|
Endpoint: github.Endpoint,
|
|
}
|
|
)
|
|
|
|
func getGoogleConfig() *oauth2.Config {
|
|
googleOauthConfig.ClientID = configs.AppConfig.GoogleClientID
|
|
googleOauthConfig.ClientSecret = configs.AppConfig.GoogleClientSecret
|
|
googleOauthConfig.RedirectURL = configs.AppConfig.GoogleRedirectURL
|
|
return googleOauthConfig
|
|
}
|
|
|
|
func getGithubConfig() *oauth2.Config {
|
|
githubOauthConfig.ClientID = configs.AppConfig.GithubClientID
|
|
githubOauthConfig.ClientSecret = configs.AppConfig.GithubClientSecret
|
|
githubOauthConfig.RedirectURL = configs.AppConfig.GithubRedirectURL
|
|
return githubOauthConfig
|
|
}
|
|
|
|
// GoogleLogin godoc
|
|
// @Summary Google OAuth2 Login
|
|
// @Description Redirects to Google for authentication
|
|
// @Tags auth
|
|
// @Success 302
|
|
// @Router /api/v1/auth/google [get]
|
|
func GoogleLogin(c *gin.Context) {
|
|
url := getGoogleConfig().AuthCodeURL("state_google", oauth2.AccessTypeOffline)
|
|
c.Redirect(http.StatusTemporaryRedirect, url)
|
|
}
|
|
|
|
// GoogleCallback godoc
|
|
// @Summary Google OAuth2 Callback
|
|
// @Description Handles Google OAuth2 callback
|
|
// @Tags auth
|
|
// @Success 200 {object} controllers.AuthResponse
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /api/v1/auth/google/callback [get]
|
|
func GoogleCallback(c *gin.Context) {
|
|
code := c.Query("code")
|
|
token, err := getGoogleConfig().Exchange(context.Background(), code)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
client := getGoogleConfig().Client(context.Background(), token)
|
|
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info: " + err.Error()})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
userData, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read user info"})
|
|
return
|
|
}
|
|
|
|
var googleUser struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
VerifiedEmail bool `json:"verified_email"`
|
|
Name string `json:"name"`
|
|
Picture string `json:"picture"`
|
|
}
|
|
if err := json.Unmarshal(userData, &googleUser); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse user info"})
|
|
return
|
|
}
|
|
|
|
handleSocialLogin(c, "google", googleUser.ID, googleUser.Email, googleUser.Name, googleUser.Picture)
|
|
}
|
|
|
|
// GithubLogin godoc
|
|
// @Summary GitHub OAuth2 Login
|
|
// @Description Redirects to GitHub for authentication
|
|
// @Tags auth
|
|
// @Success 302
|
|
// @Router /api/v1/auth/github [get]
|
|
func GithubLogin(c *gin.Context) {
|
|
url := getGithubConfig().AuthCodeURL("state_github", oauth2.AccessTypeOffline)
|
|
c.Redirect(http.StatusTemporaryRedirect, url)
|
|
}
|
|
|
|
// GithubCallback godoc
|
|
// @Summary GitHub OAuth2 Callback
|
|
// @Description Handles GitHub OAuth2 callback
|
|
// @Tags auth
|
|
// @Success 200 {object} controllers.AuthResponse
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /api/v1/auth/github/callback [get]
|
|
func GithubCallback(c *gin.Context) {
|
|
code := c.Query("code")
|
|
token, err := getGithubConfig().Exchange(context.Background(), code)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
client := getGithubConfig().Client(context.Background(), token)
|
|
resp, err := client.Get("https://api.github.com/user")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info: " + err.Error()})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
userData, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read user info"})
|
|
return
|
|
}
|
|
|
|
var githubUser struct {
|
|
ID int64 `json:"id"`
|
|
Login string `json:"login"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
}
|
|
if err := json.Unmarshal(userData, &githubUser); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse user info"})
|
|
return
|
|
}
|
|
|
|
// GitHub email might be private, need to fetch separately if empty
|
|
email := githubUser.Email
|
|
if email == "" {
|
|
// Fetch emails
|
|
emailResp, err := client.Get("https://api.github.com/user/emails")
|
|
if err == nil {
|
|
defer emailResp.Body.Close()
|
|
var emails []struct {
|
|
Email string `json:"email"`
|
|
Primary bool `json:"primary"`
|
|
Verified bool `json:"verified"`
|
|
}
|
|
if body, err := io.ReadAll(emailResp.Body); err == nil {
|
|
json.Unmarshal(body, &emails)
|
|
for _, e := range emails {
|
|
if e.Primary && e.Verified {
|
|
email = e.Email
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if email == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Could not retrieve email from GitHub"})
|
|
return
|
|
}
|
|
|
|
handleSocialLogin(c, "github", fmt.Sprintf("%d", githubUser.ID), email, githubUser.Name, githubUser.AvatarURL)
|
|
}
|
|
|
|
func handleSocialLogin(c *gin.Context, provider, providerID, email, name, avatarURL string) {
|
|
var user models.User
|
|
var socialAccount models.SocialAccount
|
|
|
|
// Check if social account exists
|
|
err := database.DB.Where("provider = ? AND provider_id = ?", provider, providerID).First(&socialAccount).Error
|
|
|
|
if err == nil {
|
|
// Found social account, find user
|
|
if err := database.DB.First(&user, socialAccount.UserID).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "User record missing for social account"})
|
|
return
|
|
}
|
|
} else {
|
|
// Social account not found. Check if email exists
|
|
if err := database.DB.Where("email = ?", email).First(&user).Error; err == nil {
|
|
// User exists, add social account
|
|
newSocial := models.SocialAccount{
|
|
UserID: uint64(user.ID),
|
|
Provider: provider,
|
|
ProviderID: providerID,
|
|
Email: email,
|
|
Name: name,
|
|
AvatarURL: avatarURL,
|
|
}
|
|
database.DB.Create(&newSocial)
|
|
} else {
|
|
// Create new user
|
|
verified := true
|
|
now := time.Now()
|
|
// Generate random password
|
|
randomPass := generateSecureToken()
|
|
hashedPwd, _ := utils.HashPassword(randomPass)
|
|
|
|
user = models.User{
|
|
UserName: name, // Handle duplicate usernames?
|
|
Email: email,
|
|
Password: hashedPwd,
|
|
EmailVerified: &verified,
|
|
EmailVerifiedAt: &now,
|
|
}
|
|
// Fallback username if empty
|
|
if user.UserName == "" {
|
|
user.UserName = strings.Split(email, "@")[0]
|
|
}
|
|
|
|
if err := database.DB.Create(&user).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
newSocial := models.SocialAccount{
|
|
UserID: uint64(user.ID),
|
|
Provider: provider,
|
|
ProviderID: providerID,
|
|
Email: email,
|
|
Name: name,
|
|
AvatarURL: avatarURL,
|
|
}
|
|
database.DB.Create(&newSocial)
|
|
}
|
|
}
|
|
|
|
// Login logic
|
|
isAdmin := false
|
|
if user.IsAdmin != nil && *user.IsAdmin {
|
|
isAdmin = true
|
|
}
|
|
|
|
jwtService := services.NewJWTService()
|
|
accessToken, err := jwtService.GenerateToken(user.ID, isAdmin)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate access token"})
|
|
return
|
|
}
|
|
refreshToken, err := jwtService.GenerateRefreshToken(user.ID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate refresh token"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"user": toUserResponse(user),
|
|
"access_token": accessToken,
|
|
"refresh_token": refreshToken,
|
|
})
|
|
}
|