Ctrl K
ComunidadGuías…

server/internal/handlers/auth.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package handlers

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"

	"blackbox/server/internal/auth"
	"blackbox/server/internal/events"
	"blackbox/server/internal/models"
	gooidc "github.com/coreos/go-oidc/v3/oidc"
	"github.com/go-chi/chi/v5"
	"github.com/oklog/ulid/v2"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

const (
	maxCredentialBodyBytes = 8 << 10
)

var errAlreadyBootstrapped = errors.New("already bootstrapped")
var errOIDCEmailAmbiguous = errors.New("oidc email match ambiguous")
var errOIDCEmailAlreadyLinked = errors.New("oidc email already linked to a different identity")

type oidcIDClaims struct {
	Nonce             string `json:"nonce"`
	Sub               string `json:"sub"`
	Email             string `json:"email"`
	EmailVerified     bool   `json:"email_verified"`
	PreferredUsername string `json:"preferred_username"`
}

func jwtTTL() time.Duration {
	if v := os.Getenv("JWT_TTL"); v != "" {
		if d, err := time.ParseDuration(v); err == nil {
			return d
		}
	}
	return 24 * time.Hour
}

func Bootstrap(database *gorm.DB, jwtSecret string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req struct {
			Username string `json:"username"`
			Password string `json:"password"`
			Email    string `json:"email"`
		}
		if !decodeJSONBody(w, r, maxCredentialBodyBytes, &req) {
			return
		}
		if req.Username == "" || req.Password == "" {
			writeError(w, http.StatusBadRequest, "username and password required")
			return
		}

		hash, err := auth.HashPassword(req.Password)
		if err != nil {
			writeError(w, http.StatusInternalServerError, "failed to hash password")
			return
		}

		user := models.User{
			ID:           ulid.Make().String(),
			Username:     req.Username,
			Email:        req.Email,
			PasswordHash: hash,
			IsAdmin:      true,
		}
		var token string

		txErr := database.Transaction(func(tx *gorm.DB) error {
			result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.SetupState{Key: "bootstrap"})
			if result.Error != nil {
				return result.Error
			}
			if result.RowsAffected == 0 {
				return errAlreadyBootstrapped
			}

			var count int64
			if err := tx.Model(&models.User{}).Count(&count).Error; err != nil {
				return err
			}
			if count > 0 {
				return errAlreadyBootstrapped
			}

			if err := tx.Create(&user).Error; err != nil {
				return err
			}

			var issueErr error
			token, issueErr = auth.IssueJWT(user.ID, user.Username, user.Email, user.OIDCIssuer != "", user.IsAdmin, user.TokenVersion, jwtSecret, jwtTTL())
			return issueErr
		})
		if txErr == errAlreadyBootstrapped {
			writeError(w, http.StatusConflict, "already bootstrapped")
			return
		}
		if txErr != nil {
			writeError(w, http.StatusInternalServerError, "failed to create user")
			return
		}

		setSessionCookie(w, r, token, jwtTTL())

		events.LogSystem(database, "auth", "admin.bootstrap", "admin user "+req.Username+" created")

		writeSessionResponse(w, &auth.Claims{
			UserID:       user.ID,
			Username:     user.Username,
			Email:        user.Email,
			OIDCLinked:   user.OIDCIssuer != "",
			IsAdmin:      user.IsAdmin,
			TokenVersion: user.TokenVersion,
		}, http.StatusCreated)
	}
}

func Login(database *gorm.DB, jwtSecret string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req struct {
			Username string `json:"username"`
			Password string `json:"password"`
		}
		if !decodeJSONBody(w, r, maxCredentialBodyBytes, &req) {
			return
		}

		var user models.User
		if err := database.First(&user, "username = ?", req.Username).Error; err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				events.LogSystem(database, "auth", "user.login.failed", "failed login attempt for username "+req.Username)
				writeError(w, http.StatusUnauthorized, "invalid credentials")
				return
			}
			writeError(w, http.StatusServiceUnavailable, "service unavailable")
			return
		}

		if !auth.VerifyPassword(user.PasswordHash, req.Password) {
			events.LogSystem(database, "auth", "user.login.failed", "failed login attempt for username "+req.Username)
			writeError(w, http.StatusUnauthorized, "invalid credentials")
			return
		}

		token, err := auth.IssueJWT(user.ID, user.Username, user.Email, user.OIDCIssuer != "", user.IsAdmin, user.TokenVersion, jwtSecret, jwtTTL())
		if err != nil {
			writeError(w, http.StatusInternalServerError, "failed to issue token")
			return
		}

		setSessionCookie(w, r, token, jwtTTL())

		events.LogSystem(database, "auth", "user.login", "user "+req.Username+" logged in")

		writeSessionResponse(w, &auth.Claims{
			UserID:       user.ID,
			Username:     user.Username,
			Email:        user.Email,
			OIDCLinked:   user.OIDCIssuer != "",
			IsAdmin:      user.IsAdmin,
			TokenVersion: user.TokenVersion,
		}, http.StatusOK)
	}
}

