server/internal/handlers/auth.go
Rama main · réplica de lectura del repositorio original.
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})
}