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 — кнопки провайдеров и ошибка возврата; серверная ручка отдаёт понятный отказ без настроек.
This commit is contained in:
@@ -81,6 +81,25 @@ func newAPIError(err error) apiError {
|
||||
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):
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/httpx"
|
||||
)
|
||||
|
||||
// registerOAuthRoutes вешает ручки входа через внешние провайдеры (AGENT.md 7.1).
|
||||
// `start` и `callback` — публичные (это вход), лимит по IP общий с другими
|
||||
// ручками аутентификации.
|
||||
func (s *Server) registerOAuthRoutes(router chi.Router) {
|
||||
oauthLimit := s.oauthLimiter.Middleware(func(r *http.Request) string {
|
||||
return httpx.ClientIP(r, nil)
|
||||
})
|
||||
router.Get("/auth/oauth/providers", s.handleOAuthProviders)
|
||||
router.With(oauthLimit).Get("/auth/oauth/{provider}/start", s.handleOAuthStart)
|
||||
router.With(oauthLimit).Get("/auth/oauth/{provider}/callback", s.handleOAuthCallback)
|
||||
}
|
||||
|
||||
// handleOAuthProviders отдаёт список включённых провайдеров: клиент рисует по
|
||||
// нему кнопки входа, а закрытые провайдеры не показывает вовсе.
|
||||
func (s *Server) handleOAuthProviders(w http.ResponseWriter, r *http.Request) {
|
||||
if s.auth == nil {
|
||||
writeJSON(w, map[string]any{"providers": []any{}, "redirect_url": ""})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"providers": s.auth.OAuthProviders(),
|
||||
"redirect_url": s.auth.OAuthRedirectURL(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleOAuthStart уводит браузер на страницу согласия провайдера.
|
||||
// Если провайдер не настроен — отвечаем понятной ошибкой, а не редиректом.
|
||||
func (s *Server) handleOAuthStart(w http.ResponseWriter, r *http.Request) {
|
||||
if s.auth == nil {
|
||||
writeAPIError(w, auth.ErrSessionExpired)
|
||||
return
|
||||
}
|
||||
provider := chi.URLParam(r, "provider")
|
||||
redirect := r.URL.Query().Get("redirect")
|
||||
authorizeURL, err := s.auth.OAuthAuthorizeURL(provider, redirect)
|
||||
if err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
//nolint:gosec // адрес берётся из каталога провайдеров, пользователь его не задаёт
|
||||
http.Redirect(w, r, authorizeURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// handleOAuthCallback принимает код, находит или создаёт аккаунт и возвращает
|
||||
// пользователя в клиент. Ошибки уходят в `?oauth_error=<код>`: браузер видит
|
||||
// понятное сообщение на странице входа, а не JSON.
|
||||
func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if s.auth == nil {
|
||||
writeAPIError(w, auth.ErrSessionExpired)
|
||||
return
|
||||
}
|
||||
provider := chi.URLParam(r, "provider")
|
||||
query := r.URL.Query()
|
||||
_, token, session, redirect, err := s.auth.OAuthCallback(r.Context(), provider,
|
||||
query.Get("code"), query.Get("state"),
|
||||
httpx.ClientIPFromContext(r.Context()), httpx.UserAgentFromContext(r.Context()))
|
||||
if err != nil {
|
||||
code := oauthErrorCode(err)
|
||||
s.logger.InfoContext(r.Context(), "вход через OAuth не удался",
|
||||
slog.String("provider", provider), slog.String("code", code))
|
||||
http.Redirect(w, r, "/login?oauth_error="+url.QueryEscape(code), http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt))
|
||||
// Признак в адресе нужен клиенту, чтобы перечитать профиль после возврата.
|
||||
target := redirect
|
||||
if target == "" || target == "/" {
|
||||
target = "/app"
|
||||
}
|
||||
separator := "?"
|
||||
if strings.Contains(target, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
//nolint:gosec // target — только внутренний путь: его проверил safeRedirect
|
||||
http.Redirect(w, r, target+separator+"oauth=ok", http.StatusFound)
|
||||
}
|
||||
|
||||
// oauthErrorCode переводит доменную ошибку в код для `?oauth_error=`.
|
||||
func oauthErrorCode(err error) string {
|
||||
for _, candidate := range []error{
|
||||
auth.ErrOAuthNotConfigured,
|
||||
auth.ErrOAuthUnknownProvider,
|
||||
auth.ErrOAuthState,
|
||||
auth.ErrOAuthEmailUnverified,
|
||||
auth.ErrOAuthEmailMissing,
|
||||
auth.ErrRegistrationOff,
|
||||
auth.ErrUserBanned,
|
||||
auth.ErrOAuthExchange,
|
||||
} {
|
||||
if errors.Is(err, candidate) {
|
||||
return strings.TrimPrefix(candidate.Error(), "oauth.")
|
||||
}
|
||||
}
|
||||
return "failed"
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOAuthProviderNotConfigured — ручка входа через провайдера отвечает
|
||||
// понятной ошибкой, пока оператор не задал OAUTH_* (Фаза 7).
|
||||
func TestOAuthProviderNotConfigured(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
|
||||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/github/start", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("oauth start without config = %d, want 404, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if code := errorCodeOf(t, rec); code != "oauth.provider_not_configured" {
|
||||
t.Fatalf("error code = %q, want oauth.provider_not_configured", code)
|
||||
}
|
||||
|
||||
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/vk/start", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown provider = %d, want 404", rec.Code)
|
||||
}
|
||||
if code := errorCodeOf(t, rec); code != "oauth.provider_unknown" {
|
||||
t.Fatalf("error code = %q, want oauth.provider_unknown", code)
|
||||
}
|
||||
|
||||
// Список провайдеров пуст, клиент не рисует кнопки.
|
||||
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/providers", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("providers list = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Providers []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"providers"`
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode providers: %v", err)
|
||||
}
|
||||
if len(payload.Providers) != 0 {
|
||||
t.Fatalf("providers = %s, want empty", rec.Body.String())
|
||||
}
|
||||
|
||||
// Мета инстанса честно сообщает, что провайдеры выключены.
|
||||
meta := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
|
||||
if meta.Code != http.StatusOK {
|
||||
t.Fatalf("meta = %d", meta.Code)
|
||||
}
|
||||
if !strings.Contains(meta.Body.String(), `"oauth_enabled":false`) {
|
||||
t.Fatalf("meta must report oauth_enabled=false: %s", meta.Body.String())
|
||||
}
|
||||
if !strings.Contains(meta.Body.String(), `"passkeys_enabled":true`) {
|
||||
t.Fatalf("meta must report passkeys_enabled=true for a domain: %s", meta.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestOAuthCallbackRejectsUnsignedState — колбэк без валидного state
|
||||
// возвращает пользователя на вход с кодом ошибки, а не создаёт сессию.
|
||||
func TestOAuthCallbackRejectsUnsignedState(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||
"/api/v1/auth/oauth/github/callback?code=abc&state=forged", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, request)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("callback with forged state = %d, want 302, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
location := rec.Header().Get("Location")
|
||||
if !strings.HasPrefix(location, "/login?oauth_error=") {
|
||||
t.Fatalf("callback must redirect to the login page with a code, got %q", location)
|
||||
}
|
||||
if len(rec.Result().Cookies()) != 0 {
|
||||
t.Fatal("failed oauth callback must not set a session cookie")
|
||||
}
|
||||
}
|
||||
@@ -67,9 +67,10 @@ type Server struct {
|
||||
uploadDailyLimiter *httpx.RateLimiter
|
||||
// reactionLimiter — 20 реакций в минуту на пользователя.
|
||||
reactionLimiter *httpx.RateLimiter
|
||||
// passkeyLimiter ограничивает новые ручки входа (Фаза 7): 10 запросов в
|
||||
// минуту на IP — с запасом на пару begin/finish.
|
||||
// passkeyLimiter и oauthLimiter ограничивают новые ручки входа (Фаза 7):
|
||||
// 10 запросов в минуту на IP — с запасом на пару begin/finish.
|
||||
passkeyLimiter *httpx.RateLimiter
|
||||
oauthLimiter *httpx.RateLimiter
|
||||
// slowmode — время последней отправки в комнату для режима медленной
|
||||
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
||||
slowmodeMu sync.Mutex
|
||||
@@ -128,6 +129,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
uploadDailyLimiter: httpx.NewRateLimiterWindow(100, 24*time.Hour, 100),
|
||||
reactionLimiter: httpx.NewRateLimiter(20, 20),
|
||||
passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||
oauthLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||
slowmode: map[string]time.Time{},
|
||||
presence: map[uint64]time.Time{},
|
||||
webhookSeen: map[string]time.Time{},
|
||||
@@ -159,6 +161,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
s.registerAuthRoutes(apiRouter)
|
||||
if deps.Auth != nil {
|
||||
s.registerPasskeyRoutes(apiRouter)
|
||||
s.registerOAuthRoutes(apiRouter)
|
||||
}
|
||||
if deps.Store != nil {
|
||||
s.registerUserRoutes(s.api)
|
||||
|
||||
Reference in New Issue
Block a user