327 lines
8.9 KiB
Go
327 lines
8.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/M1ngdaXie/realtime-collab/internal/auth"
|
|
"github.com/M1ngdaXie/realtime-collab/internal/config"
|
|
"github.com/M1ngdaXie/realtime-collab/internal/models"
|
|
"github.com/M1ngdaXie/realtime-collab/internal/store"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
type AuthHandler struct {
|
|
store store.Store
|
|
cfg *config.Config
|
|
googleConfig *oauth2.Config
|
|
githubConfig *oauth2.Config
|
|
}
|
|
|
|
func NewAuthHandler(store store.Store, cfg *config.Config) *AuthHandler {
|
|
var googleConfig *oauth2.Config
|
|
if cfg.HasGoogleOAuth() {
|
|
googleConfig = auth.GetGoogleOAuthConfig(
|
|
cfg.GoogleClientID,
|
|
cfg.GoogleClientSecret,
|
|
cfg.GoogleRedirectURL,
|
|
)
|
|
}
|
|
|
|
var githubConfig *oauth2.Config
|
|
if cfg.HasGitHubOAuth() {
|
|
githubConfig = auth.GetGitHubOAuthConfig(
|
|
cfg.GitHubClientID,
|
|
cfg.GitHubClientSecret,
|
|
cfg.GitHubRedirectURL,
|
|
)
|
|
}
|
|
|
|
return &AuthHandler{
|
|
store: store,
|
|
cfg: cfg,
|
|
googleConfig: googleConfig,
|
|
githubConfig: githubConfig,
|
|
}
|
|
}
|
|
|
|
// GoogleLogin redirects to Google OAuth
|
|
func (h *AuthHandler) GoogleLogin(c *gin.Context) {
|
|
// Generate random state and set cookie
|
|
oauthState := h.generateStateOauthCookie(c.Writer)
|
|
url := h.googleConfig.AuthCodeURL(oauthState, oauth2.AccessTypeOffline)
|
|
c.Redirect(http.StatusTemporaryRedirect, url)
|
|
}
|
|
|
|
// GoogleCallback handles Google OAuth callback
|
|
func (h *AuthHandler) GoogleCallback(c *gin.Context) {
|
|
oauthState, err := c.Cookie("oauthstate")
|
|
if err != nil || c.Query("state") != oauthState {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid oauth state"})
|
|
return
|
|
}
|
|
log.Println("Google callback state:", c.Query("state"))
|
|
// Exchange code for token
|
|
token, err := h.googleConfig.Exchange(c.Request.Context(), c.Query("code"))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token"})
|
|
return
|
|
}
|
|
|
|
// Get user info from Google
|
|
client := h.googleConfig.Client(c.Request.Context(), 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"})
|
|
return
|
|
}
|
|
log.Println("Google user info response status:", resp.Status)
|
|
log.Println("Google user info response headers:", resp.Header)
|
|
defer resp.Body.Close()
|
|
|
|
data, _ := io.ReadAll(resp.Body)
|
|
var userInfo struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
Picture string `json:"picture"`
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &userInfo); err != nil {
|
|
log.Printf("Failed to parse Google response: %v | Data: %s", err, string(data))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid Google response"})
|
|
return
|
|
}
|
|
log.Println("Google user info:", userInfo)
|
|
// Upsert user in database
|
|
user, err := h.store.UpsertUser(
|
|
c.Request.Context(),
|
|
"google",
|
|
userInfo.ID,
|
|
userInfo.Email,
|
|
userInfo.Name,
|
|
&userInfo.Picture,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
|
return
|
|
}
|
|
|
|
// Create session and JWT
|
|
jwt, err := h.createSessionAndJWT(c, user)
|
|
if err != nil {
|
|
fmt.Printf("❌ DATABASE ERROR: %v\n", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": fmt.Sprintf("CreateSession Error: %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Redirect to frontend with token
|
|
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", h.cfg.FrontendURL, jwt)
|
|
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
|
}
|
|
|
|
// GithubLogin redirects to GitHub OAuth
|
|
func (h *AuthHandler) GithubLogin(c *gin.Context) {
|
|
oauthState := h.generateStateOauthCookie(c.Writer)
|
|
url := h.githubConfig.AuthCodeURL(oauthState)
|
|
c.Redirect(http.StatusTemporaryRedirect, url)
|
|
}
|
|
|
|
// GithubCallback handles GitHub OAuth callback
|
|
func (h *AuthHandler) GithubCallback(c *gin.Context) {
|
|
oauthState, err := c.Cookie("oauthstate")
|
|
if err != nil || c.Query("state") != oauthState {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid oauth state"})
|
|
return
|
|
}
|
|
log.Println("Github callback state:", c.Query("state"))
|
|
code := c.Query("code")
|
|
if code == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No code provided"})
|
|
return
|
|
}
|
|
|
|
// Exchange code for token
|
|
token, err := h.githubConfig.Exchange(c.Request.Context(), code)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token"})
|
|
return
|
|
}
|
|
|
|
// Get user info from GitHub
|
|
client := h.githubConfig.Client(c.Request.Context(), token)
|
|
|
|
// Get user profile
|
|
resp, err := client.Get("https://api.github.com/user")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, _ := io.ReadAll(resp.Body)
|
|
var userInfo struct {
|
|
ID int `json:"id"`
|
|
Login string `json:"login"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
}
|
|
if err := json.Unmarshal(data, &userInfo); err != nil {
|
|
log.Printf("Failed to parse GitHub response: %v | Data: %s", err, string(data))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid GitHub response"})
|
|
return
|
|
}
|
|
|
|
// If email is not public, fetch it separately
|
|
if userInfo.Email == "" {
|
|
emailResp, _ := client.Get("https://api.github.com/user/emails")
|
|
if emailResp != nil {
|
|
defer emailResp.Body.Close()
|
|
emailData, _ := io.ReadAll(emailResp.Body)
|
|
var emails []struct {
|
|
Email string `json:"email"`
|
|
Primary bool `json:"primary"`
|
|
}
|
|
json.Unmarshal(emailData, &emails)
|
|
for _, e := range emails {
|
|
if e.Primary {
|
|
userInfo.Email = e.Email
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use login as name if name is empty
|
|
if userInfo.Name == "" {
|
|
userInfo.Name = userInfo.Login
|
|
}
|
|
fmt.Println("Getting user info : ")
|
|
fmt.Println(userInfo)
|
|
// Upsert user in database
|
|
user, err := h.store.UpsertUser(
|
|
c.Request.Context(),
|
|
"github",
|
|
fmt.Sprintf("%d", userInfo.ID),
|
|
userInfo.Email,
|
|
userInfo.Name,
|
|
&userInfo.AvatarURL,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create user: %v", err)})
|
|
return
|
|
}
|
|
|
|
// Create session and JWT
|
|
jwt, err := h.createSessionAndJWT(c, user)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
|
return
|
|
}
|
|
|
|
// Redirect to frontend with token
|
|
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", h.cfg.FrontendURL, jwt)
|
|
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
|
}
|
|
|
|
// Me returns current user info
|
|
func (h *AuthHandler) Me(c *gin.Context) {
|
|
userID := auth.GetUserFromContext(c)
|
|
if userID == nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
return
|
|
}
|
|
|
|
user, err := h.store.GetUserByID(c.Request.Context(), *userID)
|
|
if err != nil || user == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, models.UserResponse{User: user})
|
|
}
|
|
|
|
// Logout invalidates the session
|
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
|
authHeader := c.GetHeader("Authorization")
|
|
if authHeader == "" {
|
|
c.JSON(http.StatusOK, gin.H{"message": "Already logged out"})
|
|
return
|
|
}
|
|
|
|
// Extract token
|
|
token := ""
|
|
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
|
token = authHeader[7:]
|
|
}
|
|
|
|
if token != "" {
|
|
h.store.DeleteSession(c.Request.Context(), token)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
|
|
}
|
|
|
|
// Helper: create session and JWT
|
|
func (h *AuthHandler) createSessionAndJWT(c *gin.Context, user *models.User) (string, error) {
|
|
expiresAt := time.Now().Add(7 * 24 * time.Hour) // 7 days
|
|
|
|
// Generate JWT first (we need it for session) - now includes avatar URL
|
|
jwt, err := auth.GenerateJWT(user.ID, user.Name, user.Email, user.AvatarURL, h.cfg.JWTSecret, 7*24*time.Hour)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Create session in database
|
|
sessionID := uuid.New()
|
|
userAgent := c.GetHeader("User-Agent")
|
|
ipAddress := c.ClientIP()
|
|
_, err = h.store.CreateSession(
|
|
c.Request.Context(),
|
|
user.ID,
|
|
sessionID,
|
|
jwt,
|
|
expiresAt,
|
|
&userAgent,
|
|
&ipAddress,
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return jwt, nil
|
|
}
|
|
func (h *AuthHandler) generateStateOauthCookie(w http.ResponseWriter) string {
|
|
b := make([]byte, 16)
|
|
n, err := rand.Read(b)
|
|
if err != nil || n != 16 {
|
|
fmt.Printf("Failed to generate random state: %v\n", err)
|
|
return "" // Critical for CSRF security
|
|
}
|
|
state := base64.URLEncoding.EncodeToString(b)
|
|
|
|
cookie := http.Cookie{
|
|
Name: "oauthstate",
|
|
Value: state,
|
|
Expires: time.Now().Add(10 * time.Minute),
|
|
HttpOnly: true, // Prevents JavaScript access (XSS protection)
|
|
Secure: h.cfg.SecureCookie, // true in production, false for localhost
|
|
SameSite: http.SameSiteLaxMode, // Allows same-site OAuth redirects
|
|
Path: "/", // Ensures cookie is sent to all backend paths
|
|
}
|
|
http.SetCookie(w, &cookie)
|
|
|
|
return state
|
|
}
|