163 lines
4.2 KiB
Go
163 lines
4.2 KiB
Go
package public
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/mattnite/forgejo-tickets/internal/models"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
type SSOClaims struct {
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
type SSOHandler struct {
|
|
deps Dependencies
|
|
}
|
|
|
|
func (h *SSOHandler) HandleSSO(c *gin.Context) {
|
|
slug := c.Param("slug")
|
|
token := c.Query("token")
|
|
|
|
if token == "" {
|
|
c.String(http.StatusBadRequest, "missing token")
|
|
return
|
|
}
|
|
|
|
// Look up repo by slug
|
|
var repo models.Repo
|
|
if err := h.deps.DB.First(&repo, "slug = ?", slug).Error; err != nil {
|
|
c.String(http.StatusNotFound, "repo not found")
|
|
return
|
|
}
|
|
|
|
if !repo.Active {
|
|
c.String(http.StatusNotFound, "repo not found")
|
|
return
|
|
}
|
|
|
|
if repo.SSOPublicKey == nil || strings.TrimSpace(*repo.SSOPublicKey) == "" {
|
|
c.String(http.StatusBadRequest, "SSO not configured for this repo")
|
|
return
|
|
}
|
|
|
|
// Parse PEM public key
|
|
pubKey, err := parseEd25519PublicKey(*repo.SSOPublicKey)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("repo", slug).Msg("failed to parse SSO public key")
|
|
c.String(http.StatusInternalServerError, "SSO configuration error")
|
|
return
|
|
}
|
|
|
|
// Parse and verify JWT
|
|
claims := &SSOClaims{}
|
|
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
|
|
// Enforce EdDSA algorithm to prevent algorithm confusion attacks
|
|
if t.Method != jwt.SigningMethodEdDSA {
|
|
return nil, jwt.ErrSignatureInvalid
|
|
}
|
|
return pubKey, nil
|
|
})
|
|
if err != nil || !parsed.Valid {
|
|
c.String(http.StatusUnauthorized, "invalid or expired token")
|
|
return
|
|
}
|
|
|
|
// Validate claims
|
|
email := strings.ToLower(strings.TrimSpace(claims.Email))
|
|
name := strings.TrimSpace(claims.Name)
|
|
|
|
if email == "" || name == "" {
|
|
c.String(http.StatusBadRequest, "missing email or name in token")
|
|
return
|
|
}
|
|
|
|
// Find or create user
|
|
var user models.User
|
|
result := h.deps.DB.Where("email = ?", email).First(&user)
|
|
if result.Error != nil {
|
|
// User doesn't exist — create
|
|
user = models.User{
|
|
Email: email,
|
|
Name: name,
|
|
EmailVerified: true,
|
|
Approved: true,
|
|
}
|
|
if err := h.deps.DB.Create(&user).Error; err != nil {
|
|
// Race condition: another request may have created the user
|
|
if err2 := h.deps.DB.Where("email = ?", email).First(&user).Error; err2 != nil {
|
|
log.Error().Err(err).Msg("SSO: failed to create user")
|
|
c.String(http.StatusInternalServerError, "failed to create user")
|
|
return
|
|
}
|
|
} else {
|
|
log.Info().Str("email", email).Str("name", name).Str("repo", slug).Msg("SSO: created new user")
|
|
}
|
|
} else {
|
|
log.Info().Str("email", email).Str("repo", slug).Msg("SSO: existing user logged in")
|
|
}
|
|
|
|
// Update existing user if needed (never re-approve a user that was disapproved by admin)
|
|
updates := map[string]interface{}{}
|
|
if user.Name != name {
|
|
updates["name"] = name
|
|
}
|
|
if !user.EmailVerified {
|
|
updates["email_verified"] = true
|
|
}
|
|
if len(updates) > 0 {
|
|
h.deps.DB.Model(&user).Updates(updates)
|
|
}
|
|
|
|
if !user.Approved {
|
|
c.String(http.StatusForbidden, "your account is not approved")
|
|
return
|
|
}
|
|
|
|
// Assign user to repo if not already
|
|
var count int64
|
|
h.deps.DB.Model(&models.UserRepo{}).Where("user_id = ? AND repo_id = ?", user.ID, repo.ID).Count(&count)
|
|
if count == 0 {
|
|
userRepo := models.UserRepo{UserID: user.ID, RepoID: repo.ID}
|
|
if err := h.deps.DB.Create(&userRepo).Error; err != nil {
|
|
log.Warn().Err(err).Msg("SSO: failed to create user-repo association (may already exist)")
|
|
}
|
|
}
|
|
|
|
// Create session
|
|
if err := h.deps.Auth.CreateSession(c.Request, c.Writer, user.ID); err != nil {
|
|
log.Error().Err(err).Msg("SSO: failed to create session")
|
|
c.String(http.StatusInternalServerError, "failed to create session")
|
|
return
|
|
}
|
|
|
|
c.Redirect(http.StatusSeeOther, "/tickets/new")
|
|
}
|
|
|
|
func parseEd25519PublicKey(pemStr string) (ed25519.PublicKey, error) {
|
|
block, _ := pem.Decode([]byte(pemStr))
|
|
if block == nil {
|
|
return nil, jwt.ErrSignatureInvalid
|
|
}
|
|
|
|
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
edKey, ok := pub.(ed25519.PublicKey)
|
|
if !ok {
|
|
return nil, jwt.ErrSignatureInvalid
|
|
}
|
|
|
|
return edKey, nil
|
|
}
|