Unverified Commit d93c1b69 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 47573533
Loading
Loading
Loading
Loading
+83 −9
Changes for docs/openapi.yaml: 83 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -1307,14 +1307,75 @@ 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}:
    get:
      tags: [account-internal]
      operationId: opLoginShow
      summary: Show an in-game admin whose op.console login a request is (spec §B).
      description: >
        Internal-only. velocity's /felis web op approve <code> 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 } }
        - { name: approver_uuid, in: query, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: The pending request and where it was started.
          content:
            application/json:
              schema:
                type: object
                required: [request_id, username, email, client_ip, user_agent, created_at, expires_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.
                  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:
            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' }

  /api/v1/internal/op-login/{id}/approve:
    post:
@@ -1323,11 +1384,14 @@ paths:
      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. 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.
        in-game admin running /felis web op approve <code> <username>, 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]
@@ -1340,9 +1404,12 @@ paths:
          application/json:
            schema:
              type: object
              required: [approver_uuid]
              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.
@@ -1350,11 +1417,13 @@ paths:
            application/json:
              schema:
                type: object
                required: [approved]
                required: [approved, username, email]
                properties:
                  approved: { type: boolean, const: true }
                  username: { type: string }
                  email: { type: string }
        '400':
          description: approver_uuid is required (bad_request).
          description: approver_uuid and username are required (bad_request).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
@@ -1370,6 +1439,11 @@ paths:
          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:
+1 −0
Changes for internal/api/api.go: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for internal/api/api_test.go: 16 added lines, 13 removed lines.
Original line number Diff line number Diff line
@@ -263,6 +263,8 @@ type fakeOpLogin struct {
	consumed  bool
	expiresAt time.Time
	createdAt time.Time
	clientIP  string
	userAgent string
}

// fakeSetupToken mirrors a setup_tokens row (spec §B setup): a one-time
@@ -1606,25 +1608,27 @@ func (f *fakeRepo) ConsumeLoginEmailOTP(_ context.Context, userID, purpose, code

// CreateOpLoginRequest records a fresh pending op.console login attempt. status is
// born 'pending'; createdAt orders the pending list (the PG ORDER BY created_at).
func (f *fakeRepo) CreateOpLoginRequest(_ context.Context, id, userID, email string, expiresAt time.Time) error {
	f.opLogins[id] = &fakeOpLogin{
		id: id, userID: userID, email: email, status: "pending",
		expiresAt: expiresAt, createdAt: expiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation
func (f *fakeRepo) CreateOpLoginRequest(_ context.Context, req NewOpLoginRequest) error {
	f.opLogins[req.ID] = &fakeOpLogin{
		id: req.ID, userID: req.UserID, email: req.Email, status: "pending",
		expiresAt: req.ExpiresAt, createdAt: req.ExpiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation
		clientIP: req.ClientIP, userAgent: req.UserAgent,
	}
	return nil
}

// OpLoginRequestByID loads a request by handle, projecting the fake row into the
// OpLoginRequest the status/finish paths read (Status, Consumed, ExpiresAt). Status
// is the (approved_at, denied_at) projection the handler gates on.
// OpLoginRequest the handlers read, with the username joined like the PG query.
// Status is the (approved_at, denied_at) projection the handler gates on.
func (f *fakeRepo) OpLoginRequestByID(_ context.Context, id string) (*OpLoginRequest, error) {
	r, ok := f.opLogins[id]
	if !ok {
		return nil, ErrNotFound
	}
	return &OpLoginRequest{
		ID: r.id, UserID: r.userID, Email: r.email, ExpiresAt: r.expiresAt,
		Status: r.status, Consumed: r.consumed,
		ID: r.id, UserID: r.userID, Username: f.usernameFor(r.userID), Email: r.email,
		ExpiresAt: r.expiresAt, CreatedAt: r.createdAt, Status: r.status, Consumed: r.consumed,
		ClientIP: r.clientIP, UserAgent: r.userAgent,
	}, nil
}

@@ -1644,8 +1648,7 @@ func (f *fakeRepo) ConsumeOpLoginRequest(_ context.Context, id string, now time.
// ListPendingOpLogins returns the live (pending, unconsumed, unexpired) requests
// oldest-first, mirroring the PG WHERE consumed_at IS NULL AND approved_at IS NULL
// AND expires_at > now ORDER BY created_at. Username is joined from the staff map
// (the in-game admin needs to name who is waiting), exactly as the repo.go contract
// documents — ListPendingOpLogins is the ONLY path that populates Username.
// (the in-game admin needs to name who is waiting).
func (f *fakeRepo) ListPendingOpLogins(_ context.Context, now time.Time) ([]OpLoginRequest, error) {
	var out []OpLoginRequest
	for _, r := range f.opLogins {
@@ -1655,6 +1658,7 @@ func (f *fakeRepo) ListPendingOpLogins(_ context.Context, now time.Time) ([]OpLo
		out = append(out, OpLoginRequest{
			ID: r.id, UserID: r.userID, Username: f.usernameFor(r.userID),
			Email: r.email, ExpiresAt: r.expiresAt, Status: "pending", CreatedAt: r.createdAt,
			ClientIP: r.clientIP, UserAgent: r.userAgent,
		})
	}
	sort.Slice(out, func(i, j int) bool {
@@ -1690,9 +1694,8 @@ func (f *fakeRepo) ConsumeSetupToken(_ context.Context, tokenHash string, now ti
	return tok.UserID, nil
}

// usernameFor joins a userID to its staff username (the ListPendingOpLogins
// projection the in-game admin needs to name who is waiting). "" when the user is
// gone — mirroring a missing JOIN row.
// usernameFor joins a userID to its staff username (the op-login projection the
// in-game admin needs to name who is waiting). "" when the user is gone.
func (f *fakeRepo) usernameFor(userID string) string {
	for _, u := range f.staff {
		if u.ID == userID {
+127 −36
Changes for internal/api/handlers_op_login.go: 127 added lines, 36 removed lines.
Original line number Diff line number Diff line
@@ -11,14 +11,15 @@ import (
// sensitive tier. Unlike the console.<root_domain> player doors (email OTP / bind
// code), a staff web session is never minted from a single factor. The flow is a
// three-call state machine over op_login_requests (migration 0016), all Public
// pre-session routes (the caller has no principal yet), plus two internal-face routes
// for the in-game side (approve is driven by velocity's /felis command; pending has
// no consumer yet — see handleOpLoginPending):
// pre-session routes (the caller has no principal yet), plus internal-face routes
// for the in-game side (show and approve are driven by velocity's /felis command;
// pending has no consumer yet — see handleOpLoginPending):
//
//	POST /api/v1/auth/op-login/start            (public)   — mint a request + mail an OTP
//	GET  /api/v1/auth/op-login/status/{id}      (public)   — poll until an admin approves
//	POST /api/v1/auth/op-login/finish           (public)   — redeem code+approval → session
//	GET  /api/v1/internal/op-login/pending      (internal) — list requests awaiting a vouch
//	GET  /api/v1/internal/op-login/{id}         (internal) — who a request is for, shown to the admin
//	POST /api/v1/internal/op-login/{id}/approve (internal) — an in-game admin vouches
//
// The two factors:
@@ -30,6 +31,9 @@ import (
//     request via velocity's /felis command (internal approve). The API's own user
//     table is the sole authority: only a UUID linked to a staff account may
//     approve (velocity's command runs for any player and relies on this check).
//     The admin first sees whose request it is (account, address, where it was
//     started) and approves by typing that account's name, so a code relayed by a
//     stranger ("please approve abc123") cannot be vouched for blind.
//
// finish mints the session only when BOTH have landed. Neither factor alone — a mailed
// code without an approval, or an approval without the code — yields a session.
@@ -173,7 +177,14 @@ func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) {
		writeError(w, r, err)
		return
	}
	if err := a.Repo.CreateOpLoginRequest(r.Context(), id, u.ID, u.Email, expiresAt); err != nil {
	origin := ""
	if addr := a.clientIP(r); addr.IsValid() {
		origin = addr.String()
	}
	if err := a.Repo.CreateOpLoginRequest(r.Context(), NewOpLoginRequest{
		ID: id, UserID: u.ID, Email: u.Email, ExpiresAt: expiresAt,
		ClientIP: origin, UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent),
	}); err != nil {
		writeError(w, r, err)
		return
	}
@@ -375,26 +386,106 @@ func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
			"request_id": req.ID,
			"username":   req.Username,
			"email":      req.Email,
			"client_ip":  req.ClientIP,
			"created_at": req.CreatedAt.UTC(),
		})
	}
	writeJSON(w, http.StatusOK, map[string]any{"pending": out})
}

// opLoginApprover resolves the in-game player running /felis web op approve to a
// linked staff account (admin, or the owner superset). An unlinked UUID or a
// non-staff player may never see or vouch for an op.console login; all refusals
// share one 403 so a caller cannot tell "not linked" from "linked but not staff".
func (a *API) opLoginApprover(r *http.Request, mcUUID string) (*StaffUser, error) {
	notAdmin := newError(http.StatusForbidden, "not_admin", "only a linked administrator may approve an operator login")
	approverID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID)
	switch {
	case errors.Is(err, ErrNotFound):
		return nil, notAdmin
	case err != nil:
		return nil, err
	}
	approver, err := a.Repo.UserByID(r.Context(), approverID)
	switch {
	case errors.Is(err, ErrNotFound):
		return nil, notAdmin
	case err != nil:
		return nil, err
	}
	if !staffRole(approver.Role) {
		return nil, notAdmin
	}
	return approver, nil
}

// pendingOpLogin loads a request an admin may still vouch for: pending, unconsumed
// and unexpired. Anything else is the same 404 the approve race returns.
func (a *API) pendingOpLogin(r *http.Request, id string) (*OpLoginRequest, error) {
	notFound := newError(http.StatusNotFound, "op_login_not_found", "no pending operator login with that id")
	req, err := a.Repo.OpLoginRequestByID(r.Context(), id)
	switch {
	case errors.Is(err, ErrNotFound):
		return nil, notFound
	case err != nil:
		return nil, err
	}
	if req.Status != "pending" || req.Consumed || !req.ExpiresAt.After(a.now()) {
		return nil, notFound
	}
	return req, nil
}

// handleOpLoginShow tells the in-game admin who a pending request is for before
// they vouch (internal face): the account, its address, when and from where the
// sign-in was started. velocity's /felis web op approve <code> renders this and
// asks the admin to confirm by name. The approver UUID rides in the query and gets
// the same staff check as approve, since velocity's command runs for any player and
// a staff address must not be readable by one.
func (a *API) handleOpLoginShow(w http.ResponseWriter, r *http.Request) {
	approverUUID := strings.TrimSpace(r.URL.Query().Get("approver_uuid"))
	if approverUUID == "" {
		writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid is required"))
		return
	}
	if _, err := a.opLoginApprover(r, approverUUID); err != nil {
		writeError(w, r, err)
		return
	}
	req, err := a.pendingOpLogin(r, r.PathValue("id"))
	if err != nil {
		writeError(w, r, err)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"request_id": req.ID,
		"username":   req.Username,
		"email":      req.Email,
		"client_ip":  req.ClientIP,
		"user_agent": req.UserAgent,
		"created_at": req.CreatedAt.UTC(),
		"expires_at": req.ExpiresAt.UTC(),
	})
}

// opLoginApproveRequest is the internal approve body: the online-mode UUID of the
// in-game admin running /felis web op approve. The API resolves it to a linked account
// and refuses unless that account is staff (admin or owner) — this check against the API's
// authoritative user table is the only gate; velocity's command itself is unprivileged.
// in-game admin running /felis web op approve, and the account name they typed to
// confirm whose sign-in they are vouching for. The API resolves the UUID to a
// linked account and refuses unless that account is staff (admin or owner) — this
// check against the API's authoritative user table is the only gate; velocity's
// command itself is unprivileged.
type opLoginApproveRequest struct {
	ApproverUUID string `json:"approver_uuid"`
	Username     string `json:"username"`
}

// handleOpLoginApprove records an in-game admin's vouch for a pending staff login
// (internal face), supplying the second factor. It resolves the approver UUID to a
// linked staff account (admin or owner; else 403), then flips the request approved.
// A missing or no-longer-pending request is 404. Self-approval is allowed: a staff
// member online as their own admin identity supplies a genuine second factor
// (in-game session control) distinct from the mailbox factor.
// linked staff account (admin or owner; else 403), requires the typed username to
// name the request's account (else 409, request left pending), then flips the
// request approved. A missing or no-longer-pending request is 404. Self-approval is
// allowed: a staff member online as their own admin identity supplies a genuine
// second factor (in-game session control) distinct from the mailbox factor.
func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	var req opLoginApproveRequest
@@ -403,38 +494,33 @@ func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) {
		return
	}
	approverUUID := strings.TrimSpace(req.ApproverUUID)
	if approverUUID == "" {
		writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid is required"))
	typed := strings.TrimSpace(req.Username)
	if approverUUID == "" || typed == "" {
		writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid and username are required"))
		return
	}
	// Resolve the in-game approver to a linked account and require a staff role
	// (admin, or the owner superset). An unlinked UUID or a non-staff player may
	// never vouch for an op.console login. All three refusals share one response so
	// a caller cannot tell "not linked" from "linked but not staff".
	notAdmin := newError(http.StatusForbidden, "not_admin", "only a linked administrator may approve an operator login")
	approverID, err := a.Repo.UserByMCUUID(r.Context(), approverUUID)
	switch {
	case errors.Is(err, ErrNotFound):
		writeError(w, r, notAdmin)
		return
	case err != nil:
	approver, err := a.opLoginApprover(r, approverUUID)
	if err != nil {
		writeError(w, r, err)
		return
	}
	approver, err := a.Repo.UserByID(r.Context(), approverID)
	switch {
	case errors.Is(err, ErrNotFound):
		writeError(w, r, notAdmin)
		return
	case err != nil:
	loginReq, err := a.pendingOpLogin(r, id)
	if err != nil {
		writeError(w, r, err)
		return
	}
	if !staffRole(approver.Role) {
		writeError(w, r, notAdmin)
	// Minecraft names are case-insensitive, and so is the name an admin retypes.
	if !strings.EqualFold(typed, loginReq.Username) {
		payload, _ := json.Marshal(map[string]string{"request_id": id, "typed_username": typed})
		a.auditEntry(r, AuditEntry{
			Actor: approver.Username, ActorUserID: approver.ID, Source: internalSource(r),
			Action: "auth.op_login.approve_mismatch", Payload: payload,
		})
		writeError(w, r, newError(http.StatusConflict, "op_login_mismatch",
			"that operator login is for a different account"))
		return
	}
	switch err := a.Repo.ApproveOpLogin(r.Context(), id, approverID, a.now()); {
	switch err := a.Repo.ApproveOpLogin(r.Context(), id, approver.ID, a.now()); {
	case errors.Is(err, ErrNotFound):
		writeError(w, r, newError(http.StatusNotFound, "op_login_not_found", "no pending operator login with that id"))
		return
@@ -442,10 +528,15 @@ func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) {
		writeError(w, r, err)
		return
	}
	payload, _ := json.Marshal(map[string]string{"request_id": id, "approver_user_id": approverID})
	payload, _ := json.Marshal(map[string]string{
		"request_id": id, "approver_user_id": approver.ID,
		"username": loginReq.Username, "client_ip": loginReq.ClientIP,
	})
	a.auditEntry(r, AuditEntry{
		Actor: approver.Username, ActorUserID: approverID, Source: internalSource(r),
		Actor: approver.Username, ActorUserID: approver.ID, Source: internalSource(r),
		Action: "auth.op_login.approved", Payload: payload,
	})
	writeJSON(w, http.StatusOK, map[string]any{"approved": true})
	writeJSON(w, http.StatusOK, map[string]any{
		"approved": true, "username": loginReq.Username, "email": loginReq.Email,
	})
}
+149 −19

File changed.

Preview size limit exceeded, changes collapsed.

Loading