feat(api): список событий безопасности аккаунта
GET /users/@me/security-events отдаёт последние входы, неудачные попытки, смены пароля и изменения 2FA (AGENT.md 7.1: уведомления о новых входах — основа для настроек безопасности в клиенте).
This commit is contained in:
@@ -154,6 +154,27 @@ func TestProfileUpdateFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityEventsListed(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
cookie := registerAndLogin(t, srv, "events_user", "events@example.com")
|
||||
|
||||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/security-events", "", cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("security events = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
payload := decodeResponse[struct {
|
||||
Events []struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"events"`
|
||||
}](t, rec)
|
||||
if len(payload.Events) == 0 {
|
||||
t.Fatal("registration must leave a security event")
|
||||
}
|
||||
if payload.Events[0].Type != "register" {
|
||||
t.Fatalf("first event = %q, want register", payload.Events[0].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuildLifecycleAndPermissions(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
ownerCookie := registerAndLogin(t, srv, "guild_owner", "owner@example.com")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
@@ -105,6 +106,21 @@ type onboardingInput struct {
|
||||
}
|
||||
}
|
||||
|
||||
// securityEventPayload — событие безопасности без служебных метаданных.
|
||||
type securityEventPayload struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type securityEventListOutput struct {
|
||||
Body struct {
|
||||
Events []securityEventPayload `json:"events"`
|
||||
}
|
||||
}
|
||||
|
||||
type guildSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -236,6 +252,39 @@ func (s *Server) registerUserRoutes(api huma.API) {
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listSecurityEvents",
|
||||
Method: http.MethodGet,
|
||||
Path: "/users/@me/security-events",
|
||||
Summary: "Последние события безопасности аккаунта (входы, смена пароля, 2FA)",
|
||||
Tags: []string{"Users"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Limit int `query:"limit" default:"20" minimum:"1" maximum:"100"`
|
||||
},
|
||||
) (*securityEventListOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := s.store.ListSecurityEvents(ctx, user.ID, input.Limit)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &securityEventListOutput{}
|
||||
output.Body.Events = make([]securityEventPayload, 0, len(events))
|
||||
for _, event := range events {
|
||||
output.Body.Events = append(output.Body.Events, securityEventPayload{
|
||||
ID: formatSnowflake(event.ID),
|
||||
Type: event.Type,
|
||||
IP: event.IP,
|
||||
UserAgent: event.UserAgent,
|
||||
CreatedAt: event.CreatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getUser",
|
||||
Method: http.MethodGet,
|
||||
|
||||
Reference in New Issue
Block a user