func OIDCProviderLogin(database *gorm.DB, registry *auth.OIDCRegistry) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		providerID, ok := oidcProviderIDFromRequest(w, r)
		if !ok {
			return
		}
		var oidcProvider *auth.OIDCProvider
		if registry != nil && registry.IsReady() {
			oidcProvider = registry.Get(providerID)
		}
		if oidcProvider == nil {
			writeError(w, http.StatusServiceUnavailable, "OIDC provider unavailable")
			return
		}

		stateBytes := make([]byte, 32)
		nonceBytes := make([]byte, 32)
		if _, err := rand.Read(stateBytes); err != nil {
			writeError(w, http.StatusInternalServerError, "failed to generate state")
			return
		}
		if _, err := rand.Read(nonceBytes); err != nil {
			writeError(w, http.StatusInternalServerError, "failed to generate nonce")
			return
		}

		state := hex.EncodeToString(stateBytes)
		nonce := hex.EncodeToString(nonceBytes)
		oidcState := models.OIDCState{
			ID:         ulid.Make().String(),
			State:      state,
			Nonce:      nonce,
			ProviderID: providerID,
			InviteCode: r.URL.Query().Get("invite_code"),
			ExpiresAt:  time.Now().Add(10 * time.Minute),
			CreatedAt:  time.Now(),
		}
		if err := database.Create(&oidcState).Error; err != nil {
			writeError(w, http.StatusInternalServerError, "failed to store state")
			return
		}

		stateCookie := &http.Cookie{
			Name:     oidcStateCookieName(providerID),
			Value:    state,
			HttpOnly: true,
			SameSite: http.SameSiteLaxMode,
			MaxAge:   600,
			Path:     "/",
		}
		if isSecureRequest(r) {
			stateCookie.Secure = true
		}
		http.SetCookie(w, stateCookie)

		http.Redirect(w, r, oidcProvider.Config.AuthCodeURL(state, gooidc.Nonce(nonce)), http.StatusFound)
	}
}

