cd1d662572
Фаза 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.
259 lines
8.3 KiB
Go
259 lines
8.3 KiB
Go
package server
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"log/slog"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
|
||
"glchat/internal/auth"
|
||
"glchat/internal/httpx"
|
||
"glchat/internal/store"
|
||
)
|
||
|
||
// registerPasskeyRoutes вешает ручки passkeys (AGENT.md 7.1, Фаза 7).
|
||
// Вход по ключу — публичные ручки с отдельным лимитом по IP, управление
|
||
// ключами требует сессии и свежего step-up.
|
||
func (s *Server) registerPasskeyRoutes(router chi.Router) {
|
||
loginLimit := s.passkeyLimiter.Middleware(func(r *http.Request) string {
|
||
return httpx.ClientIP(r, nil)
|
||
})
|
||
router.With(loginLimit).Post("/auth/passkeys/login/begin", s.handlePasskeyLoginBegin)
|
||
router.With(loginLimit).Post("/auth/passkeys/login/finish", s.handlePasskeyLoginFinish)
|
||
router.Get("/auth/passkeys", s.handleListPasskeys)
|
||
router.Post("/auth/passkeys/register/begin", s.handlePasskeyRegisterBegin)
|
||
router.Post("/auth/passkeys/register/finish", s.handlePasskeyRegisterFinish)
|
||
router.Patch("/auth/passkeys/{passkey_id}", s.handleRenamePasskey)
|
||
router.Delete("/auth/passkeys/{passkey_id}", s.handleDeletePasskey)
|
||
}
|
||
|
||
type passkeyPayload struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
CreatedAt string `json:"created_at"`
|
||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||
SignCount int64 `json:"sign_count"`
|
||
}
|
||
|
||
// handleListPasskeys отдаёт ключи текущего пользователя.
|
||
func (s *Server) handleListPasskeys(w http.ResponseWriter, r *http.Request) {
|
||
user, _, ok := s.authenticate(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
passkeys, err := s.auth.ListPasskeys(r.Context(), user.ID)
|
||
if err != nil {
|
||
writeAPIError(w, err)
|
||
return
|
||
}
|
||
payload := make([]passkeyPayload, 0, len(passkeys))
|
||
for _, passkey := range passkeys {
|
||
item := passkeyPayload{
|
||
ID: formatSnowflake(passkey.ID),
|
||
Name: passkey.Name,
|
||
CreatedAt: passkey.CreatedAt.UTC().Format(time.RFC3339),
|
||
SignCount: int64(passkey.SignCount),
|
||
}
|
||
if passkey.LastUsedAt != nil {
|
||
item.LastUsedAt = passkey.LastUsedAt.UTC().Format(time.RFC3339)
|
||
}
|
||
payload = append(payload, item)
|
||
}
|
||
writeJSON(w, map[string]any{
|
||
"passkeys": payload,
|
||
"supported": s.auth.PasskeysEnabled(),
|
||
})
|
||
}
|
||
|
||
type passkeyRegisterFinishRequest struct {
|
||
Ceremony string `json:"ceremony"`
|
||
Name string `json:"name"`
|
||
// Credential — ответ navigator.credentials.create (WebAuthn JSON).
|
||
Credential json.RawMessage `json:"credential"`
|
||
// StepUp подтверждает личность: управление ключами — чувствительное
|
||
// действие (AGENT.md 7.1).
|
||
StepUpPassword string `json:"step_up_password"`
|
||
StepUpTOTP string `json:"step_up_totp"`
|
||
}
|
||
|
||
// handlePasskeyRegisterBegin начинает регистрацию ключа: нужен step-up.
|
||
func (s *Server) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||
user, session, ok := s.authenticate(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
var request stepUpRequest
|
||
if !decodeBody(w, r, &request) {
|
||
return
|
||
}
|
||
if err := s.auth.RequireStepUp(r.Context(), user, session, request.Password, request.TOTPCode); err != nil {
|
||
writeAPIError(w, err)
|
||
return
|
||
}
|
||
start, err := s.auth.BeginPasskeyRegistration(r.Context(), user)
|
||
if err != nil {
|
||
writeAPIError(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, map[string]any{
|
||
"options": start.Options,
|
||
"ceremony": start.Ceremony,
|
||
"expires_in_sec": int(start.ExpiresIn.Seconds()),
|
||
})
|
||
}
|
||
|
||
// handlePasskeyRegisterFinish проверяет ответ аутентификатора и сохраняет ключ.
|
||
func (s *Server) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||
user, session, ok := s.authenticate(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
var request passkeyRegisterFinishRequest
|
||
if !decodeBody(w, r, &request) {
|
||
return
|
||
}
|
||
// Step-up проверяем ещё раз: между begin и finish сессия могла «остыть».
|
||
if err := s.auth.RequireStepUp(r.Context(), user, session, request.StepUpPassword, request.StepUpTOTP); err != nil {
|
||
writeAPIError(w, err)
|
||
return
|
||
}
|
||
if len(request.Credential) == 0 {
|
||
writeAPIError(w, auth.ErrPasskeyVerification)
|
||
return
|
||
}
|
||
passkey, err := s.auth.FinishPasskeyRegistration(r.Context(), user, request.Ceremony, request.Name,
|
||
bytes.NewReader(request.Credential))
|
||
if err != nil {
|
||
writeAPIError(w, err)
|
||
return
|
||
}
|
||
if err := s.store.RecordAudit(r.Context(), store.AuditParams{
|
||
ActorID: &user.ID,
|
||
Action: "user.passkey_add",
|
||
TargetType: "user",
|
||
TargetID: &user.ID,
|
||
Changes: map[string]any{"passkey_id": formatSnowflake(passkey.ID), "name": passkey.Name},
|
||
}); err != nil {
|
||
s.logger.WarnContext(r.Context(), "не удалось записать добавление passkey в аудит", slog.Any("error", err))
|
||
}
|
||
writeJSON(w, map[string]any{"passkey": passkeyPayload{
|
||
ID: formatSnowflake(passkey.ID),
|
||
Name: passkey.Name,
|
||
CreatedAt: passkey.CreatedAt.UTC().Format(time.RFC3339),
|
||
SignCount: int64(passkey.SignCount),
|
||
}})
|
||
}
|
||
|
||
func (s *Server) handleRenamePasskey(w http.ResponseWriter, r *http.Request) {
|
||
user, _, ok := s.authenticate(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
id, err := parseID("passkey_id", chi.URLParam(r, "passkey_id"))
|
||
if err != nil {
|
||
writeHumaAPIError(w, err)
|
||
return
|
||
}
|
||
var request struct {
|
||
Name string `json:"name"`
|
||
}
|
||
if !decodeBody(w, r, &request) {
|
||
return
|
||
}
|
||
if err := s.auth.RenamePasskey(r.Context(), user.ID, id, request.Name); err != nil {
|
||
writeAPIError(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, map[string]any{"ok": true})
|
||
}
|
||
|
||
// handleDeletePasskey отзывает ключ: требуется свежий step-up (тело у DELETE
|
||
// не читаем — клиент сначала вызывает /auth/step-up, как и у других ручек).
|
||
func (s *Server) handleDeletePasskey(w http.ResponseWriter, r *http.Request) {
|
||
user, session, ok := s.authenticate(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := s.auth.RequireStepUp(r.Context(), user, session, "", ""); err != nil {
|
||
writeAPIError(w, err)
|
||
return
|
||
}
|
||
id, err := parseID("passkey_id", chi.URLParam(r, "passkey_id"))
|
||
if err != nil {
|
||
writeHumaAPIError(w, err)
|
||
return
|
||
}
|
||
if err := s.auth.DeletePasskey(r.Context(), user.ID, id); err != nil {
|
||
writeAPIError(w, err)
|
||
return
|
||
}
|
||
if err := s.store.RecordAudit(r.Context(), store.AuditParams{
|
||
ActorID: &user.ID,
|
||
Action: "user.passkey_remove",
|
||
TargetType: "user",
|
||
TargetID: &user.ID,
|
||
Changes: map[string]any{"passkey_id": formatSnowflake(id)},
|
||
}); err != nil {
|
||
s.logger.WarnContext(r.Context(), "не удалось записать удаление passkey в аудит", slog.Any("error", err))
|
||
}
|
||
writeJSON(w, map[string]any{"ok": true})
|
||
}
|
||
|
||
// handlePasskeyLoginBegin начинает вход по ключу: email необязателен.
|
||
func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||
if s.auth == nil {
|
||
writeAPIError(w, auth.ErrSessionExpired)
|
||
return
|
||
}
|
||
var request struct {
|
||
Email string `json:"email"`
|
||
}
|
||
// Пустое тело — тоже валидный запрос (вход без email).
|
||
if r.Body != nil && r.ContentLength != 0 {
|
||
if !decodeBody(w, r, &request) {
|
||
return
|
||
}
|
||
}
|
||
start, err := s.auth.BeginPasskeyLogin(r.Context(), request.Email)
|
||
if err != nil {
|
||
writeAPIError(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, map[string]any{
|
||
"options": start.Options,
|
||
"ceremony": start.Ceremony,
|
||
"expires_in_sec": int(start.ExpiresIn.Seconds()),
|
||
})
|
||
}
|
||
|
||
// handlePasskeyLoginFinish проверяет подпись и выдаёт сессию.
|
||
func (s *Server) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||
if s.auth == nil {
|
||
writeAPIError(w, auth.ErrSessionExpired)
|
||
return
|
||
}
|
||
var request struct {
|
||
Ceremony string `json:"ceremony"`
|
||
Credential json.RawMessage `json:"credential"`
|
||
}
|
||
if !decodeBody(w, r, &request) {
|
||
return
|
||
}
|
||
if len(request.Credential) == 0 {
|
||
writeAPIError(w, auth.ErrPasskeyVerification)
|
||
return
|
||
}
|
||
user, token, session, err := s.auth.FinishPasskeyLogin(r.Context(), request.Ceremony,
|
||
bytes.NewReader(request.Credential), httpx.ClientIPFromContext(r.Context()),
|
||
httpx.UserAgentFromContext(r.Context()))
|
||
if err != nil {
|
||
writeAPIError(w, err)
|
||
return
|
||
}
|
||
http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt))
|
||
writeJSON(w, s.withTOTPState(r.Context(), user))
|
||
}
|