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