func OIDCProviderCallback(database *gorm.DB, registry *auth.OIDCRegistry, jwtSecret string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		providerID, ok := oidcProviderIDFromRequest(w, r)
		if !ok {
			return
		}

		cookie, err := r.Cookie(oidcStateCookieName(providerID))
		if err != nil {
			events.LogSystem(database, "auth", "user.login.oidc.failed", "OIDC callback error: missing state cookie")
			writeError(w, http.StatusBadRequest, "missing state cookie")
			return
		}

		if r.URL.Query().Get("state") != cookie.Value {
			clearOIDCStateCookie(w, r, providerID)
			events.LogSystem(database, "auth", "user.login.oidc.failed", "OIDC callback error: state mismatch")
			writeError(w, http.StatusBadRequest, "state mismatch")
			return
		}

		var oidcState models.OIDCState
		if err := database.First(&oidcState, "state = ?", cookie.Value).Error; err != nil {
			clearOIDCStateCookie(w, r, providerID)
			if errors.Is(err, gorm.ErrRecordNotFound) {
				events.LogSystem(database, "auth", "user.login.oidc.failed", "OIDC callback error: invalid or expired state")
				writeError(w, http.StatusBadRequest, "invalid or expired state")
				return
			}
			writeError(w, http.StatusServiceUnavailable, "service unavailable")
			return
		}
		if oidcState.ExpiresAt.Before(time.Now()) {
			clearOIDCStateCookie(w, r, providerID)
			events.LogSystem(database, "auth", "user.login.oidc.failed", "OIDC callback error: invalid or expired state")
			writeError(w, http.StatusBadRequest, "invalid or expired state")
			return
		}

		cleanedUp := false
		cleanupState := func() {
			if cleanedUp {
				return
			}
			cleanedUp = true
			_ = database.Delete(&models.OIDCState{}, "id = ?", oidcState.ID).Error
			clearOIDCStateCookie(w, r, providerID)
		}

		if oidcState.ProviderID != providerID {
			cleanupState()
			events.LogSystem(database, "auth", "user.login.oidc.failed", "OIDC callback error: provider mismatch")
			writeError(w, http.StatusBadRequest, "invalid or expired state")
			return
		}

		var oidcProvider *auth.OIDCProvider
		if registry != nil && registry.IsReady() {
			oidcProvider = registry.Get(providerID)
		}
		if oidcProvider == nil {
			cleanupState()
			writeError(w, http.StatusServiceUnavailable, "OIDC provider unavailable")
			return
		}

		oauth2Token, err := oidcProvider.Config.Exchange(r.Context(), r.URL.Query().Get("code"))
		if err != nil {
			cleanupState()
			events.LogSystem(database, "auth", "user.login.oidc.failed", "OIDC callback error: code exchange failed")
			writeError(w, http.StatusUnauthorized, "code exchange failed")
			return
		}

		rawIDToken, ok := oauth2Token.Extra("id_token").(string)
		if !ok {
			cleanupState()
			events.LogSystem(database, "auth", "user.login.oidc.failed", "OIDC callback error: missing id_token")
			writeError(w, http.StatusUnauthorized, "missing id_token")
			return
		}

		idToken, err := oidcProvider.Verifier.Verify(r.Context(), rawIDToken)
		if err != nil {
			cleanupState()
			events.LogSystem(database, "auth", "user.login.oidc.failed", "OIDC callback error: id_token verification failed")
			writeError(w, http.StatusUnauthorized, "id_token verification failed")
			return
		}

		var idClaims oidcIDClaims
		if err := idToken.Claims(&idClaims); err != nil || idClaims.Nonce != oidcState.Nonce {
			cleanupState()
			events.LogSystem(database, "auth", "user.login.oidc.failed", "OIDC callback error: nonce mismatch")
			writeError(w, http.StatusBadRequest, "nonce mismatch")
			return
		}
		issuer := strings.TrimSpace(idToken.Issuer)
		if issuer == "" {
			cleanupState()
			events.LogSystem(database, "auth", "user.login.oidc.failed", "OIDC callback error: missing issuer")
			writeError(w, http.StatusUnauthorized, "invalid id_token issuer")
			return
		}

		var user models.User
		err = database.First(&user, "oidc_issuer = ? AND oidc_subject = ?", issuer, idClaims.Sub).Error
		switch {
		case err == nil:
		case errors.Is(err, gorm.ErrRecordNotFound):
			providerConfig, configErr := getOIDCProviderConfig(database, providerID)
			if configErr != nil {
				cleanupState()
				writeError(w, http.StatusInternalServerError, "failed to load OIDC provider")
				return
			}
			user, linkExisting, lookupErr := findOIDCUserByEmail(database, issuer, idClaims, requireVerifiedEmail(providerConfig))
			switch {
			case lookupErr == nil && linkExisting:
				if err := database.Model(&user).Updates(map[string]interface{}{
					"oidc_issuer":  issuer,
					"oidc_subject": idClaims.Sub,
				}).Error; err != nil {
					cleanupState()
					writeError(w, http.StatusInternalServerError, "failed to link account")
					return
				}
				user.OIDCIssuer = issuer
				user.OIDCSubject = idClaims.Sub
			case lookupErr == nil:
				newUser, ok := handleNewOIDCUser(w, database, issuer, idClaims, oidcState, cleanupState)
				if !ok {
					return
				}
				user = newUser
			case errors.Is(lookupErr, errOIDCEmailAmbiguous), errors.Is(lookupErr, errOIDCEmailAlreadyLinked):
				cleanupState()
				writeError(w, http.StatusConflict, lookupErr.Error())
				return
			default:
				cleanupState()
				writeError(w, http.StatusInternalServerError, "failed to lookup user")
				return
			}
		default:
			cleanupState()
			writeError(w, http.StatusInternalServerError, "failed to lookup user")
			return
		}

		token, err := auth.IssueJWT(user.ID, user.Username, user.Email, true, user.IsAdmin, user.TokenVersion, jwtSecret, jwtTTL())
		if err != nil {
			cleanupState()
			writeError(w, http.StatusInternalServerError, "failed to issue token")
			return
		}

		cleanupState()
		setSessionCookie(w, r, token, jwtTTL())

		events.LogSystem(database, "auth", "user.login.oidc", "user "+user.Username+" logged in via OIDC")

		http.Redirect(w, r, "/timeline", http.StatusFound)
	}
}

