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)) }