From d93c1b6913d5e6acddf97b28d4a2852ac3bb3f19 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Fri, 25 Sep 2026 17:02:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20op-login=20=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E5=86=85=E5=AE=A1=E6=89=B9=E5=85=88=E5=B1=95=E7=A4=BA=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E8=B4=A6=E5=8F=B7=E3=80=81=E9=82=AE=E7=AE=B1=E4=B8=8E?= =?UTF-8?q?=E5=8F=91=E8=B5=B7=E6=9D=A5=E6=BA=90=EF=BC=8C=E9=A1=BB=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E8=B4=A6=E6=88=B7=E5=90=8D=E7=A1=AE=E8=AE=A4=EF=BC=8C?= =?UTF-8?q?velocity=20=E6=98=BE=E7=A4=BA=E5=AE=A1=E6=89=B9=E5=8D=A1?= =?UTF-8?q?=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/openapi.yaml | 120 ++++++++++--- internal/api/api.go | 1 + internal/api/api_test.go | 29 +-- internal/api/handlers_op_login.go | 163 +++++++++++++---- internal/api/handlers_op_login_test.go | 168 ++++++++++++++++-- internal/api/internal_callers_test.go | 1 + internal/api/pgrepo.go | 33 ++-- internal/api/repo.go | 42 +++-- internal/pgint/pgint_test.go | 19 +- .../store/migrations/0030_op_login_origin.sql | 8 + panel/src/i18n/resources/en-US/errors.json | 1 + panel/src/i18n/resources/zh-CN/errors.json | 1 + panel/src/lib/openapi.gen.ts | 127 +++++++++++-- .../lolicon/felis/link/FelisApiClient.java | 43 +++-- .../java/best/lolicon/felis/link/Json.java | 26 +++ .../best/lolicon/felis/link/OpLoginView.java | 83 +++++++++ .../felis/link/FelisApiClientTest.java | 71 +++++++- plugins/test.sh | 15 +- .../felis/velocity/FelisVelocityPlugin.java | 75 ++++++-- .../felis/velocity/OpApprovalCard.java | 89 ++++++++++ .../felis/velocity/OpApprovalCardTest.java | 149 ++++++++++++++++ 21 files changed, 1088 insertions(+), 176 deletions(-) create mode 100644 internal/store/migrations/0030_op_login_origin.sql create mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/OpLoginView.java create mode 100644 plugins/velocity/src/main/java/best/lolicon/felis/velocity/OpApprovalCard.java create mode 100644 plugins/velocity/test/best/lolicon/felis/velocity/OpApprovalCardTest.java diff --git a/docs/openapi.yaml b/docs/openapi.yaml index cddacdd..74390f1 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1307,52 +1307,58 @@ paths: type: array items: type: object - required: [request_id, username, email, created_at] + required: [request_id, username, email, client_ip, created_at] properties: request_id: { type: string } username: { type: string } email: { type: string } + client_ip: + type: string + description: Where start was called from; empty on requests from before this was recorded. created_at: { type: string, format: date-time } '401': $ref: '#/components/responses/Unauthorized' - /api/v1/internal/op-login/{id}/approve: - post: + /api/v1/internal/op-login/{id}: + get: tags: [account-internal] - operationId: opLoginApprove - summary: Record an in-game admin's vouch for a pending op.console login (spec §B). + operationId: opLoginShow + summary: Show an in-game admin whose op.console login a request is (spec §B). description: > - Internal-only second factor: velocity submits the online-mode UUID of the - in-game admin running /felis web op approve. The API resolves it to a linked - role=admin account (else 403 not_admin) and flips the request approved. A - missing or no-longer-pending request is 404. Self-approval is allowed — an - online staff member vouching as their own admin identity is a genuine second - factor distinct from the mailbox. + Internal-only. velocity's /felis web op approve reads this and shows the + admin the account, its address, and when and from where the sign-in was started, + then asks them to confirm by typing the account name (see approve). The + approver's online-mode UUID gets the same check as approve (a linked admin or + owner, else 403 not_admin), since the command runs for any player and a staff + address must not be readable by one. A request that is unknown, expired, + approved or consumed is 404. x-felis-face: [internal] x-felis-tier: service x-felis-callers: [velocity] security: [{ serviceToken: [] }] parameters: - { name: id, in: path, required: true, schema: { type: string } } - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [approver_uuid] - properties: - approver_uuid: { type: string, format: uuid } + - { name: approver_uuid, in: query, required: true, schema: { type: string, format: uuid } } responses: '200': - description: The vouch was recorded; the request is now approved. + description: The pending request and where it was started. content: application/json: schema: type: object - required: [approved] + required: [request_id, username, email, client_ip, user_agent, created_at, expires_at] properties: - approved: { type: boolean, const: true } + request_id: { type: string } + username: { type: string } + email: { type: string } + client_ip: + type: string + description: Where start was called from; empty on requests from before this was recorded. + user_agent: + type: string + description: The browser's User-Agent at start, up to 256 bytes; may be empty. + created_at: { type: string, format: date-time } + expires_at: { type: string, format: date-time } '400': description: approver_uuid is required (bad_request). content: @@ -1371,6 +1377,74 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /api/v1/internal/op-login/{id}/approve: + post: + tags: [account-internal] + operationId: opLoginApprove + summary: Record an in-game admin's vouch for a pending op.console login (spec §B). + description: > + Internal-only second factor: velocity submits the online-mode UUID of the + in-game admin running /felis web op approve , and the account + name they typed after seeing the request (GET /api/v1/internal/op-login/{id}). + The API resolves the UUID to a linked admin or owner account (else 403 + not_admin), requires the typed name to match the request's account ignoring + case (else 409 op_login_mismatch, audited, request left pending) and flips the + request approved. A missing or no-longer-pending request is 404. Self-approval + is allowed — an online staff member vouching as their own admin identity is a + genuine second factor distinct from the mailbox. + x-felis-face: [internal] + x-felis-tier: service + x-felis-callers: [velocity] + security: [{ serviceToken: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [approver_uuid, username] + properties: + approver_uuid: { type: string, format: uuid } + username: + type: string + description: The account name the admin typed to confirm whose sign-in this is. + responses: + '200': + description: The vouch was recorded; the request is now approved. + content: + application/json: + schema: + type: object + required: [approved, username, email] + properties: + approved: { type: boolean, const: true } + username: { type: string } + email: { type: string } + '400': + description: approver_uuid and username are required (bad_request). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: The approver is not a linked administrator (not_admin). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '404': + description: No pending operator login with that id (op_login_not_found). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '409': + description: The typed name is not the request's account (op_login_mismatch). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + /api/v1/internal/servers/{name}/backup: post: tags: [account-internal] diff --git a/internal/api/api.go b/internal/api/api.go index af014c9..24d9d6b 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 8c1d00b..a222b6b 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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 { diff --git a/internal/api/handlers_op_login.go b/internal/api/handlers_op_login.go index a356f04..b92ef8b 100644 --- a/internal/api/handlers_op_login.go +++ b/internal/api/handlers_op_login.go @@ -11,14 +11,15 @@ import ( // sensitive tier. Unlike the console. 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 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, + }) } diff --git a/internal/api/handlers_op_login_test.go b/internal/api/handlers_op_login_test.go index 3535c19..f33889b 100644 --- a/internal/api/handlers_op_login_test.go +++ b/internal/api/handlers_op_login_test.go @@ -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, "op@example.net"))["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, "op@example.net"))["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: "player@example.net", 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: "op@example.net", status: "pending", expiresAt: future, createdAt: time.Unix(1_700_000_200, 0)} - repo.opLogins["r1"] = &fakeOpLogin{id: "r1", userID: "a1", email: "op@example.net", status: "pending", expiresAt: future, createdAt: time.Unix(1_700_000_100, 0)} + repo.opLogins["r1"] = &fakeOpLogin{id: "r1", userID: "a1", email: "op@example.net", 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: "op@example.net", status: "approved", expiresAt: future, createdAt: time.Unix(1_700_000_150, 0)} repo.opLogins["ex"] = &fakeOpLogin{id: "ex", userID: "a1", email: "op@example.net", 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"] != "op@example.net" { - t.Errorf("row projection = %v, want username op / email op@example.net", first) + if first["username"] != "op" || first["email"] != "op@example.net" || first["client_ip"] != "198.51.100.7" { + t.Errorf("row projection = %v, want username op / email op@example.net / 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":"op@example.net"}`, + 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": "Op@Example.NET", + "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: "player@example.net", Role: "user", EmailVerified: true} + repo.links["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"] = "u9" + repo.opLogins["old"] = &fakeOpLogin{id: "old", userID: "a1", email: "op@example.net", 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(), "Op@Example.NET") { + 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: "Op@Example.NET", 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"] != "Op@Example.NET" { + t.Errorf("approve body = %v, want approved:true username:op email:Op@Example.NET", 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) + } } diff --git a/internal/api/internal_callers_test.go b/internal/api/internal_callers_test.go index 249a1b6..f0b8551 100644 --- a/internal/api/internal_callers_test.go +++ b/internal/api/internal_callers_test.go @@ -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}}, diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 09bea45..4854c99 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -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" diff --git a/internal/api/repo.go b/internal/api/repo.go index 5acfc20..870d2fc 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -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 diff --git a/internal/pgint/pgint_test.go b/internal/pgint/pgint_test.go index e7eecac..67774a0 100644 --- a/internal/pgint/pgint_test.go +++ b/internal/pgint/pgint_test.go @@ -1049,7 +1049,10 @@ func TestOpLoginStateMachine(t *testing.T) { now := mustNow() id := "opl-" + suffix(t) - if err := repo.CreateOpLoginRequest(ctx, id, staff.ID, "staff@example.net", now.Add(10*time.Minute)); err != nil { + if err := repo.CreateOpLoginRequest(ctx, api.NewOpLoginRequest{ + ID: id, UserID: staff.ID, Email: "staff@example.net", ExpiresAt: now.Add(10 * time.Minute), + ClientIP: "203.0.113.9", UserAgent: "Mozilla/5.0 Firefox/140.0", + }); err != nil { t.Fatalf("CreateOpLoginRequest: %v", err) } req, err := repo.OpLoginRequestByID(ctx, id) @@ -1059,8 +1062,10 @@ func TestOpLoginStateMachine(t *testing.T) { if req.Status != "pending" || req.Consumed { t.Fatalf("fresh request = %+v, want pending+unconsumed", req) } - if req.Username != "" { - t.Errorf("ByID must not join a username, got %q", req.Username) + // The in-game approval card reads all of this off ByID. + if req.Username != staff.Username || req.Email != "staff@example.net" || + req.ClientIP != "203.0.113.9" || req.UserAgent != "Mozilla/5.0 Firefox/140.0" || req.CreatedAt.IsZero() { + t.Errorf("ByID = %+v, want username %q, the snapshot email, the start origin and a created_at", req, staff.Username) } // The pending list is what the in-game admin sees: it must name the staff @@ -1075,8 +1080,8 @@ func TestOpLoginStateMachine(t *testing.T) { continue } found = true - if p.Username != staff.Username { - t.Errorf("pending username = %q, want %q", p.Username, staff.Username) + if p.Username != staff.Username || p.ClientIP != "203.0.113.9" { + t.Errorf("pending username/client = %q/%q, want %q/203.0.113.9", p.Username, p.ClientIP, staff.Username) } if p.CreatedAt.IsZero() { t.Error("pending created_at is zero") @@ -1102,7 +1107,9 @@ func TestOpLoginStateMachine(t *testing.T) { // An expired request is dead on every path. oldID := "opl-old-" + suffix(t) - if err := repo.CreateOpLoginRequest(ctx, oldID, staff.ID, "staff@example.net", now.Add(-time.Minute)); err != nil { + if err := repo.CreateOpLoginRequest(ctx, api.NewOpLoginRequest{ + ID: oldID, UserID: staff.ID, Email: "staff@example.net", ExpiresAt: now.Add(-time.Minute), + }); err != nil { t.Fatalf("CreateOpLoginRequest (expired): %v", err) } pending, err = repo.ListPendingOpLogins(ctx, now) diff --git a/internal/store/migrations/0030_op_login_origin.sql b/internal/store/migrations/0030_op_login_origin.sql new file mode 100644 index 0000000..e207f32 --- /dev/null +++ b/internal/store/migrations/0030_op_login_origin.sql @@ -0,0 +1,8 @@ +-- Where an op.console sign-in was started. The in-game admin sees it before +-- vouching (/felis web op approve ), next to the account name and +-- address, so a request started from an unfamiliar network or browser can be +-- turned down. Recorded once, at start; rows from before this migration show +-- blanks. +ALTER TABLE op_login_requests + ADD COLUMN client_ip text NOT NULL DEFAULT '', + ADD COLUMN user_agent text NOT NULL DEFAULT ''; diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 166ec11..21434fd 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -83,6 +83,7 @@ "no_session": "This only works in a browser signed in to Felis.", "op_login_invalid": "This operator sign-in couldn't be completed — restart the sign-in.", "op_login_not_found": "No pending operator sign-in with that id.", + "op_login_mismatch": "That operator sign-in is for a different account.", "too_many_streams": "Too many live streams are open — close some pages and try again.", "protected_admin": "That name belongs to a linked administrator and can't be reclaimed.", "auth_unavailable": "The sign-in service isn't available right now.", diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index 17284ec..021ce07 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -83,6 +83,7 @@ "no_session": "只能在已登录 Felis 的浏览器中进行此操作。", "op_login_invalid": "本次管理员登录未能完成——请重新发起登录。", "op_login_not_found": "找不到该管理员登录请求。", + "op_login_mismatch": "该管理员登录请求属于另一个账户。", "too_many_streams": "同时打开的实时连接过多——请关闭一些页面后再试。", "protected_admin": "该名字属于已绑定的管理员账户,不能被认领。", "auth_unavailable": "登录服务当前不可用。", diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index b0f090f..57548cc 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -350,6 +350,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/internal/op-login/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Show an in-game admin whose op.console login a request is (spec §B). + * @description Internal-only. velocity's /felis web op approve reads this and shows the admin the account, its address, and when and from where the sign-in was started, then asks them to confirm by typing the account name (see approve). The approver's online-mode UUID gets the same check as approve (a linked admin or owner, else 403 not_admin), since the command runs for any player and a staff address must not be readable by one. A request that is unknown, expired, approved or consumed is 404. + */ + get: operations["opLoginShow"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/internal/op-login/{id}/approve": { parameters: { query?: never; @@ -361,7 +381,7 @@ export interface paths { put?: never; /** * Record an in-game admin's vouch for a pending op.console login (spec §B). - * @description Internal-only second factor: velocity submits the online-mode UUID of the in-game admin running /felis web op approve. The API resolves it to a linked role=admin account (else 403 not_admin) and flips the request approved. A missing or no-longer-pending request is 404. Self-approval is allowed — an online staff member vouching as their own admin identity is a genuine second factor distinct from the mailbox. + * @description Internal-only second factor: velocity submits the online-mode UUID of the in-game admin running /felis web op approve , and the account name they typed after seeing the request (GET /api/v1/internal/op-login/{id}). The API resolves the UUID to a linked admin or owner account (else 403 not_admin), requires the typed name to match the request's account ignoring case (else 409 op_login_mismatch, audited, request left pending) and flips the request approved. A missing or no-longer-pending request is 404. Self-approval is allowed — an online staff member vouching as their own admin identity is a genuine second factor distinct from the mailbox. */ post: operations["opLoginApprove"]; delete?: never; @@ -3116,6 +3136,8 @@ export interface operations { request_id: string; username: string; email: string; + /** @description Where start was called from; empty on requests from before this was recorded. */ + client_ip: string; /** Format: date-time */ created_at: string; }[]; @@ -3125,33 +3147,37 @@ export interface operations { 401: components["responses"]["Unauthorized"]; }; }; - opLoginApprove: { + opLoginShow: { parameters: { - query?: never; + query: { + approver_uuid: string; + }; header?: never; path: { id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": { - /** Format: uuid */ - approver_uuid: string; - }; - }; - }; + requestBody?: never; responses: { - /** @description The vouch was recorded; the request is now approved. */ + /** @description The pending request and where it was started. */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - /** @constant */ - approved: true; + request_id: string; + username: string; + email: string; + /** @description Where start was called from; empty on requests from before this was recorded. */ + client_ip: string; + /** @description The browser's User-Agent at start, up to 256 bytes; may be empty. */ + user_agent: string; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + expires_at: string; }; }; }; @@ -3185,6 +3211,79 @@ export interface operations { }; }; }; + opLoginApprove: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: uuid */ + approver_uuid: string; + /** @description The account name the admin typed to confirm whose sign-in this is. */ + username: string; + }; + }; + }; + responses: { + /** @description The vouch was recorded; the request is now approved. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + approved: true; + username: string; + email: string; + }; + }; + }; + /** @description approver_uuid and username are required (bad_request). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description The approver is not a linked administrator (not_admin). */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description No pending operator login with that id (op_login_not_found). */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The typed name is not the request's account (op_login_mismatch). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; internalBackupNow: { parameters: { query?: never; diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java index a84ed5f..ed6f4d6 100644 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java @@ -179,30 +179,49 @@ public final class FelisApiClient { } /** - * opLoginApprove records an in-game administrator's vouch for a pending op.console - * staff login — the second factor of the spec §B op-login door, supplied from - * Velocity's {@code /felis web op approve }. It POSTs the approver's verified - * online-mode UUID to {@code POST /api/v1/internal/op-login/{id}/approve}; felis-api - * resolves that UUID to a linked account and refuses unless it is {@code role=admin} - * (403 {@code not_admin}), so this is defence in depth over Velocity's own in-game - * guard rather than the sole check. A {@code requestId} naming no live pending - * request is 404 {@code op_login_not_found}. Both arrive as branchable - * {@link LinkException}s; a 200 that does not affirm {@code approved:true} is a - * contract breach, not a refusal. + * opLoginShow reads whose op.console staff sign-in a pending request is — the first + * half of Velocity's {@code /felis web op approve }, shown to the admin before + * they vouch. {@code GET /api/v1/internal/op-login/{id}?approver_uuid=}: + * felis-api refuses unless the approver's verified UUID is linked to an admin or + * owner (403 {@code not_admin}), so a player who types the command learns nothing + * about a staff account. An id naming no live pending request is 404 {@code + * op_login_not_found}. Both arrive as branchable {@link LinkException}s. * *

{@code requestId} is interpolated into the request path. It is * percent-encoded here, and the Velocity command also validates its charset * before calling. */ - public void opLoginApprove(String requestId, UUID approverUuid) throws LinkException { + public OpLoginView opLoginShow(String requestId, UUID approverUuid) throws LinkException { Objects.requireNonNull(requestId, "requestId"); Objects.requireNonNull(approverUuid, "approverUuid"); - String body = "{\"approver_uuid\":\"" + approverUuid + "\"}"; + return OpLoginView.fromJson(getObject("/api/v1/internal/op-login/" + segment(requestId) + + "?approver_uuid=" + approverUuid, 200)); + } + + /** + * opLoginApprove records an in-game administrator's vouch for a pending op.console + * staff login — the second factor of the spec §B op-login door, supplied from + * Velocity's {@code /felis web op approve }. It POSTs the approver's + * verified online-mode UUID and the account name they typed after reading + * {@link #opLoginShow} to {@code POST /api/v1/internal/op-login/{id}/approve}. + * felis-api refuses unless the UUID is linked to an admin or owner (403 {@code + * not_admin}) and unless the name is the request's account (409 {@code + * op_login_mismatch}, request left pending). An id naming no live pending request is + * 404 {@code op_login_not_found}. All arrive as branchable {@link LinkException}s; a + * 200 that does not affirm {@code approved:true} is a contract breach, not a + * refusal. Returns the approved account's username and email. + */ + public OpLoginView opLoginApprove(String requestId, UUID approverUuid, String username) throws LinkException { + Objects.requireNonNull(requestId, "requestId"); + Objects.requireNonNull(approverUuid, "approverUuid"); + Objects.requireNonNull(username, "username"); + String body = "{\"approver_uuid\":\"" + approverUuid + "\",\"username\":" + Json.quote(username) + "}"; Map res = postObject("/api/v1/internal/op-login/" + segment(requestId) + "/approve", body, 200); Object approved = res.get("approved"); if (!(approved instanceof Boolean) || !((Boolean) approved)) { throw new LinkException(200, "bad_response", "approve returned 200 without approved=true"); } + return OpLoginView.fromJson(res); } /** diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/Json.java b/plugins/shared/src/main/java/best/lolicon/felis/link/Json.java index 000b901..fdbfcee 100644 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/Json.java +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/Json.java @@ -25,6 +25,32 @@ final class Json { this.s = s; } + /** + * quote renders s as a JSON string literal, for the few request bodies that carry + * text a player typed. Quotes, backslashes and control characters are escaped. + */ + static String quote(String s) { + StringBuilder b = new StringBuilder(s.length() + 2).append('"'); + for (int k = 0; k < s.length(); k++) { + char c = s.charAt(k); + switch (c) { + case '"': + b.append("\\\""); + break; + case '\\': + b.append("\\\\"); + break; + default: + if (c < 0x20) { + b.append(String.format("\\u%04x", (int) c)); + } else { + b.append(c); + } + } + } + return b.append('"').toString(); + } + /** parse reads a single JSON value from text, rejecting trailing garbage. */ static Object parse(String text) { Json p = new Json(text); diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/OpLoginView.java b/plugins/shared/src/main/java/best/lolicon/felis/link/OpLoginView.java new file mode 100644 index 0000000..fe6e86f --- /dev/null +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/OpLoginView.java @@ -0,0 +1,83 @@ +package best.lolicon.felis.link; + +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Map; + +/** + * OpLoginView is a pending op.console staff sign-in as the in-game approver sees it + * ({@code GET /api/v1/internal/op-login/{id}}, spec §B op-login): whose account it is, + * the address the code went to, and when and from where the sign-in was started. The + * approve response reuses it for the account it approved, where only the username + * and email are filled. + * + *

{@link #fromJson(Map)} tolerates absent fields like {@link ServerView}: a + * missing string reads as "", an unparseable time as null, so a partial body never + * throws on the proxy's command thread. + */ +public final class OpLoginView { + private final String requestId; + private final String username; + private final String email; + private final String clientIp; + private final String userAgent; + private final Instant createdAt; + + public OpLoginView(String requestId, String username, String email, + String clientIp, String userAgent, Instant createdAt) { + this.requestId = requestId; + this.username = username; + this.email = email; + this.clientIp = clientIp; + this.userAgent = userAgent; + this.createdAt = createdAt; + } + + /** fromJson builds a view from a parsed show or approve body. */ + public static OpLoginView fromJson(Map o) { + Instant created = null; + String at = str(o, "created_at"); + if (!at.isEmpty()) { + try { + created = Instant.parse(at); + } catch (DateTimeParseException ignored) { + // Leave it null; the card then omits the age line. + } + } + return new OpLoginView(str(o, "request_id"), str(o, "username"), str(o, "email"), + str(o, "client_ip"), str(o, "user_agent"), created); + } + + public String requestId() { + return requestId; + } + + /** username is the Felis account the sign-in is for; the approver retypes it. */ + public String username() { + return username; + } + + public String email() { + return email; + } + + /** clientIp is where the sign-in was started; "" when the API did not record one. */ + public String clientIp() { + return clientIp; + } + + /** userAgent is the browser that started the sign-in; may be "". */ + public String userAgent() { + return userAgent; + } + + /** createdAt is when the sign-in was started, or null when absent. */ + public Instant createdAt() { + return createdAt; + } + + private static String str(Map o, String key) { + Object v = o.get(key); + return v instanceof String ? (String) v : ""; + } +} diff --git a/plugins/shared/test/best/lolicon/felis/link/FelisApiClientTest.java b/plugins/shared/test/best/lolicon/felis/link/FelisApiClientTest.java index b8aaebd..c94be51 100644 --- a/plugins/shared/test/best/lolicon/felis/link/FelisApiClientTest.java +++ b/plugins/shared/test/best/lolicon/felis/link/FelisApiClientTest.java @@ -5,6 +5,7 @@ import com.sun.net.httpserver.HttpServer; import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.List; import java.util.UUID; import java.util.concurrent.CopyOnWriteArrayList; @@ -28,20 +29,35 @@ public final class FelisApiClientTest { private static int checks; private static final List seen = new CopyOnWriteArrayList<>(); + private static final List bodies = new CopyOnWriteArrayList<>(); public static void main(String[] args) throws Exception { HttpServer stub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); stub.createContext("/", exchange -> { - seen.add(exchange.getRequestMethod() + " " + exchange.getRequestURI().getRawPath()); + String query = exchange.getRequestURI().getRawQuery(); + seen.add(exchange.getRequestMethod() + " " + exchange.getRequestURI().getRawPath() + + (query == null ? "" : "?" + query)); + bodies.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); String path = exchange.getRequestURI().getRawPath(); String body; int status; if (path.endsWith("/wake")) { status = 202; body = "{\"name\":\"alpha\",\"phase\":\"Starting\",\"ready\":false}"; + } else if (path.equals("/api/v1/internal/op-login/mismatch/approve")) { + status = 409; + body = "{\"error\":{\"code\":\"op_login_mismatch\",\"message\":\"that operator login is for a different account\"}}"; + } else if (path.equals("/api/v1/internal/op-login/half/approve")) { + status = 200; + body = "{\"username\":\"op\"}"; } else if (path.endsWith("/approve")) { status = 200; - body = "{\"approved\":true}"; + body = "{\"approved\":true,\"username\":\"op\",\"email\":\"Op@Example.NET\"}"; + } else if (path.startsWith("/api/v1/internal/op-login/")) { + status = 200; + body = "{\"request_id\":\"0123abcd\",\"username\":\"op\",\"email\":\"Op@Example.NET\"," + + "\"client_ip\":\"203.0.113.9\",\"user_agent\":\"Firefox/140.0\"," + + "\"created_at\":\"2023-11-14T22:13:20Z\",\"expires_at\":\"2023-11-14T22:23:20Z\"}"; } else { status = 200; body = "{\"name\":\"alpha\",\"phase\":\"Running\",\"ready\":true,\"claimable\":false}"; @@ -60,6 +76,8 @@ public final class FelisApiClientTest { wellFormedNamesReachTheirRoute(api); pathBendingNamesNeverLeaveTheClient(api); opaqueSegmentsArePercentEncoded(api); + opLoginShowNamesTheAccount(api); + opLoginApproveSendsTheTypedName(api); } finally { stub.stop(0); } @@ -113,8 +131,53 @@ public final class FelisApiClientTest { expectRefused("dotdot", () -> FelisApiClient.segment("..")); seen.clear(); - api.opLoginApprove("0123abcd", UUID.fromString("00000000-0000-0000-0000-000000000003")); - assertEq("approve route", List.of("POST /api/v1/internal/op-login/0123abcd/approve"), seen); + api.opLoginApprove("0123abcd", UUID.fromString("00000000-0000-0000-0000-000000000003"), "op"); + api.opLoginShow("a/b", UUID.fromString("00000000-0000-0000-0000-000000000003")); + assertEq("op-login routes", List.of( + "POST /api/v1/internal/op-login/0123abcd/approve", + "GET /api/v1/internal/op-login/a%2Fb?approver_uuid=00000000-0000-0000-0000-000000000003"), seen); + } + + // The in-game card is built from this view, so every field the admin reads has to + // come through. + private static void opLoginShowNamesTheAccount(FelisApiClient api) throws LinkException { + seen.clear(); + OpLoginView v = api.opLoginShow("0123abcd", UUID.fromString("00000000-0000-0000-0000-000000000004")); + assertEq("show route", List.of( + "GET /api/v1/internal/op-login/0123abcd?approver_uuid=00000000-0000-0000-0000-000000000004"), seen); + assertEq("show request_id", "0123abcd", v.requestId()); + assertEq("show username", "op", v.username()); + assertEq("show email", "Op@Example.NET", v.email()); + assertEq("show client_ip", "203.0.113.9", v.clientIp()); + assertEq("show user_agent", "Firefox/140.0", v.userAgent()); + assertEq("show created_at", Instant.parse("2023-11-14T22:13:20Z"), v.createdAt()); + } + + // The typed name is player input, so it has to arrive as one JSON string however + // it is spelled; the approved account comes back for the confirmation line. + private static void opLoginApproveSendsTheTypedName(FelisApiClient api) throws LinkException { + UUID approver = UUID.fromString("00000000-0000-0000-0000-000000000005"); + bodies.clear(); + OpLoginView v = api.opLoginApprove("0123abcd", approver, "o\"p\\"); + assertEq("approve body", List.of( + "{\"approver_uuid\":\"00000000-0000-0000-0000-000000000005\",\"username\":\"o\\\"p\\\\\"}"), bodies); + assertEq("approved username", "op", v.username()); + assertEq("approved email", "Op@Example.NET", v.email()); + + try { + api.opLoginApprove("mismatch", approver, "alice"); + throw new AssertionError("a 409 approve was not refused"); + } catch (LinkException e) { + assertEq("mismatch status", 409, e.statusCode()); + assertEq("mismatch code", "op_login_mismatch", e.errorCode()); + } + try { + api.opLoginApprove("half", approver, "op"); + throw new AssertionError("a 200 without approved=true was accepted"); + } catch (LinkException e) { + assertEq("half status", 200, e.statusCode()); + assertEq("half code", "bad_response", e.errorCode()); + } } // ---- harness ---- diff --git a/plugins/test.sh b/plugins/test.sh index ee2225a..0376dbf 100644 --- a/plugins/test.sh +++ b/plugins/test.sh @@ -11,8 +11,10 @@ # felis-api request path, only the lobby and the login gate may drive # felis:control (and each only with its own frames), the link-status outage # fallback fails closed outside its window, /invite prompts cannot double-fire -# or outlive their TTL, and the invite card really is a green/red clickable -# prompt. InviteCardTest needs the adventure jars the velocity plugin compiles +# or outlive their TTL, the invite card really is a green/red clickable +# prompt, and the op-login approval card names the account and leaves its +# name for the admin to type. InviteCardTest and OpApprovalCardTest need the +# adventure jars the velocity plugin compiles # against; they are fetched from Maven Central below, pinned by version and # checked by digest (a test run against silently-substituted bytes is not a # test of what we ship). @@ -97,6 +99,15 @@ javac -cp "$adventure_api:$adventure_key:$examination_api" -d "$work/card-classe java -cp "$work/card-classes:$adventure_api:$adventure_key:$examination_api" \ best.lolicon.felis.velocity.InviteCardTest +echo "==> OpApprovalCardTest (/felis web op approve card, velocity)" +mkdir -p "$work/opcard-classes" +javac -cp "$adventure_api:$adventure_key:$examination_api" -d "$work/opcard-classes" \ + plugins/shared/src/main/java/best/lolicon/felis/link/*.java \ + plugins/velocity/src/main/java/best/lolicon/felis/velocity/OpApprovalCard.java \ + plugins/velocity/test/best/lolicon/felis/velocity/OpApprovalCardTest.java +java -cp "$work/opcard-classes:$adventure_api:$adventure_key:$examination_api" \ + best.lolicon.felis.velocity.OpApprovalCardTest + # --- 2. production compile gates ------------------------------------------------ for module in velocity paper; do diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java index ccb4d19..822b455 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java @@ -4,6 +4,7 @@ import best.lolicon.felis.link.FelisApiClient; import best.lolicon.felis.link.LinkClient; import best.lolicon.felis.link.LinkCode; import best.lolicon.felis.link.LinkException; +import best.lolicon.felis.link.OpLoginView; import best.lolicon.felis.link.ServerView; import com.google.inject.Inject; @@ -28,6 +29,7 @@ import org.slf4j.Logger; import java.nio.file.Path; import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.Locale; import java.util.Optional; @@ -291,7 +293,8 @@ public final class FelisVelocityPlugin { // /felis claim take ownership of the server I'm on // /felis migrate open a migration of my servers to another account // /felis web where the web consoles live - // /felis web op approve vouch for a pending op.console staff login (§B) + // /felis web op approve show whose op.console staff login a code is (§B) + // /felis web op approve vouch for it, naming the account // // Two guards run before any subcommand that acts or reveals operational state: // @@ -357,9 +360,16 @@ public final class FelisVelocityPlugin { .then(BrigadierCommand.literalArgumentBuilder("approve") .then(BrigadierCommand.requiredArgumentBuilder("code", StringArgumentType.word()) .executes(ctx -> { - doOpApprove(ctx.getSource(), StringArgumentType.getString(ctx, "code")); + doOpApprove(ctx.getSource(), StringArgumentType.getString(ctx, "code"), null); return Command.SINGLE_SUCCESS; - }))))) + }) + .then(BrigadierCommand.requiredArgumentBuilder("account", StringArgumentType.word()) + .executes(ctx -> { + doOpApprove(ctx.getSource(), + StringArgumentType.getString(ctx, "code"), + StringArgumentType.getString(ctx, "account")); + return Command.SINGLE_SUCCESS; + })))))) .build(); CommandMeta meta = commands.metaBuilder("felis").plugin(this).build(); commands.register(meta, new BrigadierCommand(node)); @@ -447,7 +457,7 @@ public final class FelisVelocityPlugin { helpLine(source, "/felis web", zh ? "网页控制台地址" : "where the web consoles live"); helpLine(source, "/felis web op approve ", - zh ? "批准待处理的管理员登录" : "approve a pending operator sign-in"); + zh ? "查看并批准待处理的管理员登录" : "review and approve a pending operator sign-in"); } private void sendServerList(CommandSource source) { @@ -641,8 +651,8 @@ public final class FelisVelocityPlugin { source.sendMessage(field(zh ? "管理员" : "operators", "https://" + adminHost)); } source.sendMessage(Component.text( - zh ? " 管理员:/felis web op approve 用于为待处理登录作担保" - : " operators: /felis web op approve vouches for a pending sign-in", + zh ? " 管理员:/felis web op approve 查看并批准待处理的登录" + : " operators: /felis web op approve reviews a pending sign-in", NamedTextColor.GRAY)); } @@ -661,12 +671,18 @@ public final class FelisVelocityPlugin { NamedTextColor.GRAY)); source.sendMessage(Component.text(" /felis web op approve ", NamedTextColor.WHITE)); source.sendMessage(Component.text( - zh ? "即可为其担保——你必须是已绑定并在线的管理员。" - : "to vouch for it — you must be an online, linked administrator.", + zh ? "查看这是谁的登录,确认后输入其账户名即可担保——你必须是已绑定并在线的管理员。" + : "to see whose sign-in it is, then confirm with their account name to vouch for it" + + " — you must be an online, linked administrator.", NamedTextColor.GRAY)); } - private void doOpApprove(CommandSource source, String codeArg) { + // doOpApprove is both halves of the in-game vouch. Without an account name it only + // shows whose sign-in the code belongs to (OpApprovalCard); with one it approves, + // and felis-api refuses unless the name is that request's account. The admin + // therefore always reads the account before vouching, and a code someone else + // relayed cannot be approved blind. + private void doOpApprove(CommandSource source, String codeArg, String accountArg) { Player player = requirePlayer(source); if (player == null || !ensureOutOfLimbo(player)) { return; @@ -688,17 +704,35 @@ public final class FelisVelocityPlugin { } UUID approver = player.getUniqueId(); String who = player.getUsername(); + if (accountArg == null) { + async(() -> { + try { + OpLoginView req = apiClient.opLoginShow(code, approver); + long age = req.createdAt() == null ? -1 + : Math.max(0, Duration.between(req.createdAt(), Instant.now()).getSeconds()); + for (Component line : OpApprovalCard.lines(req, code, zh, age)) { + player.sendMessage(line); + } + } catch (LinkException e) { + player.sendMessage(Component.text(opApproveError(e, code, zh), NamedTextColor.RED)); + } + }); + return; + } + String account = accountArg.trim(); player.sendMessage(Component.text( zh ? "正在批准管理员登录……" : "Approving operator sign-in…", NamedTextColor.GRAY)); async(() -> { try { - apiClient.opLoginApprove(code, approver); + OpLoginView done = apiClient.opLoginApprove(code, approver, account); player.sendMessage(Component.text( - zh ? "已批准——对方现在可以完成登录了。" - : "Approved — the operator can finish signing in now.", NamedTextColor.GREEN)); - logger.info("Felis: op-login {} approved in-game by {} ({})", code, who, approver); + zh ? "已批准 " + done.username() + "(" + done.email() + ")的登录——对方现在可以完成登录了。" + : "Approved " + done.username() + " (" + done.email() + + ") — they can finish signing in now.", NamedTextColor.GREEN)); + logger.info("Felis: op-login {} for {} approved in-game by {} ({})", + code, done.username(), who, approver); } catch (LinkException e) { - player.sendMessage(Component.text(opApproveError(e, zh), NamedTextColor.RED)); + player.sendMessage(Component.text(opApproveError(e, code, zh), NamedTextColor.RED)); } }); } @@ -1061,10 +1095,11 @@ public final class FelisVelocityPlugin { } } - // opApproveError maps the internal approve refusals to player-safe text. A 403 is - // the API's own admin re-check (defence in depth over the in-game gate); a 404 - // means no live pending request carries that code. - private static String opApproveError(LinkException e, boolean zh) { + // opApproveError maps the internal show/approve refusals to player-safe text. A 403 + // is the API's own admin re-check (defence in depth over the in-game gate); a 404 + // means no live pending request carries that code; a 409 means the typed account + // is not the one the request is for. + private static String opApproveError(LinkException e, String code, boolean zh) { switch (e.statusCode()) { case 403: return zh ? "只有已绑定的管理员才能批准管理员登录。" @@ -1072,6 +1107,10 @@ public final class FelisVelocityPlugin { case 404: return zh ? "没有携带该码的待处理管理员登录(可能已过期)。" : "No pending operator sign-in with that code (it may have expired)."; + case 409: + return zh ? "该登录属于另一个账户,未批准。运行 /felis web op approve " + code + " 查看是谁的登录。" + : "That sign-in is for a different account, so it was not approved. Run /felis web op approve " + + code + " to see whose it is."; case 0: return zh ? "Felis 暂时不可用——请稍后再试。" : "Felis is temporarily unavailable — please try again."; diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/OpApprovalCard.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/OpApprovalCard.java new file mode 100644 index 0000000..e1ead53 --- /dev/null +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/OpApprovalCard.java @@ -0,0 +1,89 @@ +package best.lolicon.felis.velocity; + +import best.lolicon.felis.link.OpLoginView; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import java.util.ArrayList; +import java.util.List; + +/** + * OpApprovalCard builds what an admin sees after {@code /felis web op approve }: + * whose op.console sign-in the code belongs to (account, address, where and when it + * was started), a warning to approve only a sign-in they know about, and how to + * confirm. Confirming means typing the account name, because a code relayed by + * someone else ("please approve abc123") is exactly the case this card exists to + * catch; the button fills the command up to the name and leaves the name to the + * admin. + * + *

A pure function of (request, code, language, age), like {@link InviteCard}, so + * {@code OpApprovalCardTest} can check it without a proxy. + */ +final class OpApprovalCard { + + static final String APPROVE_COMMAND = "/felis web op approve"; + + /** A user agent longer than this is cut, so one line of chat stays one line. */ + static final int USER_AGENT_MAX = 80; + + private OpApprovalCard() { + } + + /** + * lines renders the card in the approver's language. ageSeconds is how long ago the + * sign-in was started, or a negative value when the API did not say. + */ + static List lines(OpLoginView req, String code, boolean zh, long ageSeconds) { + List out = new ArrayList<>(); + out.add(Component.text(zh ? "待你批准的管理员登录" : "Operator sign-in waiting for your approval", + NamedTextColor.AQUA)); + out.add(field(zh ? "账户" : "account", req.username() + " (" + req.email() + ")")); + String from = req.clientIp().isEmpty() ? (zh ? "未知" : "unknown") : req.clientIp(); + if (!req.userAgent().isEmpty()) { + from += " · " + shorten(req.userAgent()); + } + out.add(field(zh ? "来源" : "from", from)); + if (ageSeconds >= 0) { + out.add(field(zh ? "发起" : "started", age(ageSeconds, zh))); + } + out.add(Component.text( + zh ? " 只在你确认此人正在登录时批准。有人私下发给你批准码时尤其要核对账户。" + : " Approve only if you know this person is signing in right now — " + + "above all when someone else sent you the code.", + NamedTextColor.YELLOW)); + + String prefill = APPROVE_COMMAND + " " + code + " "; + Component button = Component.text(zh ? "[ 批准… ]" : "[ Approve… ]", NamedTextColor.GREEN) + .clickEvent(ClickEvent.suggestCommand(prefill)) + .hoverEvent(HoverEvent.showText(Component.text( + zh ? "点击后在末尾输入上面的账户名,再按回车" + : "Click, then type the account name shown above and press Enter"))); + out.add(Component.text(" ").append(button).append(Component.text( + zh ? " 或输入 " + APPROVE_COMMAND + " " + code + " <账户名>" + : " or type " + APPROVE_COMMAND + " " + code + " ", + NamedTextColor.GRAY))); + return out; + } + + static String shorten(String userAgent) { + if (userAgent.length() <= USER_AGENT_MAX) { + return userAgent; + } + return userAgent.substring(0, USER_AGENT_MAX - 1) + "…"; + } + + static String age(long seconds, boolean zh) { + if (seconds < 60) { + return zh ? "刚刚" : "just now"; + } + long minutes = seconds / 60; + return zh ? minutes + " 分钟前" : minutes + " min ago"; + } + + private static Component field(String key, String value) { + return Component.text(" " + key + ": ", NamedTextColor.GRAY) + .append(Component.text(value, NamedTextColor.WHITE)); + } +} diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/OpApprovalCardTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/OpApprovalCardTest.java new file mode 100644 index 0000000..72f213b --- /dev/null +++ b/plugins/velocity/test/best/lolicon/felis/velocity/OpApprovalCardTest.java @@ -0,0 +1,149 @@ +package best.lolicon.felis.velocity; + +import best.lolicon.felis.link.OpLoginView; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** + * OpApprovalCardTest checks what an admin reads before vouching for an op.console + * sign-in: the account, its address, where and when the sign-in started, and a + * confirm button that leaves the account name for the admin to type. The name is the + * point — a button that filled it in would let a relayed code be approved without + * reading the card. + * + *

Hermetic and framework-free like {@link InviteCardTest}; it needs the shared link + * sources (for {@link OpLoginView}) and the Kyori jars. Run: {@code javac -cp + * :: -d + * shared/src/main/java/best/lolicon/felis/link/*.java + * velocity/src/main/java/best/lolicon/felis/velocity/OpApprovalCard.java + * velocity/test/best/lolicon/felis/velocity/OpApprovalCardTest.java && java -cp + * ::: + * best.lolicon.felis.velocity.OpApprovalCardTest}. + */ +public final class OpApprovalCardTest { + + private static int checks; + + private static final OpLoginView REQ = new OpLoginView("abc123", "alice", "alice@example.net", + "203.0.113.9", "Mozilla/5.0 Firefox/140.0", Instant.parse("2023-11-14T22:13:20Z")); + + public static void main(String[] args) { + cardNamesTheAccountAndOrigin(); + englishCardReadsTheSame(); + buttonLeavesTheNameToTheAdmin(); + missingOriginStillReads(); + longUserAgentIsCut(); + ageTurnsIntoMinutesAtSixty(); + System.out.println("OpApprovalCardTest OK (" + checks + " checks)"); + } + + private static void cardNamesTheAccountAndOrigin() { + List card = OpApprovalCard.lines(REQ, "abc123", true, 150); + assertEq("zh card", List.of( + "待你批准的管理员登录", + " 账户: alice (alice@example.net)", + " 来源: 203.0.113.9 · Mozilla/5.0 Firefox/140.0", + " 发起: 2 分钟前", + " 只在你确认此人正在登录时批准。有人私下发给你批准码时尤其要核对账户。", + " [ 批准… ] 或输入 /felis web op approve abc123 <账户名>"), text(card)); + assertEq("headline colour", NamedTextColor.AQUA, card.get(0).color()); + assertEq("warning colour", NamedTextColor.YELLOW, card.get(4).color()); + } + + private static void englishCardReadsTheSame() { + assertEq("en card", List.of( + "Operator sign-in waiting for your approval", + " account: alice (alice@example.net)", + " from: 203.0.113.9 · Mozilla/5.0 Firefox/140.0", + " started: just now", + " Approve only if you know this person is signing in right now — above all when someone else sent you the code.", + " [ Approve… ] or type /felis web op approve abc123 "), + text(OpApprovalCard.lines(REQ, "abc123", false, 59))); + } + + // The button only fills the command up to the name: clicking it must not approve, + // and must not type the name for the admin. + private static void buttonLeavesTheNameToTheAdmin() { + List card = OpApprovalCard.lines(REQ, "abc123", false, 0); + Component button = card.get(card.size() - 1).children().get(0); + assertEq("button colour", NamedTextColor.GREEN, button.color()); + // Action and text are compared as plain values: ClickEvent#toString needs + // examination-string, which is not on this test's classpath. + ClickEvent click = button.clickEvent(); + assertEq("button only fills the chat box", ClickEvent.Action.SUGGEST_COMMAND, click.action()); + assertEq("button fills the command up to the name", "/felis web op approve abc123 ", + ((ClickEvent.Payload.Text) click.payload()).value()); + assertEq("button hover", "Click, then type the account name shown above and press Enter", + flat(button.hoverEvent().value())); + } + + private static void missingOriginStillReads() { + OpLoginView bare = new OpLoginView("abc123", "alice", "alice@example.net", "", "", null); + assertEq("bare card", List.of( + "Operator sign-in waiting for your approval", + " account: alice (alice@example.net)", + " from: unknown", + " Approve only if you know this person is signing in right now — above all when someone else sent you the code.", + " [ Approve… ] or type /felis web op approve abc123 "), + text(OpApprovalCard.lines(bare, "abc123", false, -1))); + } + + private static void longUserAgentIsCut() { + String ua = "x".repeat(200); + OpLoginView req = new OpLoginView("abc123", "alice", "alice@example.net", "203.0.113.9", ua, null); + assertEq("from line", " from: 203.0.113.9 · " + "x".repeat(79) + "…", + text(OpApprovalCard.lines(req, "abc123", false, -1)).get(2)); + assertEq("short agent kept", "Firefox/140.0", OpApprovalCard.shorten("Firefox/140.0")); + assertEq("80 chars kept", "y".repeat(80), OpApprovalCard.shorten("y".repeat(80))); + } + + private static void ageTurnsIntoMinutesAtSixty() { + assertEq("59s", "just now", OpApprovalCard.age(59, false)); + assertEq("60s", "1 min ago", OpApprovalCard.age(60, false)); + assertEq("599s", "9 分钟前", OpApprovalCard.age(599, true)); + } + + // ---- harness ---- + + private static List text(List card) { + List out = new ArrayList<>(); + for (Component c : card) { + out.add(flat(c)); + } + return out; + } + + /** flat joins a component's text with its children's, depth first. */ + private static String flat(Object value) { + if (!(value instanceof Component)) { + return String.valueOf(value); + } + Component c = (Component) value; + StringBuilder sb = new StringBuilder(c instanceof TextComponent ? ((TextComponent) c).content() : ""); + for (Component child : c.children()) { + sb.append(flat(child)); + } + return sb.toString(); + } + + private static void assertEq(String what, Object want, Object got) { + if (want instanceof TextColor && got instanceof TextColor) { + if (((TextColor) want).value() != ((TextColor) got).value()) { + throw new AssertionError(what + " = " + got + ", want " + want); + } + checks++; + return; + } + if (want == null ? got != null : !want.equals(got)) { + throw new AssertionError(what + " = " + got + ", want " + want); + } + checks++; + } +}