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