v0.15.0 password recovery, phone layout, CLI pager, embed policy fixes
test / test (push) Canceled after 0s
test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-19 18:15:31 -04:00
1 parent a999177f0c
commit e1fa5242e3
48 files changed
+2101 -135

No files matched your search

+112
View File
@@ -35,6 +35,17 @@ type DeleteAccountRequest struct {
Password string `json:"password" validate:"required"`
}
// RecoverRequest asks for a password-reset email.
type RecoverRequest struct {
Identifier string `json:"identifier" validate:"required"` // username or email
}
// ResetPasswordRequest sets a new password with the token from the email.
type ResetPasswordRequest struct {
Token string `json:"token" validate:"required"`
Password string `json:"password" validate:"required"`
}
// AuthResponse contains JWT token and user information
type AuthResponse struct {
Token string `json:"token"`
@@ -322,3 +333,104 @@ func (h *HTTPHandler) DeleteAccountHandler(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
// RecoverHandler emails a password-reset link. The answer is the same whether
// or not the account exists or has an email, so nothing is revealed; only a
// missing transport (503) or a delivery failure (502) is reported.
func (h *HTTPHandler) RecoverHandler(c *fiber.Ctx) error {
var req RecoverRequest
if err := c.BodyParser(&req); err != nil || strings.TrimSpace(req.Identifier) == "" {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid request body",
Code: core.ErrInvalidRequest,
Details: "identifier (username or email) is required",
})
}
if len(req.Identifier) > 254 {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid identifier", Code: core.ErrInvalidRequest,
})
}
if err := h.svc.RequestPasswordReset(req.Identifier); err != nil {
switch {
case errors.Is(err, service.ErrMailDisabled):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "password recovery is not available on this server", Code: core.ErrNotConfigured,
})
case errors.Is(err, service.ErrMailFailed):
return c.Status(fiber.StatusBadGateway).JSON(core.ErrorResponse{
Error: "the reset email could not be sent; try again later", Code: core.ErrInternalError,
})
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "account storage unavailable", Code: core.ErrStorageUnavailable,
})
default:
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to start password recovery", Code: core.ErrInternalError,
})
}
}
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{
"message": "If the account has an email address, a reset link has been sent to it.",
})
}
// ResetPasswordHandler sets a new password from a reset link and signs the
// user in, returning a session token like login.
func (h *HTTPHandler) ResetPasswordHandler(c *fiber.Ctx) error {
var req ResetPasswordRequest
if err := c.BodyParser(&req); err != nil || req.Token == "" || req.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid request body",
Code: core.ErrInvalidRequest,
Details: "token and password are required",
})
}
if err := validatePassword(req.Password); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "weak password",
Code: core.ErrInvalidRequest,
Details: err.Error(),
})
}
user, sessionID, err := h.svc.ResetPassword(req.Token, req.Password)
if err != nil {
switch {
case errors.Is(err, service.ErrResetInvalid):
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid or expired reset link",
Code: core.ErrInvalidRequest,
Details: "request a new link from the sign-in dialog",
})
case errors.Is(err, service.ErrAuthBusy):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication temporarily unavailable", Code: core.ErrResourceLimit,
})
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "account storage unavailable", Code: core.ErrStorageUnavailable,
})
default:
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to reset password", Code: core.ErrInternalError,
})
}
}
token, err := h.svc.GenerateUserToken(user.UserID, sessionID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to generate token", Code: core.ErrInternalError,
})
}
return c.JSON(AuthResponse{
Token: token,
UserID: user.UserID,
Username: user.Username,
Email: user.Email,
ExpiresAt: time.Now().Add(service.SessionTTL),
})
}
+28
View File
@@ -140,6 +140,33 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
},
}), h.LoginHandler)
// Password recovery: 3 requests a minute per IP, each of which may send
// an email; the reset itself is limited like login.
auth.Post("/recover", limiter.New(limiter.Config{
Max: 3,
Expiration: 1 * time.Minute,
KeyGenerator: clientIP,
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
Error: "rate limit exceeded",
Code: core.ErrRateLimitExceeded,
Details: "3 recovery requests per minute allowed",
})
},
}), h.RecoverHandler)
auth.Post("/reset", limiter.New(limiter.Config{
Max: 10,
Expiration: 1 * time.Minute,
KeyGenerator: clientIP,
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
Error: "rate limit exceeded",
Code: core.ErrRateLimitExceeded,
Details: "10 password resets per minute allowed",
})
},
}), h.ResetPasswordHandler)
// Create token validator closure
validateToken := svc.ValidateToken
@@ -263,6 +290,7 @@ func (h *HTTPHandler) Health(c *fiber.Ctx) error {
"status": status,
"time": time.Now().Unix(),
"storage": storageHealth,
"mail": h.svc.MailHealth(),
"version": h.version,
})
}
+2
View File
@@ -89,6 +89,8 @@ func TestAPIRoutes(t *testing.T) {
"GET /api/auth/me",
"POST /api/auth/logout",
"DELETE /api/auth/me",
"POST /api/auth/recover",
"POST /api/auth/reset",
"POST /api/games",
"GET /api/games/:gameId",
"GET /api/games/:gameId/history",