server/internal/handlers/oidc_admin.go
Rama main · réplica de lectura del repositorio original.
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"regexp"
"strings"
"time"
"blackbox/server/internal/auth"
"blackbox/server/internal/models"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type oidcProviderResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RedirectURL string `json:"redirect_url"`
RequireVerifiedEmail bool `json:"require_verified_email"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type publicOIDCProviderResponse struct {
ID string `json:"id"`
Name string `json:"name"`
}
var oidcProviderIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
func getOIDCPolicy(db *gorm.DB) (string, error) {
var setting models.AppSetting
if err := db.First(&setting, "key = ?", "oidc_policy").Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "open", nil
}
return "", err
}
switch setting.Value {
case "open", "existing_only", "invite_required":
return setting.Value, nil
default:
return "", errors.New("invalid OIDC policy value")
}
}
func ListOIDCProviders(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok || !claims.IsAdmin {
writeError(w, http.StatusForbidden, "admin required")
return
}
var providers []models.OIDCProviderConfig
if err := db.Order("created_at ASC").Find(&providers).Error; err != nil {
writeError(w, http.StatusInternalServerError, "failed to list OIDC providers")
return
}
resp := make([]oidcProviderResponse, len(providers))
for i, provider := range providers {
resp[i] = toOIDCProviderResponse(provider)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
}
func CreateOIDCProvider(db *gorm.DB, registry *auth.OIDCRegistry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok || !claims.IsAdmin {
writeError(w, http.StatusForbidden, "admin required")
return
}
var req struct {
ID string `json:"id"`
Name string `json:"name"`
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RedirectURL string `json:"redirect_url"`
RequireVerifiedEmail *bool `json:"require_verified_email"`
Enabled *bool `json:"enabled"`
}
if !decodeJSONBody(w, r, 8<<10, &req) {
return
}
req.ID = strings.TrimSpace(req.ID)
req.Name = strings.TrimSpace(req.Name)
req.Issuer = strings.TrimSpace(req.Issuer)
req.ClientID = strings.TrimSpace(req.ClientID)
req.ClientSecret = strings.TrimSpace(req.ClientSecret)
req.RedirectURL = strings.TrimSpace(req.RedirectURL)
if req.Name == "" || req.Issuer == "" || req.ClientID == "" || req.ClientSecret == "" || req.RedirectURL == "" {
writeError(w, http.StatusBadRequest, "name, issuer, client_id, client_secret, and redirect_url required")
return
}
if req.ClientSecret == "***" {
writeError(w, http.StatusBadRequest, "client_secret placeholder not allowed")
return
}
if req.ID == "" {
req.ID = ulid.Make().String()
}
if !oidcProviderIDPattern.MatchString(req.ID) {
writeError(w, http.StatusBadRequest, "invalid provider id")
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
requireVerified := true
if req.RequireVerifiedEmail != nil {
requireVerified = *req.RequireVerifiedEmail
}
provider := models.OIDCProviderConfig{
ID: req.ID,
Name: req.Name,
Issuer: req.Issuer,
ClientID: req.ClientID,
ClientSecret: req.ClientSecret,
RedirectURL: req.RedirectURL,
RequireVerifiedEmail: models.BoolPtr(requireVerified),
Enabled: models.BoolPtr(enabled),
}
if err := db.Create(&provider).Error; err != nil {
writeError(w, http.StatusInternalServerError, "failed to create OIDC provider")
return
}
if err := reloadOIDCRegistry(r.Context(), registry); err != nil {
writeError(w, http.StatusInternalServerError, "OIDC provider saved but registry reload failed")
return
}
WriteAuditLog(db, r, claims, "oidc.create", "oidc_provider", provider.ID, map[string]interface{}{
"name": provider.Name,
"issuer": provider.Issuer,
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(toOIDCProviderResponse(provider))
}
}
func UpdateOIDCProvider(db *gorm.DB, registry *auth.OIDCRegistry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok || !claims.IsAdmin {
writeError(w, http.StatusForbidden, "admin required")
return
}
providerID := chi.URLParam(r, "id")
var provider models.OIDCProviderConfig
if err := db.First(&provider, "id = ?", providerID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
writeError(w, http.StatusNotFound, "OIDC provider not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to fetch OIDC provider")
return
}
var req struct {
ID *string `json:"id"`
Name *string `json:"name"`
Issuer *string `json:"issuer"`
ClientID *string `json:"client_id"`
ClientSecret *string `json:"client_secret"`
RedirectURL *string `json:"redirect_url"`
RequireVerifiedEmail *bool `json:"require_verified_email"`
Enabled *bool `json:"enabled"`
}
if !decodeJSONBody(w, r, 8<<10, &req) {
return
}
if req.ID != nil {
trimmed := strings.TrimSpace(*req.ID)
if trimmed == "" {
writeError(w, http.StatusBadRequest, "id cannot be empty")
return
}
if !oidcProviderIDPattern.MatchString(trimmed) {
writeError(w, http.StatusBadRequest, "invalid provider id")
return
}
req.ID = &trimmed
}
if req.Name != nil {
trimmed := strings.TrimSpace(*req.Name)
req.Name = &trimmed
}
if req.Name != nil && *req.Name == "" {
writeError(w, http.StatusBadRequest, "name cannot be empty")
return
}
if req.Issuer != nil {
trimmed := strings.TrimSpace(*req.Issuer)
req.Issuer = &trimmed
}
if req.Issuer != nil && *req.Issuer == "" {
writeError(w, http.StatusBadRequest, "issuer cannot be empty")
return
}
if req.ClientID != nil {
trimmed := strings.TrimSpace(*req.ClientID)
req.ClientID = &trimmed
}
if req.ClientID != nil && *req.ClientID == "" {
writeError(w, http.StatusBadRequest, "client_id cannot be empty")
return
}
if req.ClientSecret != nil {
trimmed := strings.TrimSpace(*req.ClientSecret)
req.ClientSecret = &trimmed
if *req.ClientSecret == "" {
writeError(w, http.StatusBadRequest, "client_secret cannot be empty")
return
}
if *req.ClientSecret == "***" {
writeError(w, http.StatusBadRequest, "client_secret placeholder not allowed")
return
}
}
if req.RedirectURL != nil {
trimmed := strings.TrimSpace(*req.RedirectURL)
req.RedirectURL = &trimmed
}
if req.RedirectURL != nil && *req.RedirectURL == "" {
writeError(w, http.StatusBadRequest, "redirect_url cannot be empty")
return
}
updates := map[string]interface{}{}
if req.ID != nil {
if *req.ID != provider.ID && (provider.Enabled == nil || *provider.Enabled) {
writeError(w, http.StatusConflict, "disable provider before changing id")
return
}
updates["id"] = *req.ID
}
if req.Name != nil {
updates["name"] = *req.Name
}
if req.Issuer != nil {
updates["issuer"] = *req.Issuer
}
if req.ClientID != nil {
updates["client_id"] = *req.ClientID
}
if req.ClientSecret != nil {
updates["client_secret"] = *req.ClientSecret
}
if req.RedirectURL != nil {
updates["redirect_url"] = *req.RedirectURL
}
if req.RequireVerifiedEmail != nil {
updates["require_verified_email"] = *req.RequireVerifiedEmail
}
if req.Enabled != nil {
updates["enabled"] = *req.Enabled
}
updatedProviderID := providerID
if req.ID != nil {
updatedProviderID = *req.ID
}
if len(updates) > 0 {
if err := db.Model(&provider).Updates(updates).Error; err != nil {
writeError(w, http.StatusInternalServerError, "failed to update OIDC provider")
return
}
if err := db.First(&provider, "id = ?", updatedProviderID).Error; err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch updated OIDC provider")
return
}
}
if err := reloadOIDCRegistry(r.Context(), registry); err != nil {
writeError(w, http.StatusInternalServerError, "OIDC provider saved but registry reload failed")
return
}
WriteAuditLog(db, r, claims, "oidc.update", "oidc_provider", updatedProviderID, map[string]interface{}{
"name": provider.Name,
})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(toOIDCProviderResponse(provider))
}
}
func DeleteOIDCProvider(db *gorm.DB, registry *auth.OIDCRegistry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok || !claims.IsAdmin {
writeError(w, http.StatusForbidden, "admin required")
return
}
providerID := chi.URLParam(r, "id")
result := db.Delete(&models.OIDCProviderConfig{}, "id = ?", providerID)
if result.Error != nil {
writeError(w, http.StatusInternalServerError, "failed to delete OIDC provider")
return
}
if result.RowsAffected == 0 {
writeError(w, http.StatusNotFound, "OIDC provider not found")
return
}
if err := reloadOIDCRegistry(r.Context(), registry); err != nil {
writeError(w, http.StatusInternalServerError, "OIDC provider deleted but registry reload failed")
return
}
WriteAuditLog(db, r, claims, "oidc.delete", "oidc_provider", providerID, map[string]interface{}{})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}
}
func GetOIDCPolicy(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok || !claims.IsAdmin {
writeError(w, http.StatusForbidden, "admin required")
return
}
policy, err := getOIDCPolicy(db)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load OIDC policy")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"policy": policy})
}
}
func SetOIDCPolicy(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok || !claims.IsAdmin {
writeError(w, http.StatusForbidden, "admin required")
return
}
var req struct {
Policy string `json:"policy"`
}
if !decodeJSONBody(w, r, 8<<10, &req) {
return
}
switch req.Policy {
case "open", "existing_only", "invite_required":
default:
writeError(w, http.StatusBadRequest, "invalid OIDC policy")
return
}
setting := models.AppSetting{
Key: "oidc_policy",
Value: req.Policy,
UpdatedAt: time.Now(),
}
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"value": req.Policy,
"updated_at": time.Now(),
}),
}).Create(&setting).Error; err != nil {
writeError(w, http.StatusInternalServerError, "failed to update OIDC policy")
return
}
WriteAuditLog(db, r, claims, "oidc.policy_update", "oidc_policy", "oidc_policy", map[string]interface{}{
"policy": req.Policy,
})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"policy": req.Policy})
}
}
func ListPublicOIDCProviders(db *gorm.DB, registry *auth.OIDCRegistry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var providers []models.OIDCProviderConfig
if err := db.Where("enabled = ?", true).Order("name ASC").Find(&providers).Error; err != nil {
writeError(w, http.StatusInternalServerError, "failed to list OIDC providers")
return
}
if len(providers) == 0 {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string][]publicOIDCProviderResponse{"providers": []publicOIDCProviderResponse{}})
return
}
if registry == nil || !registry.IsReady() {
writeError(w, http.StatusServiceUnavailable, "OIDC providers unavailable")
return
}
resp := make([]publicOIDCProviderResponse, 0, len(providers))
for _, provider := range providers {
if registry.Get(provider.ID) == nil {
continue
}
resp = append(resp, publicOIDCProviderResponse{
ID: provider.ID,
Name: provider.Name,
})
}
if len(resp) == 0 {
writeError(w, http.StatusServiceUnavailable, "OIDC providers unavailable")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string][]publicOIDCProviderResponse{"providers": resp})
}
}
func toOIDCProviderResponse(provider models.OIDCProviderConfig) oidcProviderResponse {
return oidcProviderResponse{
ID: provider.ID,
Name: provider.Name,
Issuer: provider.Issuer,
ClientID: provider.ClientID,
ClientSecret: "***",
RedirectURL: provider.RedirectURL,
RequireVerifiedEmail: provider.RequireVerifiedEmail == nil || *provider.RequireVerifiedEmail,
Enabled: provider.Enabled != nil && *provider.Enabled,
CreatedAt: provider.CreatedAt,
UpdatedAt: provider.UpdatedAt,
}
}
func reloadOIDCRegistry(parent context.Context, registry *auth.OIDCRegistry) error {
if registry == nil {
return nil
}
ctx, cancel := context.WithTimeout(parent, 15*time.Second)
defer cancel()
return registry.Reload(ctx)
}