cebce0ae3b
Фаза 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 — кнопки провайдеров и ошибка возврата; серверная ручка отдаёт понятный отказ без настроек.
164 lines
8.3 KiB
Go
164 lines
8.3 KiB
Go
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
|
||
}
|