Files
glchat/internal/server/errors.go
T
grendervill cebce0ae3b feat(auth): вход через OAuth-провайдеры (GitHub, Google, Discord)
Фаза 7, AGENT.md 7.1: провайдеры включаются переменными окружения
OAUTH_<PROVIDER>_CLIENT_ID/SECRET, привязка внешнего аккаунта идёт по
подтверждённому провайдером email через blind index, вход забаненному на
инстансе запрещён, все входы и привязки попадают в события безопасности,
привязка — ещё и в аудит инстанса.

Внешний идентификатор (subject) хранится только индексом HMAC-SHA-256,
токены провайдеров не сохраняются вовсе; state подписывается ключом сессий
и ограничен по времени, редирект после входа — только внутренний путь.
Если провайдер не настроен, ручка отвечает oauth.provider_not_configured,
а клиент показывает человеческий текст вместо кнопки.

Клиент: кнопки входа по списку включённых провайдеров на экране входа и
раздел «Вход через внешние сервисы» в настройках безопасности; адрес
возврата для настроек приложения виден в GET /auth/oauth/providers.

Миграция 00019 добавляет таблицу oauth_accounts. Установщик и .env.example
знают про OAUTH_* и сохраняют значения при --reconfigure.

Тесты: Go — выключенный провайдер, подписанный state (подмена и чужой
провайдер), создание и повторный вход, привязка к существующему аккаунту,
неподтверждённый email, выключенная регистрация, бан инстанса, mocked
провайдер (httptest) для обмена кода; web — кнопки провайдеров и ошибка
возврата; серверная ручка отдаёт понятный отказ без настроек.
2026-09-26 15:13:24 +03:00

164 lines
8.3 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"
// OAuth-провайдеры (Фаза 7).
case errors.Is(err, auth.ErrOAuthNotConfigured):
candidate.Status, candidate.Code = http.StatusNotFound, "oauth.provider_not_configured"
candidate.Message = "oauth provider is not configured on this instance"
case errors.Is(err, auth.ErrOAuthUnknownProvider):
candidate.Status, candidate.Code = http.StatusNotFound, "oauth.provider_unknown"
candidate.Message = "unknown oauth provider"
case errors.Is(err, auth.ErrOAuthState):
candidate.Status, candidate.Code = http.StatusBadRequest, "oauth.state_invalid"
candidate.Message = "oauth state is invalid or expired"
case errors.Is(err, auth.ErrOAuthEmailUnverified):
candidate.Status, candidate.Code = http.StatusForbidden, "oauth.email_unverified"
candidate.Message = "provider did not confirm the email address"
case errors.Is(err, auth.ErrOAuthEmailMissing):
candidate.Status, candidate.Code = http.StatusForbidden, "oauth.email_missing"
candidate.Message = "provider did not return an email address"
case errors.Is(err, auth.ErrOAuthExchange):
candidate.Status, candidate.Code = http.StatusBadGateway, "oauth.exchange_failed"
candidate.Message = "oauth provider rejected the request"
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
}