feat(api): op-login 游戏内审批先展示目标账号、邮箱与发起来源,须输入账户名确认,velocity 显示审批卡片
This commit is contained in:
21 files changed
+1088
-176
No files matched your search
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user