Files
glchat/internal/server/errors.go
T
grendervill cd1d662572 feat(auth): вход по ключу доступа (passkeys, WebAuthn)
Фаза 7, AGENT.md 7.1: регистрация ключа в настройках безопасности с
обязательным step-up, вход по ключу без пароля (в т.ч. без ввода почты —
обнаруживаемый ключ), несколько ключей на аккаунт, отзыв и переименование,
события безопасности и аудит.

Сервер: github.com/go-webauthn/webauthn (BSD-3-Clause), RP ID и Origin
берутся из конфига домена; если домен — IP-адрес (стенд без домена),
passkeys честно выключены (auth.passkey_unsupported). Церемонии живут в
памяти процесса 5 минут и одноразовые: повторная отправка challenge
отклоняется. Миграция 00018 пересобирает неиспользуемую таблицу
webauthn_credentials под полную запись credential в JSON. Новые ручки
входа ограничены по IP (10/мин).

Клиент: тонкая обёртка над navigator.credentials без тяжёлых SDK,
раздел «Ключи доступа» в настройках безопасности и кнопка «Войти по ключу»
на экране входа; понятные сообщения для браузеров без поддержки WebAuthn
и при отмене диалога.

Тесты: Go — регистрация/вход с эмулятором аутентификатора (реальная
проверка подписи P-256), отказ при чужом challenge, одноразовость
церемонии, обязательный step-up при управлении ключами, запрет входа
забаненному, ограничение allowCredentials при входе с почтой, лимит и
валидация имени; web — 12 vitest с моком navigator.credentials;
Playwright — живой сценарий с виртуальным аутентификатором Chromium.
2026-09-26 15:12:43 +03:00

145 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"encoding/json"
"errors"
"log/slog"
"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_enrollment_required"
candidate.Message = "instance administrator must enable two-factor authentication: run `glchat totp-setup --email <admin>` on the server"
case errors.Is(err, auth.ErrUserBanned):
candidate.Status, candidate.Code = http.StatusForbidden, "user.banned"
candidate.Message = "account is banned on this instance"
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"
// Passkeys (WebAuthn), Фаза 7: клиент различает «не поддерживается»,
// «церемония истекла» и «подпись не сошлась» (AGENT.md 8.5).
case errors.Is(err, auth.ErrPasskeysDisabled):
candidate.Status, candidate.Code = http.StatusNotImplemented, "auth.passkey_unsupported"
candidate.Message = "passkeys are not available on this instance"
case errors.Is(err, auth.ErrPasskeyCeremony):
candidate.Status, candidate.Code = http.StatusBadRequest, "auth.passkey_challenge_invalid"
candidate.Message = "passkey challenge is unknown, expired or already used"
case errors.Is(err, auth.ErrPasskeyVerification):
candidate.Status, candidate.Code = http.StatusUnauthorized, "auth.passkey_verification_failed"
candidate.Message = "passkey verification failed"
case errors.Is(err, auth.ErrPasskeyUnknown):
candidate.Status, candidate.Code = http.StatusNotFound, "auth.passkey_unknown"
candidate.Message = "passkey is not registered for this account"
case errors.Is(err, auth.ErrPasskeyNameInvalid):
candidate.Status, candidate.Code = http.StatusUnprocessableEntity, "auth.passkey_name_invalid"
candidate.Message = "passkey name must be 1-64 characters"
case errors.Is(err, auth.ErrPasskeyLimit):
candidate.Status, candidate.Code = http.StatusConflict, "auth.passkey_limit"
candidate.Message = "passkey limit reached for this account"
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 {
// Неожиданную ошибку пишем в лог: клиент видит «internal error», а
// разобраться без текста ошибки невозможно (AGENT.md 9.4).
slog.Error("unexpected API error", slog.String("code", apiErr.Code), slog.Any("error", err))
apiErr.Message = "internal error"
}
httpx.WriteJSON(w, apiErr.Status, map[string]any{
"error": map[string]any{
"code": apiErr.Code,
"message": apiErr.Message,
},
})
}
// writeHumaAPIError отдаёт ошибку chi-ручки: huma-ошибки уже несут код и
// статус, доменные ошибки переводит writeAPIError (AGENT.md 8.5).
func writeHumaAPIError(w http.ResponseWriter, err error) {
var apiErr *humaAPIError
if errors.As(err, &apiErr) {
httpx.WriteJSON(w, apiErr.status, map[string]any{
"error": map[string]any{"code": apiErr.code, "message": apiErr.message},
})
return
}
writeAPIError(w, err)
}
// 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
}