feat(api): op-login 游戏内审批先展示目标账号、邮箱与发起来源,须输入账户名确认,velocity 显示审批卡片

This commit is contained in:
Lemon-miaow committed 2026-09-25 17:02:43 +08:00
1 parent 4757353324
commit d93c1b6913
21 files changed
+1088 -176

No files matched your search

+1
View File
@@ -434,6 +434,7 @@ func (a *API) internalAPIRoutes() []apiRoute {
// approve action (service-token auth, no Principal); the public face carries
// the start/status/finish the staff member's browser drives.
{Method: "GET", Pattern: "/api/v1/internal/op-login/pending", Callers: proxy, h: a.handleOpLoginPending},
{Method: "GET", Pattern: "/api/v1/internal/op-login/{id}", Callers: proxy, h: a.handleOpLoginShow},
{Method: "POST", Pattern: "/api/v1/internal/op-login/{id}/approve", Callers: proxy, h: a.handleOpLoginApprove},
// Break-glass backup (spec §B4 "Sync"): the on-node console POSTs here to
+16 -13
View File
@@ -263,6 +263,8 @@ type fakeOpLogin struct {
consumed bool
expiresAt time.Time
createdAt time.Time
clientIP string
userAgent string
}
// fakeSetupToken mirrors a setup_tokens row (spec §B setup): a one-time
@@ -1606,25 +1608,27 @@ func (f *fakeRepo) ConsumeLoginEmailOTP(_ context.Context, userID, purpose, code
// CreateOpLoginRequest records a fresh pending op.console login attempt. status is
// born 'pending'; createdAt orders the pending list (the PG ORDER BY created_at).
func (f *fakeRepo) CreateOpLoginRequest(_ context.Context, id, userID, email string, expiresAt time.Time) error {
f.opLogins[id] = &fakeOpLogin{
id: id, userID: userID, email: email, status: "pending",
expiresAt: expiresAt, createdAt: expiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation
func (f *fakeRepo) CreateOpLoginRequest(_ context.Context, req NewOpLoginRequest) error {
f.opLogins[req.ID] = &fakeOpLogin{
id: req.ID, userID: req.UserID, email: req.Email, status: "pending",
expiresAt: req.ExpiresAt, createdAt: req.ExpiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation
clientIP: req.ClientIP, userAgent: req.UserAgent,
}
return nil
}
// OpLoginRequestByID loads a request by handle, projecting the fake row into the
// OpLoginRequest the status/finish paths read (Status, Consumed, ExpiresAt). Status
// is the (approved_at, denied_at) projection the handler gates on.
// OpLoginRequest the handlers read, with the username joined like the PG query.
// Status is the (approved_at, denied_at) projection the handler gates on.
func (f *fakeRepo) OpLoginRequestByID(_ context.Context, id string) (*OpLoginRequest, error) {
r, ok := f.opLogins[id]
if !ok {
return nil, ErrNotFound
}
return &OpLoginRequest{
ID: r.id, UserID: r.userID, Email: r.email, ExpiresAt: r.expiresAt,
Status: r.status, Consumed: r.consumed,
ID: r.id, UserID: r.userID, Username: f.usernameFor(r.userID), Email: r.email,
ExpiresAt: r.expiresAt, CreatedAt: r.createdAt, Status: r.status, Consumed: r.consumed,
ClientIP: r.clientIP, UserAgent: r.userAgent,
}, nil
}
@@ -1644,8 +1648,7 @@ func (f *fakeRepo) ConsumeOpLoginRequest(_ context.Context, id string, now time.
// ListPendingOpLogins returns the live (pending, unconsumed, unexpired) requests
// oldest-first, mirroring the PG WHERE consumed_at IS NULL AND approved_at IS NULL
// AND expires_at > now ORDER BY created_at. Username is joined from the staff map
// (the in-game admin needs to name who is waiting), exactly as the repo.go contract
// documents — ListPendingOpLogins is the ONLY path that populates Username.
// (the in-game admin needs to name who is waiting).
func (f *fakeRepo) ListPendingOpLogins(_ context.Context, now time.Time) ([]OpLoginRequest, error) {
var out []OpLoginRequest
for _, r := range f.opLogins {
@@ -1655,6 +1658,7 @@ func (f *fakeRepo) ListPendingOpLogins(_ context.Context, now time.Time) ([]OpLo
out = append(out, OpLoginRequest{
ID: r.id, UserID: r.userID, Username: f.usernameFor(r.userID),
Email: r.email, ExpiresAt: r.expiresAt, Status: "pending", CreatedAt: r.createdAt,
ClientIP: r.clientIP, UserAgent: r.userAgent,
})
}
sort.Slice(out, func(i, j int) bool {
@@ -1690,9 +1694,8 @@ func (f *fakeRepo) ConsumeSetupToken(_ context.Context, tokenHash string, now ti
return tok.UserID, nil
}
// usernameFor joins a userID to its staff username (the ListPendingOpLogins
// projection the in-game admin needs to name who is waiting). "" when the user is
// gone — mirroring a missing JOIN row.
// usernameFor joins a userID to its staff username (the op-login projection the
// in-game admin needs to name who is waiting). "" when the user is gone.
func (f *fakeRepo) usernameFor(userID string) string {
for _, u := range f.staff {
if u.ID == userID {
+127 -36
View File
@@ -11,14 +11,15 @@ import (
// sensitive tier. Unlike the console.<root_domain> player doors (email OTP / bind
// code), a staff web session is never minted from a single factor. The flow is a
// three-call state machine over op_login_requests (migration 0016), all Public
// pre-session routes (the caller has no principal yet), plus two internal-face routes
// for the in-game side (approve is driven by velocity's /felis command; pending has
// no consumer yet — see handleOpLoginPending):
// pre-session routes (the caller has no principal yet), plus internal-face routes
// for the in-game side (show and approve are driven by velocity's /felis command;
// pending has no consumer yet — see handleOpLoginPending):
//
// POST /api/v1/auth/op-login/start (public) — mint a request + mail an OTP
// GET /api/v1/auth/op-login/status/{id} (public) — poll until an admin approves
// POST /api/v1/auth/op-login/finish (public) — redeem code+approval → session
// GET /api/v1/internal/op-login/pending (internal) — list requests awaiting a vouch
// GET /api/v1/internal/op-login/{id} (internal) — who a request is for, shown to the admin
// POST /api/v1/internal/op-login/{id}/approve (internal) — an in-game admin vouches
//
// The two factors:
@@ -30,6 +31,9 @@ import (
// request via velocity's /felis command (internal approve). The API's own user
// table is the sole authority: only a UUID linked to a staff account may
// approve (velocity's command runs for any player and relies on this check).
// The admin first sees whose request it is (account, address, where it was
// started) and approves by typing that account's name, so a code relayed by a
// stranger ("please approve abc123") cannot be vouched for blind.
//
// finish mints the session only when BOTH have landed. Neither factor alone — a mailed
// code without an approval, or an approval without the code — yields a session.
@@ -173,7 +177,14 @@ func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
if err := a.Repo.CreateOpLoginRequest(r.Context(), id, u.ID, u.Email, expiresAt); err != nil {
origin := ""
if addr := a.clientIP(r); addr.IsValid() {
origin = addr.String()
}
if err := a.Repo.CreateOpLoginRequest(r.Context(), NewOpLoginRequest{
ID: id, UserID: u.ID, Email: u.Email, ExpiresAt: expiresAt,
ClientIP: origin, UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent),
}); err != nil {
writeError(w, r, err)
return
}
@@ -375,26 +386,106 @@ func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
"request_id": req.ID,
"username": req.Username,
"email": req.Email,
"client_ip": req.ClientIP,
"created_at": req.CreatedAt.UTC(),
})
}
writeJSON(w, http.StatusOK, map[string]any{"pending": out})
}
// opLoginApprover resolves the in-game player running /felis web op approve to a
// linked staff account (admin, or the owner superset). An unlinked UUID or a
// non-staff player may never see or vouch for an op.console login; all refusals
// share one 403 so a caller cannot tell "not linked" from "linked but not staff".
func (a *API) opLoginApprover(r *http.Request, mcUUID string) (*StaffUser, error) {
notAdmin := newError(http.StatusForbidden, "not_admin", "only a linked administrator may approve an operator login")
approverID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID)
switch {
case errors.Is(err, ErrNotFound):
return nil, notAdmin
case err != nil:
return nil, err
}
approver, err := a.Repo.UserByID(r.Context(), approverID)
switch {
case errors.Is(err, ErrNotFound):
return nil, notAdmin
case err != nil:
return nil, err
}
if !staffRole(approver.Role) {
return nil, notAdmin
}
return approver, nil
}
// pendingOpLogin loads a request an admin may still vouch for: pending, unconsumed
// and unexpired. Anything else is the same 404 the approve race returns.
func (a *API) pendingOpLogin(r *http.Request, id string) (*OpLoginRequest, error) {
notFound := newError(http.StatusNotFound, "op_login_not_found", "no pending operator login with that id")
req, err := a.Repo.OpLoginRequestByID(r.Context(), id)
switch {
case errors.Is(err, ErrNotFound):
return nil, notFound
case err != nil:
return nil, err
}
if req.Status != "pending" || req.Consumed || !req.ExpiresAt.After(a.now()) {
return nil, notFound
}
return req, nil
}
// handleOpLoginShow tells the in-game admin who a pending request is for before
// they vouch (internal face): the account, its address, when and from where the
// sign-in was started. velocity's /felis web op approve <code> renders this and
// asks the admin to confirm by name. The approver UUID rides in the query and gets
// the same staff check as approve, since velocity's command runs for any player and
// a staff address must not be readable by one.
func (a *API) handleOpLoginShow(w http.ResponseWriter, r *http.Request) {
approverUUID := strings.TrimSpace(r.URL.Query().Get("approver_uuid"))
if approverUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid is required"))
return
}
if _, err := a.opLoginApprover(r, approverUUID); err != nil {
writeError(w, r, err)
return
}
req, err := a.pendingOpLogin(r, r.PathValue("id"))
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"request_id": req.ID,
"username": req.Username,
"email": req.Email,
"client_ip": req.ClientIP,
"user_agent": req.UserAgent,
"created_at": req.CreatedAt.UTC(),
"expires_at": req.ExpiresAt.UTC(),
})
}
// opLoginApproveRequest is the internal approve body: the online-mode UUID of the
// in-game admin running /felis web op approve. The API resolves it to a linked account
// and refuses unless that account is staff (admin or owner) — this check against the API's
// authoritative user table is the only gate; velocity's command itself is unprivileged.
// in-game admin running /felis web op approve, and the account name they typed to
// confirm whose sign-in they are vouching for. The API resolves the UUID to a
// linked account and refuses unless that account is staff (admin or owner) — this
// check against the API's authoritative user table is the only gate; velocity's
// command itself is unprivileged.
type opLoginApproveRequest struct {
ApproverUUID string `json:"approver_uuid"`
Username string `json:"username"`
}
// handleOpLoginApprove records an in-game admin's vouch for a pending staff login
// (internal face), supplying the second factor. It resolves the approver UUID to a
// linked staff account (admin or owner; else 403), then flips the request approved.
// A missing or no-longer-pending request is 404. Self-approval is allowed: a staff
// member online as their own admin identity supplies a genuine second factor
// (in-game session control) distinct from the mailbox factor.
// linked staff account (admin or owner; else 403), requires the typed username to
// name the request's account (else 409, request left pending), then flips the
// request approved. A missing or no-longer-pending request is 404. Self-approval is
// allowed: a staff member online as their own admin identity supplies a genuine
// second factor (in-game session control) distinct from the mailbox factor.
func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req opLoginApproveRequest
@@ -403,38 +494,33 @@ func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) {
return
}
approverUUID := strings.TrimSpace(req.ApproverUUID)
if approverUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid is required"))
typed := strings.TrimSpace(req.Username)
if approverUUID == "" || typed == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid and username are required"))
return
}
// Resolve the in-game approver to a linked account and require a staff role
// (admin, or the owner superset). An unlinked UUID or a non-staff player may
// never vouch for an op.console login. All three refusals share one response so
// a caller cannot tell "not linked" from "linked but not staff".
notAdmin := newError(http.StatusForbidden, "not_admin", "only a linked administrator may approve an operator login")
approverID, err := a.Repo.UserByMCUUID(r.Context(), approverUUID)
switch {
case errors.Is(err, ErrNotFound):
writeError(w, r, notAdmin)
return
case err != nil:
approver, err := a.opLoginApprover(r, approverUUID)
if err != nil {
writeError(w, r, err)
return
}
approver, err := a.Repo.UserByID(r.Context(), approverID)
switch {
case errors.Is(err, ErrNotFound):
writeError(w, r, notAdmin)
return
case err != nil:
loginReq, err := a.pendingOpLogin(r, id)
if err != nil {
writeError(w, r, err)
return
}
if !staffRole(approver.Role) {
writeError(w, r, notAdmin)
// Minecraft names are case-insensitive, and so is the name an admin retypes.
if !strings.EqualFold(typed, loginReq.Username) {
payload, _ := json.Marshal(map[string]string{"request_id": id, "typed_username": typed})
a.auditEntry(r, AuditEntry{
Actor: approver.Username, ActorUserID: approver.ID, Source: internalSource(r),
Action: "auth.op_login.approve_mismatch", Payload: payload,
})
writeError(w, r, newError(http.StatusConflict, "op_login_mismatch",
"that operator login is for a different account"))
return
}
switch err := a.Repo.ApproveOpLogin(r.Context(), id, approverID, a.now()); {
switch err := a.Repo.ApproveOpLogin(r.Context(), id, approver.ID, a.now()); {
case errors.Is(err, ErrNotFound):
writeError(w, r, newError(http.StatusNotFound, "op_login_not_found", "no pending operator login with that id"))
return
@@ -442,10 +528,15 @@ func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
payload, _ := json.Marshal(map[string]string{"request_id": id, "approver_user_id": approverID})
payload, _ := json.Marshal(map[string]string{
"request_id": id, "approver_user_id": approver.ID,
"username": loginReq.Username, "client_ip": loginReq.ClientIP,
})
a.auditEntry(r, AuditEntry{
Actor: approver.Username, ActorUserID: approverID, Source: internalSource(r),
Actor: approver.Username, ActorUserID: approver.ID, Source: internalSource(r),
Action: "auth.op_login.approved", Payload: payload,
})
writeJSON(w, http.StatusOK, map[string]any{"approved": true})
writeJSON(w, http.StatusOK, map[string]any{
"approved": true, "username": loginReq.Username, "email": loginReq.Email,
})
}
+149 -19
View File
@@ -58,9 +58,9 @@ func finishOp(eh http.Handler, id, code string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/op-login/finish",
`{"request_id":"`+id+`","code":"`+code+`"}`, jsonHeader)
}
func approveOp(ih http.Handler, id, approverUUID string) *httptest.ResponseRecorder {
func approveOp(ih http.Handler, id, approverUUID, username string) *httptest.ResponseRecorder {
return do(ih, "POST", "/api/v1/internal/op-login/"+id+"/approve",
`{"approver_uuid":"`+approverUUID+`"}`, nil)
`{"approver_uuid":"`+approverUUID+`","username":"`+username+`"}`, nil)
}
// TestOpLoginVertical walks the whole two-factor slice end to end: start mails a code
@@ -126,7 +126,7 @@ func TestOpLoginVertical(t *testing.T) {
}
// 4) an in-game admin approves via the internal face.
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
if w := approveOp(ih, reqID, opUUID, "op"); w.Code != http.StatusOK {
t.Fatalf("approve: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if ab := acctBody(t, statusOp(eh, reqID)); ab["approved"] != true {
@@ -198,7 +198,7 @@ func TestOpLoginOwnerAdmitted(t *testing.T) {
t.Fatalf("owner start must mint a request + mail a code: req=%q mails=%d rows=%d",
reqID, mailer.calls, len(repo.opLogins))
}
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
if w := approveOp(ih, reqID, opUUID, "owner"); w.Code != http.StatusOK {
t.Fatalf("approve: code = %d (%s)", w.Code, w.Body.String())
}
w = finishOp(eh, reqID, mailer.code)
@@ -303,7 +303,7 @@ func TestOpLoginStartByAStranger(t *testing.T) {
if mailer.calls != 1 || len(repo.otps) != 1 {
t.Fatalf("start inside the cooldown: mails=%d otps=%d, want 1/1", mailer.calls, len(repo.otps))
}
if w := approveOp(ih, own, opUUID); w.Code != http.StatusOK {
if w := approveOp(ih, own, opUUID, "op"); w.Code != http.StatusOK {
t.Fatalf("approve: code = %d (%s)", w.Code, w.Body.String())
}
if w := finishOp(eh, own, inboxCode); w.Code != http.StatusOK {
@@ -319,7 +319,7 @@ func TestOpLoginStartByAStranger(t *testing.T) {
if mailer.calls != 3 {
t.Fatalf("mails = %d, want 3", mailer.calls)
}
if w := approveOp(ih, first, opUUID); w.Code != http.StatusOK {
if w := approveOp(ih, first, opUUID, "op"); w.Code != http.StatusOK {
t.Fatalf("approve: code = %d (%s)", w.Code, w.Body.String())
}
if w := finishOp(eh, first, firstCode); w.Code != http.StatusOK {
@@ -370,7 +370,7 @@ func TestOpLoginFinishUniform(t *testing.T) {
eh, ih := api.ExternalHandler(), api.InternalHandler()
reqID := acctBody(t, startOp(eh, "[email protected]"))["request_id"].(string)
code := mailer.code
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
if w := approveOp(ih, reqID, opUUID, "op"); w.Code != http.StatusOK {
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
}
// Wrong code for a real, approved request.
@@ -403,7 +403,7 @@ func TestOpLoginFinishUniform(t *testing.T) {
}
}
// Approve, then the same code completes.
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
if w := approveOp(ih, reqID, opUUID, "op"); w.Code != http.StatusOK {
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
}
if w := finishOp(eh, reqID, code); w.Code != http.StatusOK {
@@ -416,7 +416,7 @@ func TestOpLoginFinishUniform(t *testing.T) {
eh, ih := api.ExternalHandler(), api.InternalHandler()
reqID := acctBody(t, startOp(eh, "[email protected]"))["request_id"].(string)
code := mailer.code
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
if w := approveOp(ih, reqID, opUUID, "op"); w.Code != http.StatusOK {
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
}
// Wrong code: refused, one attempt charged, request still approved+unconsumed.
@@ -454,7 +454,7 @@ func TestOpLoginApproveGate(t *testing.T) {
t.Run("unlinked approver UUID -> 403, request stays pending", func(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
id := plantPending(repo)
if w := approveOp(api.InternalHandler(), id, "ffffffff-ffff-ffff-ffff-ffffffffffff"); w.Code != http.StatusForbidden || decodeErr(t, w) != "not_admin" {
if w := approveOp(api.InternalHandler(), id, "ffffffff-ffff-ffff-ffff-ffffffffffff", "op"); w.Code != http.StatusForbidden || decodeErr(t, w) != "not_admin" {
t.Fatalf("unlinked approver: code = %d body %s, want 403 not_admin", w.Code, w.Body.String())
}
if repo.opLogins[id].status != "pending" {
@@ -467,7 +467,7 @@ func TestOpLoginApproveGate(t *testing.T) {
id := plantPending(repo)
repo.staff["p"] = &StaffUser{ID: "u9", Username: "p", Email: "[email protected]", Role: "user", EmailVerified: true}
repo.links["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"] = "u9"
if w := approveOp(api.InternalHandler(), id, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); w.Code != http.StatusForbidden || decodeErr(t, w) != "not_admin" {
if w := approveOp(api.InternalHandler(), id, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "op"); w.Code != http.StatusForbidden || decodeErr(t, w) != "not_admin" {
t.Fatalf("non-admin approver: code = %d body %s, want 403 not_admin", w.Code, w.Body.String())
}
})
@@ -479,7 +479,7 @@ func TestOpLoginApproveGate(t *testing.T) {
repo.staff["boss"] = &StaffUser{ID: "b1", Username: "boss", Role: "owner"}
repo.links["cccccccc-cccc-cccc-cccc-cccccccccccc"] = "b1"
id := plantPending(repo)
if w := approveOp(api.InternalHandler(), id, "cccccccc-cccc-cccc-cccc-cccccccccccc"); w.Code != http.StatusOK {
if w := approveOp(api.InternalHandler(), id, "cccccccc-cccc-cccc-cccc-cccccccccccc", "op"); w.Code != http.StatusOK {
t.Fatalf("owner-role approver: code = %d body %s, want 200", w.Code, w.Body.String())
}
})
@@ -494,7 +494,7 @@ func TestOpLoginApproveGate(t *testing.T) {
t.Run("unknown request id -> 404", func(t *testing.T) {
api, _, _ := seedOpLoginAPI(t)
if w := approveOp(api.InternalHandler(), "nosuchrequest", opUUID); w.Code != http.StatusNotFound || decodeErr(t, w) != "op_login_not_found" {
if w := approveOp(api.InternalHandler(), "nosuchrequest", opUUID, "op"); w.Code != http.StatusNotFound || decodeErr(t, w) != "op_login_not_found" {
t.Fatalf("unknown request: code = %d body %s, want 404 op_login_not_found", w.Code, w.Body.String())
}
})
@@ -502,10 +502,10 @@ func TestOpLoginApproveGate(t *testing.T) {
t.Run("re-approving an approved request -> 404 (first approval stands)", func(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
id := plantPending(repo)
if w := approveOp(api.InternalHandler(), id, opUUID); w.Code != http.StatusOK {
if w := approveOp(api.InternalHandler(), id, opUUID, "op"); w.Code != http.StatusOK {
t.Fatalf("first approve: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if w := approveOp(api.InternalHandler(), id, opUUID); w.Code != http.StatusNotFound {
if w := approveOp(api.InternalHandler(), id, opUUID, "op"); w.Code != http.StatusNotFound {
t.Fatalf("second approve: code = %d, want 404 (no longer pending)", w.Code)
}
if repo.opLogins[id].status != "approved" {
@@ -525,7 +525,7 @@ func TestOpLoginPendingList(t *testing.T) {
// Two pending (distinct createdAt so ordering is deterministic), one approved, one
// expired.
repo.opLogins["r2"] = &fakeOpLogin{id: "r2", userID: "a1", email: "[email protected]", status: "pending", expiresAt: future, createdAt: time.Unix(1_700_000_200, 0)}
repo.opLogins["r1"] = &fakeOpLogin{id: "r1", userID: "a1", email: "[email protected]", status: "pending", expiresAt: future, createdAt: time.Unix(1_700_000_100, 0)}
repo.opLogins["r1"] = &fakeOpLogin{id: "r1", userID: "a1", email: "[email protected]", status: "pending", expiresAt: future, createdAt: time.Unix(1_700_000_100, 0), clientIP: "198.51.100.7"}
repo.opLogins["ap"] = &fakeOpLogin{id: "ap", userID: "a1", email: "[email protected]", status: "approved", expiresAt: future, createdAt: time.Unix(1_700_000_150, 0)}
repo.opLogins["ex"] = &fakeOpLogin{id: "ex", userID: "a1", email: "[email protected]", status: "pending", expiresAt: time.Unix(1_699_999_999, 0), createdAt: time.Unix(1_700_000_050, 0)}
@@ -544,8 +544,8 @@ func TestOpLoginPendingList(t *testing.T) {
if first["request_id"] != "r1" || second["request_id"] != "r2" {
t.Errorf("order = [%v, %v], want [r1, r2] (oldest first)", first["request_id"], second["request_id"])
}
if first["username"] != "op" || first["email"] != "[email protected]" {
t.Errorf("row projection = %v, want username op / email [email protected]", first)
if first["username"] != "op" || first["email"] != "[email protected]" || first["client_ip"] != "198.51.100.7" {
t.Errorf("row projection = %v, want username op / email [email protected] / client_ip 198.51.100.7", first)
}
}
@@ -625,7 +625,137 @@ func TestOpLoginFaceSeparation(t *testing.T) {
if w := do(eh, "GET", "/api/v1/internal/op-login/pending", "", nil); w.Code != http.StatusNotFound {
t.Errorf("pending on external face: code = %d, want 404", w.Code)
}
if w := do(eh, "POST", "/api/v1/internal/op-login/x/approve", `{"approver_uuid":"`+opUUID+`"}`, nil); w.Code != http.StatusNotFound {
if w := do(eh, "POST", "/api/v1/internal/op-login/x/approve", `{"approver_uuid":"`+opUUID+`","username":"op"}`, nil); w.Code != http.StatusNotFound {
t.Errorf("approve on external face: code = %d, want 404", w.Code)
}
if w := do(eh, "GET", "/api/v1/internal/op-login/x?approver_uuid="+opUUID, "", nil); w.Code != http.StatusNotFound {
t.Errorf("show on external face: code = %d, want 404", w.Code)
}
}
// showOp drives the in-game "who is this for" read.
func showOp(ih http.Handler, id, approverUUID string) *httptest.ResponseRecorder {
return do(ih, "GET", "/api/v1/internal/op-login/"+id+"?approver_uuid="+approverUUID, "", nil)
}
// TestOpLoginShowsWhoIsWaiting pins what the in-game admin sees before vouching:
// start records where the sign-in came from, and the show read returns the account,
// its address and that origin to a linked staff approver only.
func TestOpLoginShowsWhoIsWaiting(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
eh, ih := api.ExternalHandler(), api.InternalHandler()
w := do(eh, "POST", "/api/v1/auth/op-login/start", `{"email":"[email protected]"}`,
map[string]string{"Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/140.0"})
if w.Code != http.StatusAccepted {
t.Fatalf("start: code = %d (%s)", w.Code, w.Body.String())
}
reqID, _ := acctBody(t, w)["request_id"].(string)
row := repo.opLogins[reqID]
if row == nil || row.clientIP != "192.0.2.1" || row.userAgent != "Mozilla/5.0 (X11; Linux x86_64) Firefox/140.0" {
t.Fatalf("stored origin = %+v, want client 192.0.2.1 and the Firefox user agent", row)
}
w = showOp(ih, reqID, opUUID)
if w.Code != http.StatusOK {
t.Fatalf("show: code = %d (%s)", w.Code, w.Body.String())
}
want := map[string]any{
"request_id": reqID,
"username": "op",
"email": "[email protected]",
"client_ip": "192.0.2.1",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/140.0",
"expires_at": "2023-11-14T22:23:20Z",
}
got := acctBody(t, w)
for k, v := range want {
if got[k] != v {
t.Errorf("show %s = %v, want %v", k, got[k], v)
}
}
if _, ok := got["created_at"].(string); !ok {
t.Errorf("show must carry created_at, got %v", got)
}
t.Run("refusals", func(t *testing.T) {
repo.staff["p"] = &StaffUser{ID: "u9", Username: "p", Email: "[email protected]", Role: "user", EmailVerified: true}
repo.links["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"] = "u9"
repo.opLogins["old"] = &fakeOpLogin{id: "old", userID: "a1", email: "[email protected]", status: "pending",
expiresAt: time.Unix(1_699_999_999, 0), createdAt: time.Unix(1_699_999_400, 0)}
for _, tc := range []struct {
name, target string
code int
errCode string
}{
{"no approver", "/api/v1/internal/op-login/" + reqID, http.StatusBadRequest, "bad_request"},
{"unlinked approver", "/api/v1/internal/op-login/" + reqID + "?approver_uuid=ffffffff-ffff-ffff-ffff-ffffffffffff", http.StatusForbidden, "not_admin"},
{"linked player", "/api/v1/internal/op-login/" + reqID + "?approver_uuid=bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", http.StatusForbidden, "not_admin"},
{"unknown request", "/api/v1/internal/op-login/nosuchrequest?approver_uuid=" + opUUID, http.StatusNotFound, "op_login_not_found"},
{"expired request", "/api/v1/internal/op-login/old?approver_uuid=" + opUUID, http.StatusNotFound, "op_login_not_found"},
} {
w := do(ih, "GET", tc.target, "", nil)
if w.Code != tc.code || decodeErr(t, w) != tc.errCode {
t.Errorf("%s: code = %d body %s, want %d %s", tc.name, w.Code, w.Body.String(), tc.code, tc.errCode)
}
if strings.Contains(w.Body.String(), "[email protected]") {
t.Errorf("%s: a refusal leaked the staff address: %s", tc.name, w.Body.String())
}
}
})
t.Run("an approved request is no longer shown", func(t *testing.T) {
if w := approveOp(ih, reqID, opUUID, "op"); w.Code != http.StatusOK {
t.Fatalf("approve: code = %d (%s)", w.Code, w.Body.String())
}
if w := showOp(ih, reqID, opUUID); w.Code != http.StatusNotFound || decodeErr(t, w) != "op_login_not_found" {
t.Fatalf("show after approval: code = %d body %s, want 404 op_login_not_found", w.Code, w.Body.String())
}
})
}
// TestOpLoginApproveNamesTheAccount pins the confirmation: the admin must type the
// name of the account the request is for. A different name leaves the request
// pending and is audited; the matching name (any case) approves, and the response
// says whose sign-in was approved.
func TestOpLoginApproveNamesTheAccount(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
ih := api.InternalHandler()
repo.opLogins["r1"] = &fakeOpLogin{id: "r1", userID: "a1", email: "[email protected]", status: "pending",
expiresAt: time.Unix(1_700_000_600, 0), createdAt: time.Unix(1_699_999_900, 0), clientIP: "203.0.113.50"}
if w := do(ih, "POST", "/api/v1/internal/op-login/r1/approve", `{"approver_uuid":"`+opUUID+`"}`, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Fatalf("no username: code = %d body %s, want 400 bad_request", w.Code, w.Body.String())
}
w := approveOp(ih, "r1", opUUID, "alice")
if w.Code != http.StatusConflict || decodeErr(t, w) != "op_login_mismatch" {
t.Fatalf("wrong name: code = %d body %s, want 409 op_login_mismatch", w.Code, w.Body.String())
}
if repo.opLogins["r1"].status != "pending" {
t.Fatal("a mismatched approval must leave the request pending")
}
if n := len(repo.audits); n != 1 {
t.Fatalf("audits after mismatch = %d, want 1: %+v", n, repo.audits)
}
if a := repo.audits[0]; a.Action != "auth.op_login.approve_mismatch" || a.ActorUserID != "a1" ||
string(a.Payload) != `{"request_id":"r1","typed_username":"alice"}` {
t.Errorf("mismatch audit = %+v payload %s", a, a.Payload)
}
w = approveOp(ih, "r1", opUUID, "OP")
if w.Code != http.StatusOK {
t.Fatalf("matching name: code = %d body %s, want 200", w.Code, w.Body.String())
}
body := acctBody(t, w)
if body["approved"] != true || body["username"] != "op" || body["email"] != "[email protected]" {
t.Errorf("approve body = %v, want approved:true username:op email:[email protected]", body)
}
if repo.opLogins["r1"].status != "approved" {
t.Error("the matching name must approve the request")
}
if a := repo.audits[len(repo.audits)-1]; a.Action != "auth.op_login.approved" ||
string(a.Payload) != `{"approver_user_id":"a1","client_ip":"203.0.113.50","request_id":"r1","username":"op"}` {
t.Errorf("approved audit = %+v payload %s", a, a.Payload)
}
}
+1
View File
@@ -86,6 +86,7 @@ func TestInternalRoutesServeOnlyTheirCallers(t *testing.T) {
{"GET", "/api/v1/internal/player/blacklist/00000000-0000-0000-0000-000000000001", []Caller{CallerVelocity, CallerLimbo}},
{"POST", "/api/v1/internal/op-login/req-1/approve", []Caller{CallerVelocity}},
{"GET", "/api/v1/internal/op-login/pending", []Caller{CallerVelocity}},
{"GET", "/api/v1/internal/op-login/req-1", []Caller{CallerVelocity}},
{"POST", "/api/v1/internal/account/migrate/start", []Caller{CallerVelocity}},
{"POST", "/api/v1/internal/player/reclaim", []Caller{CallerVelocity}},
{"POST", "/api/v1/internal/servers/survival/wake", []Caller{CallerVelocity}},
+18 -15
View File
@@ -2504,30 +2504,31 @@ func chargeOTPMismatch(ctx context.Context, tx *sql.Tx, userID, purpose string,
// CreateOpLoginRequest records a fresh pending op.console login attempt for a staff
// account. It writes the SECOND factor only — the email-OTP is minted separately
// under purpose 'op_login' — so a row here means this staff account is waiting for
// an in-game admin to vouch. email is a snapshot for the audit trail.
func (p *PGRepo) CreateOpLoginRequest(ctx context.Context, id, userID, email string, expiresAt time.Time) error {
// an in-game admin to vouch. Email is a snapshot for the audit trail.
func (p *PGRepo) CreateOpLoginRequest(ctx context.Context, req NewOpLoginRequest) error {
_, err := p.db.ExecContext(ctx,
`INSERT INTO op_login_requests (id, user_id, email, expires_at) VALUES ($1, $2, $3, $4)`,
id, userID, email, expiresAt)
`INSERT INTO op_login_requests (id, user_id, email, expires_at, client_ip, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)`,
req.ID, req.UserID, req.Email, req.ExpiresAt, req.ClientIP, req.UserAgent)
return err
}
// OpLoginRequestByID loads a request by its handle, or ErrNotFound. The status poll
// and the finish path both use it; finish additionally checks Status=='approved',
// !Consumed, and ExpiresAt>now before minting a session. Username is left empty (no
// join needed here). Status is derived from approved_at: 'approved' once set, else
// 'pending'.
// OpLoginRequestByID loads a request by its handle, joined to its account's
// username, or ErrNotFound. finish additionally checks Status=='approved',
// !Consumed, and ExpiresAt>now before minting a session. Status is derived from
// approved_at: 'approved' once set, else 'pending'.
func (p *PGRepo) OpLoginRequestByID(ctx context.Context, id string) (*OpLoginRequest, error) {
const q = `SELECT id, user_id, email, expires_at, consumed_at, approved_at, approved_by
FROM op_login_requests WHERE id = $1`
const q = `SELECT r.id, r.user_id, u.username, r.email, r.expires_at, r.created_at,
r.consumed_at, r.approved_at, r.client_ip, r.user_agent
FROM op_login_requests r JOIN users u ON u.id = r.user_id WHERE r.id = $1`
var (
r OpLoginRequest
consumedAt sql.NullTime
approvedAt sql.NullTime
approvedBy sql.NullString
)
switch err := p.db.QueryRowContext(ctx, q, id).Scan(
&r.ID, &r.UserID, &r.Email, &r.ExpiresAt, &consumedAt, &approvedAt, &approvedBy); {
&r.ID, &r.UserID, &r.Username, &r.Email, &r.ExpiresAt, &r.CreatedAt,
&consumedAt, &approvedAt, &r.ClientIP, &r.UserAgent); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
case err != nil:
@@ -2549,7 +2550,8 @@ func (p *PGRepo) OpLoginRequestByID(ctx context.Context, id string) (*OpLoginReq
// account; created_at orders the list and lets the prompt show how long a request
// has been waiting.
func (p *PGRepo) ListPendingOpLogins(ctx context.Context, now time.Time) ([]OpLoginRequest, error) {
const q = `SELECT r.id, r.user_id, u.username, r.email, r.expires_at, r.created_at
const q = `SELECT r.id, r.user_id, u.username, r.email, r.expires_at, r.created_at,
r.client_ip, r.user_agent
FROM op_login_requests r JOIN users u ON u.id = r.user_id
WHERE r.consumed_at IS NULL AND r.approved_at IS NULL AND r.expires_at > $1
ORDER BY r.created_at`
@@ -2561,7 +2563,8 @@ func (p *PGRepo) ListPendingOpLogins(ctx context.Context, now time.Time) ([]OpLo
var out []OpLoginRequest
for rows.Next() {
var r OpLoginRequest
if err := rows.Scan(&r.ID, &r.UserID, &r.Username, &r.Email, &r.ExpiresAt, &r.CreatedAt); err != nil {
if err := rows.Scan(&r.ID, &r.UserID, &r.Username, &r.Email, &r.ExpiresAt, &r.CreatedAt,
&r.ClientIP, &r.UserAgent); err != nil {
return nil, err
}
r.Status = "pending"
+28 -14
View File
@@ -186,18 +186,31 @@ type NewSession struct {
// OpLoginRequest is one op.console staff-login attempt (spec §B op-login): the
// durable second factor (in-game approval) that pairs with an email_otps code under
// purpose 'op_login'. Username is populated only by ListPendingOpLogins (the join the
// in-game admin needs to name who is waiting); Consumed reflects consumed_at, so the
// finish path can refuse an already-spent request without a second query.
// purpose 'op_login'. Username is joined from users so the in-game admin can name who
// is waiting; Consumed reflects consumed_at, so the finish path can refuse an
// already-spent request without a second query.
type OpLoginRequest struct {
ID string
UserID string
Username string // joined for the in-game pending list; "" elsewhere
Username string
Email string
Status string // 'pending' | 'approved' | 'denied'
Consumed bool // consumed_at IS NOT NULL (single-use guard)
ExpiresAt time.Time
CreatedAt time.Time
// ClientIP and UserAgent say where start was called from; empty on rows
// from before migration 0030.
ClientIP string
UserAgent string
}
// NewOpLoginRequest is the row op-login start writes. Email is a snapshot for the
// audit trail; ClientIP and UserAgent describe the browser that asked, for the
// in-game admin to check before vouching.
type NewOpLoginRequest struct {
ID, UserID, Email string
ClientIP, UserAgent string
ExpiresAt time.Time
}
// MigrationView is the live account-migration for a source user (spec §B3 inherit,
@@ -429,16 +442,17 @@ type Repo interface {
// ---- op.console staff login: in-game approval state machine (spec §B op-login) ----
// CreateOpLoginRequest records a fresh pending op.console login attempt for a staff
// account (spec §B op-login). id is the opaque handle the browser polls; email is a
// snapshot for the audit trail. It writes the SECOND factor only — the email-OTP
// itself is minted separately under purpose 'op_login' (CreateEmailOTP) — so a row
// here means "this staff account is waiting for an in-game admin to vouch". expiresAt
// is the API clock + TTL so expiry is driven by one authoritative clock.
CreateOpLoginRequest(ctx context.Context, id, userID, email string, expiresAt time.Time) error
// OpLoginRequestByID loads a request by its handle, or ErrNotFound. The status poll
// and the finish path both use it: finish additionally checks Status=='approved',
// !Consumed, and ExpiresAt>now before it will mint a session, so a pending, spent, or
// expired request can never be exchanged. Username is left empty (no join needed here).
// account (spec §B op-login). ID is the opaque handle the browser polls. It writes
// the SECOND factor only — the email-OTP itself is minted separately under purpose
// 'op_login' (AddLoginEmailOTP) — so a row here means "this staff account is waiting
// for an in-game admin to vouch". ExpiresAt is the API clock + TTL so expiry is
// driven by one authoritative clock.
CreateOpLoginRequest(ctx context.Context, req NewOpLoginRequest) error
// OpLoginRequestByID loads a request by its handle, joined to its account's
// username, or ErrNotFound. The status poll, the finish path and the in-game
// approval all use it: finish additionally checks Status=='approved', !Consumed,
// and ExpiresAt>now before it will mint a session, so a pending, spent, or expired
// request can never be exchanged.
OpLoginRequestByID(ctx context.Context, id string) (*OpLoginRequest, error)
// ListPendingOpLogins returns the live (pending, unconsumed, unexpired at now)
// requests oldest-first, each joined to its staff username, for the in-game admin's