feat(api): REST на chi + huma с auth-ручками и OpenAPI 3.1
- переход на chi + huma (решение D-006): huma отдаёт типизированные ручки и генерирует документ, auth-ручки живут на chi (cookie и заголовки напрямую) - единый формат ошибок: код (auth.invalid_credentials, auth.2fa_required, perm.denied и т.д.) + человекочитаемое сообщение (AGENT.md 8.5) - cookie сессии __Host-session: HttpOnly, SameSite=Lax, Secure при TLS; альтернатива — Bearer-токен для desktop/CLI (AGENT.md 8.1) - ручки: register, login, logout, logout-all, sessions, step-up, 2fa/setup, 2fa/enable, users/@me; IP и User-Agent прокидываются из запроса в контекст - /api/v1/openapi.json: объединённый документ (схемы huma + контракт auth) - тесты: регистрация через API с cookie, ошибки входа, обязательная сессия, валидация, наличие всех путей в OpenAPI
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// apiError — доменная ошибка с кодом для клиента (AGENT.md 8.5).
|
||||
type apiError struct {
|
||||
Status int `json:"-"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e apiError) Error() string { return e.Code + ": " + e.Message }
|
||||
func (e apiError) Unwrap() error { return e.cause }
|
||||
|
||||
// newAPIError подбирает код и статус по доменной ошибке.
|
||||
func newAPIError(err error) apiError {
|
||||
candidate := apiError{Status: http.StatusInternalServerError, Code: "internal.error", Message: "internal error", cause: err}
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrInvalidCredentials):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.invalid_credentials", "invalid credentials"
|
||||
case errors.Is(err, auth.ErrTOTPRequired):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.2fa_required", "two-factor code required"
|
||||
case errors.Is(err, auth.ErrTOTPInvalid):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusBadRequest, "auth.totp_invalid", "invalid two-factor code"
|
||||
case errors.Is(err, auth.ErrInstanceAdminTOTP):
|
||||
candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_required"
|
||||
candidate.Message = "instance administrators must enable two-factor authentication"
|
||||
case errors.Is(err, auth.ErrSessionExpired):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.session_expired", "session expired"
|
||||
case errors.Is(err, auth.ErrStepUpRequired):
|
||||
candidate.Status, candidate.Code = http.StatusForbidden, "auth.step_up_required"
|
||||
candidate.Message = "step-up authentication required"
|
||||
case errors.Is(err, auth.ErrUsernameTaken):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "auth.username_taken", "username is already taken"
|
||||
case errors.Is(err, auth.ErrEmailTaken):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "auth.email_taken", "email is already registered"
|
||||
case errors.Is(err, auth.ErrRegistrationOff):
|
||||
candidate.Status, candidate.Code = http.StatusForbidden, "auth.registration_disabled"
|
||||
candidate.Message = "registration is disabled"
|
||||
case errors.Is(err, auth.ErrWeakPassword):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusUnprocessableEntity, "auth.weak_password", err.Error()
|
||||
case errors.Is(err, auth.ErrInvalidUsername):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusUnprocessableEntity, "auth.invalid_username", err.Error()
|
||||
case errors.Is(err, auth.ErrTOTPAlreadyEnabled):
|
||||
candidate.Status, candidate.Code = http.StatusConflict, "auth.2fa_already_enabled"
|
||||
candidate.Message = "two-factor authentication is already enabled"
|
||||
case errors.Is(err, auth.ErrNoTOTPSecret):
|
||||
candidate.Status, candidate.Code = http.StatusBadRequest, "auth.2fa_not_configured"
|
||||
candidate.Message = "two-factor authentication is not configured"
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusNotFound, "not_found", "resource not found"
|
||||
case errors.Is(err, store.ErrConflict):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "conflict", "resource already exists"
|
||||
case errors.Is(err, permissions.ErrDenied):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusForbidden, "perm.denied", "permission denied"
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
// writeAPIError отдаёт ошибку в едином формате с машиночитаемым кодом.
|
||||
func writeAPIError(w http.ResponseWriter, err error) {
|
||||
apiErr := newAPIError(err)
|
||||
if apiErr.Status >= http.StatusInternalServerError {
|
||||
apiErr.Message = "internal error"
|
||||
}
|
||||
httpx.WriteJSON(w, apiErr.Status, map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": apiErr.Code,
|
||||
"message": apiErr.Message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// writeJSON пишет успешный ответ (все текущие ручки возвращают 200).
|
||||
func writeJSON(w http.ResponseWriter, body any) {
|
||||
httpx.WriteJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// decodeBody читает JSON-тело с ограничением размера.
|
||||
func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
if r.Body == nil {
|
||||
writeAPIError(w, errors.New("empty request body"))
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(dst); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]any{
|
||||
"error": map[string]any{"code": "request.bad", "message": "malformed json body"},
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user