- TWO_FACTOR_ALLOW_LOG_DELIVERY production opt-in REMOVED: plaintext codes are written to the stdout log ([2FA]/[VERIFY]) only in dev/test builds as a local DEV ONLY feature while email/SMS delivery (P6) is implemented. Production builds have no delivery channel and code issuance fails closed (503) under any configuration — no silent log-based code leak - verification/2FA codes hashed at rest (HMAC-SHA256 via TWO_FACTOR_PEPPER, CHAR(64)); [VERIFY] dev log relay; per-user brute-force budget; password_reset purpose clears lockout for self-service recovery; dummy-bcrypt on login no-user path kills timing oracle - sabredav weak-password list + entropy gate; .env.example ships fail-closed DAV_ADMIN_PASSWORD - delete-account re-auth (current_password + fresh 2FA code when enforced) - prod-tag suite (run-prod-tag-tests.sh) compiles and runs the production 2FA issuance gate: production ALWAYS reports no delivery channel and refuses issuance after the pepper check - startup_checks_test SNAPSHOT_ENC_KEY values built at runtime so gitleaks sees no secret-shaped literals - env-docs parity updated (flag removed, 38 vars)
203 lines
6.6 KiB
Go
203 lines
6.6 KiB
Go
package user
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"crussell/db"
|
|
"crussell/handlers/auth"
|
|
"crussell/internal/validators"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
type CreateGuestUserRequest struct {
|
|
FirstName string `json:"firstName" validate:"required,min=1,max=50"`
|
|
LastName string `json:"lastName" validate:"required,min=1,max=50"`
|
|
Email string `json:"email" validate:"required,email,max=254"`
|
|
Phone string `json:"phone" validate:"required"`
|
|
}
|
|
|
|
type CreateGuestUserResponse struct {
|
|
ID string `json:"id"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
// POST /api/user/guest
|
|
func CreateGuestUserHandler(w http.ResponseWriter, r *http.Request) {
|
|
var req CreateGuestUserRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := validators.Validate.Struct(&req); err != nil {
|
|
log.Printf("Failed to process request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Normalize input
|
|
req.FirstName = strings.TrimSpace(req.FirstName)
|
|
req.LastName = strings.TrimSpace(req.LastName)
|
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
|
req.Phone = strings.TrimSpace(req.Phone)
|
|
|
|
// Validate all 4 fields are non-empty
|
|
if req.FirstName == "" || req.LastName == "" || req.Email == "" || req.Phone == "" {
|
|
http.Error(w, "first name, last name, email and phone are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate names (unicode letters, spaces, hyphen, apostrophe, dot)
|
|
nameRegex := regexp.MustCompile(`^[\p{L}\p{M}\s\-'\.]+$`)
|
|
if !nameRegex.MatchString(req.FirstName) || !nameRegex.MatchString(req.LastName) {
|
|
http.Error(w, "invalid characters in name", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate name lengths
|
|
if len(req.FirstName) < 1 || len(req.FirstName) > 50 {
|
|
http.Error(w, "first name must be 1-50 characters", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(req.LastName) < 1 || len(req.LastName) > 50 {
|
|
http.Error(w, "last name must be 1-50 characters", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate email format
|
|
if err := validators.ValidateEmail(req.Email); err != nil {
|
|
log.Printf("Failed to process request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Normalize phone (strip non-digit/+ chars)
|
|
req.Phone = strings.Map(func(r rune) rune {
|
|
if (r >= '0' && r <= '9') || r == '+' {
|
|
return r
|
|
}
|
|
return -1
|
|
}, req.Phone)
|
|
|
|
// Validate UK phone number
|
|
phone, err := auth.ValidateUKPhoneNumber(req.Phone)
|
|
if err != nil {
|
|
http.Error(w, "invalid phone number format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
req.Phone = strings.TrimSpace(phone)
|
|
|
|
// Check if email already exists with a registered (non-guest) role
|
|
var existingRole string
|
|
err = db.Conn.QueryRow(r.Context(), `
|
|
SELECT account_role FROM users WHERE email = $1
|
|
`, req.Email).Scan(&existingRole)
|
|
|
|
if err == nil && existingRole != "guest" {
|
|
http.Error(w, "Email already registered - please log in to book", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Always create a new guest user — even if email/phone is used by another guest.
|
|
// Each booking is a disposable account; we don't track identity across guest bookings.
|
|
|
|
// Create new guest user
|
|
tx, err := db.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
var userID string
|
|
err = tx.QueryRow(r.Context(), `
|
|
INSERT INTO users
|
|
(n_first_name, n_last_name, email, phone, date_of_birth,
|
|
account_role, account_type, password_hash, privacy_policy_and_terms_consent)
|
|
VALUES ($1, $2, $3, $4, '1900-01-01', 'guest', 'email', NULL, TRUE)
|
|
RETURNING id
|
|
`, req.FirstName, req.LastName, req.Email, req.Phone).Scan(&userID)
|
|
|
|
if err != nil {
|
|
log.Printf("Failed to create guest user: %v", err)
|
|
http.Error(w, "failed to create guest user", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Note: We intentionally don't sync to CardDAV - guests don't need calendar contacts
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
if err := json.NewEncoder(w).Encode(CreateGuestUserResponse{ID: userID, Role: "guest"}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
}
|
|
|
|
// GET /api/check-email?email=...
|
|
// Returns a uniform {"available": bool} response: available=false when the
|
|
// email belongs to a registered (non-guest) account, true otherwise. The old
|
|
// response returned a "suggestion" breakdown ("login" | "check" | null) that
|
|
// told an unauthenticated caller whether an email was registered AND whether
|
|
// their first/last-name/phone matched the account — a user-enumeration and
|
|
// PII-confirmation oracle. The comment here previously claimed nginx restricts
|
|
// this endpoint to frontend-only traffic, but nginx/conf.d/default.conf has NO
|
|
// such rule (the /api/ location proxies everything with rate limiting only), so
|
|
// the handler itself must not leak the breakdown. The uniform shape keeps the
|
|
// endpoint functional for the registration form's "email already registered"
|
|
// check while removing the distinguishing detail. The guest-booking frontend
|
|
// reads data.suggestion; with the uniform shape it resolves to null and no
|
|
// suggestion banner is shown — an accepted UX trade-off (the guest-creation
|
|
// endpoint's 409 is the real enforcement for registered emails).
|
|
func CheckEmailHandler(w http.ResponseWriter, r *http.Request) {
|
|
email := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("email")))
|
|
if email == "" {
|
|
http.Error(w, "email query parameter required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := validators.ValidateEmail(email); err != nil {
|
|
log.Printf("Failed to process request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var registeredID string
|
|
err := db.Conn.QueryRow(r.Context(), `
|
|
SELECT id
|
|
FROM users
|
|
WHERE email = $1 AND account_role != 'guest'
|
|
`, email).Scan(®isteredID)
|
|
|
|
available := true
|
|
if err == nil {
|
|
available = false
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
log.Printf("Failed to check email: %v", err)
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
|
"available": available,
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
}
|