Files
glchat/internal/server/passkeys.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

259 lines
8.3 KiB
Go
Raw Permalink 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 (
"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))
}