// handleNewOIDCUser applies the OIDC access policy and creates a new user if allowed.
// Returns the created user and true on success, or writes an error response and returns false.
func handleNewOIDCUser(w http.ResponseWriter, database *gorm.DB, issuer string, idClaims oidcIDClaims, oidcState models.OIDCState, cleanupState func()) (models.User, bool) {
	policy, err := getOIDCPolicy(database)
	if err != nil {
		cleanupState()
		writeError(w, http.StatusInternalServerError, "failed to load OIDC policy")
		return models.User{}, false
	}
	switch policy {
	case "existing_only":
		cleanupState()
		writeError(w, http.StatusForbidden, "no account associated with this identity")
		return models.User{}, false
	case "invite_required":
		user := models.User{
			ID:          ulid.Make().String(),
			Username:    preferredOIDCUsername(idClaims),
			Email:       idClaims.Email,
			OIDCIssuer:  issuer,
			OIDCSubject: idClaims.Sub,
			IsAdmin:     false,
			CreatedAt:   time.Now(),
		}
		txErr := database.Transaction(func(tx *gorm.DB) error {
			result := tx.Model(&models.InviteCode{}).
				Where("code = ? AND used_by = '' AND expires_at > ?", oidcState.InviteCode, time.Now()).
				Update("used_by", user.ID)
			if result.Error != nil {
				return result.Error
			}
			if result.RowsAffected == 0 {
				return errInvalidInvite
			}
			return tx.Create(&user).Error
		})
		if txErr == errInvalidInvite {
			cleanupState()
			writeError(w, http.StatusUnauthorized, "invalid or expired invite code")
			return models.User{}, false
		}
		if txErr != nil {
			cleanupState()
			writeError(w, http.StatusInternalServerError, "failed to create user")
			return models.User{}, false
		}
		return user, true
	default:
		user := models.User{
			ID:          ulid.Make().String(),
			Username:    preferredOIDCUsername(idClaims),
			Email:       idClaims.Email,
			OIDCIssuer:  issuer,
			OIDCSubject: idClaims.Sub,
			IsAdmin:     false,
			CreatedAt:   time.Now(),
		}
		if err := database.Create(&user).Error; err != nil {
			cleanupState()
			writeError(w, http.StatusInternalServerError, "failed to create user")
			return models.User{}, false
		}
		return user, true
	}
}

func findOIDCUserByEmail(database *gorm.DB, issuer string, idClaims oidcIDClaims, requireVerified bool) (models.User, bool, error) {
	if issuer == "" || strings.TrimSpace(idClaims.Email) == "" {
		return models.User{}, false, nil
	}
	if requireVerified && !idClaims.EmailVerified {
		return models.User{}, false, nil
	}

	var matches []models.User
	if err := database.Where("email = ?", idClaims.Email).Limit(2).Find(&matches).Error; err != nil {
		return models.User{}, false, err
	}
	if len(matches) == 0 {
		return models.User{}, false, nil
	}
	if len(matches) > 1 {
		return models.User{}, false, errOIDCEmailAmbiguous
	}

	user := matches[0]
	if user.OIDCIssuer == "" && user.OIDCSubject == "" {
		return user, true, nil
	}
	if user.OIDCIssuer == issuer && user.OIDCSubject == idClaims.Sub {
		return user, true, nil
	}

	return models.User{}, false, errOIDCEmailAlreadyLinked
}

func getOIDCProviderConfig(database *gorm.DB, providerID string) (models.OIDCProviderConfig, error) {
	var provider models.OIDCProviderConfig
	if err := database.First(&provider, "id = ?", providerID).Error; err != nil {
		return models.OIDCProviderConfig{}, err
	}
	return provider, nil
}

func requireVerifiedEmail(provider models.OIDCProviderConfig) bool {
	return provider.RequireVerifiedEmail == nil || *provider.RequireVerifiedEmail
}

func oidcStateCookieName(providerID string) string {
	sum := sha256.Sum256([]byte(providerID))
	return "oidc_state_" + hex.EncodeToString(sum[:8])
}

func oidcProviderIDFromRequest(w http.ResponseWriter, r *http.Request) (string, bool) {
	providerID, err := url.PathUnescape(chi.URLParam(r, "provider_id"))
	if err != nil {
		writeError(w, http.StatusBadRequest, "invalid provider id")
		return "", false
	}
	return providerID, true
}

func preferredOIDCUsername(claims oidcIDClaims) string {
	if claims.PreferredUsername != "" {
		return claims.PreferredUsername
	}
	if claims.Email != "" {
		return claims.Email
	}
	return claims.Sub
}

func clearOIDCStateCookie(w http.ResponseWriter, r *http.Request, providerID string) {
	http.SetCookie(w, &http.Cookie{
		Name:     oidcStateCookieName(providerID),
		Value:    "",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		Path:     "/",
		MaxAge:   -1,
		Expires:  time.Unix(0, 0),
		Secure:   isSecureRequest(r),
	})
}

func CurrentUser() http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		claims, ok := auth.ClaimsFromContext(r.Context())
		if !ok {
			writeError(w, http.StatusUnauthorized, "unauthorized")
			return
		}
		writeSessionResponse(w, claims, http.StatusOK)
	}
}

func Logout() http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		clearSessionCookie(w, r)
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Cache-Control", "no-store")
		_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
	}
}

func writeError(w http.ResponseWriter, status int, msg string) {
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Cache-Control", "no-store")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}