Ctrl K
ComunidadGuías…

server/internal/handlers/register.go

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

Archivo original ↗Preguntar a KiBot
package handlers

import (
	"errors"
	"net/http"
	"net/mail"
	"strings"
	"time"

	"blackbox/server/internal/auth"
	"blackbox/server/internal/events"
	"blackbox/server/internal/models"
	"github.com/oklog/ulid/v2"
	"gorm.io/gorm"
)

var errInvalidInvite = errors.New("invalid or expired invite")

func Register(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"`
			InviteCode string `json:"invite_code"`
		}
		if !decodeJSONBody(w, r, maxCredentialBodyBytes, &req) {
			return
		}
		email := strings.TrimSpace(req.Email)
		if req.Username == "" || req.Password == "" || email == "" || req.InviteCode == "" {
			writeError(w, http.StatusBadRequest, "username, password, email, and invite_code required")
			return
		}
		parsedEmail, err := mail.ParseAddress(email)
		if err != nil || parsedEmail.Address != email {
			writeError(w, http.StatusBadRequest, "valid email required")
			return
		}

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

		userID := ulid.Make().String()
		user := models.User{}
		var invite models.InviteCode
		var token string

		txErr := database.Transaction(func(tx *gorm.DB) error {
			// Load the invite first so we can record its ID in the audit log.
			if err := tx.Where("code = ? AND used_by = '' AND expires_at > ?", req.InviteCode, time.Now()).
				First(&invite).Error; err != nil {
				return errInvalidInvite
			}

			// Atomically claim the invite: conditional UPDATE checks used_by = '' and
			// expiry in one step. RowsAffected == 0 means already used, not found, or expired.
			result := tx.Model(&models.InviteCode{}).
				Where("code = ? AND used_by = '' AND expires_at > ?", req.InviteCode, time.Now()).
				Update("used_by", userID)
			if result.Error != nil {
				return result.Error
			}
			if result.RowsAffected == 0 {
				return errInvalidInvite
			}

			user = models.User{
				ID:           userID,
				Username:     req.Username,
				Email:        email,
				PasswordHash: hash,
				IsAdmin:      false,
				CreatedAt:    time.Now(),
			}
			if err := tx.Create(&user).Error; err != nil {
				return err
			}

			var issueErr error
			token, issueErr = auth.IssueJWT(userID, user.Username, user.Email, false, false, user.TokenVersion, jwtSecret, jwtTTL())
			return issueErr
		})

		if txErr == errInvalidInvite {
			events.LogSystem(database, "auth", "invite.rejected", "registration attempt with invalid invite code")
			writeError(w, http.StatusUnauthorized, "invalid or expired invite code")
			return
		}
		if txErr != nil {
			writeError(w, http.StatusConflict, "registration failed")
			return
		}

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

		events.LogSystem(database, "auth", "user.register", "user "+req.Username+" registered via invite")
		WriteAuditLog(database, r, &auth.Claims{UserID: user.ID, Email: user.Email}, "user.register", "user", user.ID, map[string]any{"invite_id": invite.ID})

		writeSessionResponse(w, &auth.Claims{
			UserID:       userID,
			Username:     req.Username,
			Email:        user.Email,
			OIDCLinked:   false,
			IsAdmin:      false,
			TokenVersion: user.TokenVersion,
		}, http.StatusCreated)
	}
}