Files
glchat/internal/server/passkeys.go
T

259 lines
8.3 KiB
Go
Raw Normal View History

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