feat(api): record account-link auth source (mojang|thirdparty)
Capture which Yggdrasil authenticated an in-game UUID when a link code is minted (spec §10 dual-Yggdrasil) and copy it onto the durable account_links row at verify. The value originates in-game — the web verify side never sees the authentication — so it threads through account_link_codes, mirroring how mc_uuid (not user_id) lives on a code. - migration 0005: add link_auth_source enum + auth_source column on both account_link_codes and account_links; DEFAULT 'mojang' backfills existing rows and sets the Mojang-priority default for a mint that omits the field - mint validates an explicit auth_source (unknown value -> 400); verify surfaces it in the 200 body and refreshes it on idempotent re-verify
This commit is contained in:
7 files changed
+179
-48
No files matched your search
+14
-1
@@ -643,6 +643,15 @@ paths:
|
|||||||
required: [mc_uuid]
|
required: [mc_uuid]
|
||||||
properties:
|
properties:
|
||||||
mc_uuid: { type: string }
|
mc_uuid: { type: string }
|
||||||
|
auth_source:
|
||||||
|
type: string
|
||||||
|
enum: [mojang, thirdparty]
|
||||||
|
default: mojang
|
||||||
|
description: >
|
||||||
|
Which Yggdrasil authenticated the in-game UUID (spec §10
|
||||||
|
dual-Yggdrasil). Optional; an omitted value defaults to the
|
||||||
|
Mojang-priority source. Captured here because only the in-game
|
||||||
|
side sees the authentication; it is copied onto the link at verify.
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Code minted.
|
description: Code minted.
|
||||||
@@ -1427,10 +1436,14 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [linked, mc_uuid]
|
required: [linked, mc_uuid, auth_source]
|
||||||
properties:
|
properties:
|
||||||
linked: { type: boolean, const: true }
|
linked: { type: boolean, const: true }
|
||||||
mc_uuid: { type: string }
|
mc_uuid: { type: string }
|
||||||
|
auth_source:
|
||||||
|
type: string
|
||||||
|
enum: [mojang, thirdparty]
|
||||||
|
description: The source captured at mint, copied onto the durable link.
|
||||||
'400':
|
'400':
|
||||||
description: Invalid or expired code.
|
description: Invalid or expired code.
|
||||||
content:
|
content:
|
||||||
|
|||||||
+20
-13
@@ -39,6 +39,9 @@ type fakeRepo struct {
|
|||||||
// account linking (spec §10)
|
// account linking (spec §10)
|
||||||
linkCodes map[string]fakeLinkCode // code -> pending binding
|
linkCodes map[string]fakeLinkCode // code -> pending binding
|
||||||
links map[string]string // mc_uuid -> user_id (mirrors UNIQUE(mc_uuid))
|
links map[string]string // mc_uuid -> user_id (mirrors UNIQUE(mc_uuid))
|
||||||
|
// linkAuthSource mirrors account_links.auth_source (mc_uuid -> mojang|thirdparty),
|
||||||
|
// the value copied from the consumed code at verify (migration 0005).
|
||||||
|
linkAuthSource map[string]string
|
||||||
// world backups (spec §7, §22). A nil slice lists empty.
|
// world backups (spec §7, §22). A nil slice lists empty.
|
||||||
backups []fakeBackup
|
backups []fakeBackup
|
||||||
// local-password auth (spec §B). staff is keyed by username (the login key);
|
// local-password auth (spec §B). staff is keyed by username (the login key);
|
||||||
@@ -85,8 +88,9 @@ type fakeBackup struct {
|
|||||||
|
|
||||||
// fakeLinkCode mirrors an account_link_codes row.
|
// fakeLinkCode mirrors an account_link_codes row.
|
||||||
type fakeLinkCode struct {
|
type fakeLinkCode struct {
|
||||||
mcUUID string
|
mcUUID string
|
||||||
expiresAt time.Time
|
authSource string
|
||||||
|
expiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeRepo() *fakeRepo {
|
func newFakeRepo() *fakeRepo {
|
||||||
@@ -98,10 +102,11 @@ func newFakeRepo() *fakeRepo {
|
|||||||
claimOK: map[string]bool{},
|
claimOK: map[string]bool{},
|
||||||
seeded: map[string]bool{}, aliases: map[string]string{},
|
seeded: map[string]bool{}, aliases: map[string]string{},
|
||||||
linkCodes: map[string]fakeLinkCode{}, links: map[string]string{},
|
linkCodes: map[string]fakeLinkCode{}, links: map[string]string{},
|
||||||
staff: map[string]*StaffUser{},
|
linkAuthSource: map[string]string{},
|
||||||
sessions: map[string]*fakeSession{},
|
staff: map[string]*StaffUser{},
|
||||||
settings: map[string][]byte{},
|
sessions: map[string]*fakeSession{},
|
||||||
otps: map[string]*fakeEmailOTP{},
|
settings: map[string][]byte{},
|
||||||
|
otps: map[string]*fakeEmailOTP{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,27 +124,29 @@ func (f *fakeRepo) ServerByName(_ context.Context, n string) (*ServerRecord, err
|
|||||||
}
|
}
|
||||||
func (f *fakeRepo) IsLinked(_ context.Context, u string) (bool, error) { return f.linked[u], nil }
|
func (f *fakeRepo) IsLinked(_ context.Context, u string) (bool, error) { return f.linked[u], nil }
|
||||||
func (f *fakeRepo) QuotaAvailable(_ context.Context, u string) (bool, error) { return f.quota[u], nil }
|
func (f *fakeRepo) QuotaAvailable(_ context.Context, u string) (bool, error) { return f.quota[u], nil }
|
||||||
func (f *fakeRepo) CreateLinkCode(_ context.Context, code, mcUUID string, expiresAt time.Time) error {
|
func (f *fakeRepo) CreateLinkCode(_ context.Context, code, mcUUID, authSource string, expiresAt time.Time) error {
|
||||||
f.linkCodes[code] = fakeLinkCode{mcUUID: mcUUID, expiresAt: expiresAt}
|
f.linkCodes[code] = fakeLinkCode{mcUUID: mcUUID, authSource: authSource, expiresAt: expiresAt}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyLinkCode mirrors PGRepo.VerifyLinkCode exactly so the hermetic tests
|
// VerifyLinkCode mirrors PGRepo.VerifyLinkCode exactly so the hermetic tests
|
||||||
// exercise the same contract the integration impl honors: strict expiry against
|
// exercise the same contract the integration impl honors: strict expiry against
|
||||||
// the passed clock, a different-user UUID → ErrConflict WITHOUT consuming the
|
// the passed clock, a different-user UUID → ErrConflict WITHOUT consuming the
|
||||||
// code, same (user, uuid) idempotent, and the code consumed only on success.
|
// code, same (user, uuid) idempotent, the code's auth_source copied onto the link
|
||||||
func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now time.Time) (string, error) {
|
// (and refreshed on re-verify), and the code consumed only on success.
|
||||||
|
func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now time.Time) (string, string, error) {
|
||||||
rec, ok := f.linkCodes[code]
|
rec, ok := f.linkCodes[code]
|
||||||
if !ok || !rec.expiresAt.After(now) {
|
if !ok || !rec.expiresAt.After(now) {
|
||||||
return "", ErrLinkCodeInvalid
|
return "", "", ErrLinkCodeInvalid
|
||||||
}
|
}
|
||||||
if existing, ok := f.links[rec.mcUUID]; ok && existing != userID {
|
if existing, ok := f.links[rec.mcUUID]; ok && existing != userID {
|
||||||
return "", ErrConflict // do not consume another user's pending code
|
return "", "", ErrConflict // do not consume another user's pending code
|
||||||
}
|
}
|
||||||
f.links[rec.mcUUID] = userID
|
f.links[rec.mcUUID] = userID
|
||||||
|
f.linkAuthSource[rec.mcUUID] = rec.authSource // copy/refresh, mirrors DO UPDATE
|
||||||
f.linked[userID] = true
|
f.linked[userID] = true
|
||||||
delete(f.linkCodes, code)
|
delete(f.linkCodes, code)
|
||||||
return rec.mcUUID, nil
|
return rec.mcUUID, rec.authSource, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateEmailOTP / VerifyEmailOTP mirror PGRepo's contract so the hermetic tests
|
// CreateEmailOTP / VerifyEmailOTP mirror PGRepo's contract so the hermetic tests
|
||||||
|
|||||||
@@ -34,8 +34,23 @@ const (
|
|||||||
// linkCodeLen is the symbol count: a 32^8 ≈ 1.1e12 keyspace, far beyond brute
|
// linkCodeLen is the symbol count: a 32^8 ≈ 1.1e12 keyspace, far beyond brute
|
||||||
// force inside the TTL.
|
// force inside the TTL.
|
||||||
linkCodeLen = 8
|
linkCodeLen = 8
|
||||||
|
|
||||||
|
// authSource records which Yggdrasil established the in-game UUID when a code
|
||||||
|
// was minted (spec §10, dual-Yggdrasil): the official Mojang service, or a
|
||||||
|
// configured thirdparty. It is captured at mint (the only place that knows it)
|
||||||
|
// and copied onto the durable link at verify; the web side never sees the
|
||||||
|
// authentication. These mirror the link_auth_source enum (migration 0005).
|
||||||
|
authSourceMojang = "mojang"
|
||||||
|
authSourceThirdParty = "thirdparty"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// validAuthSource reports whether s is a recognised link_auth_source value. An
|
||||||
|
// empty string is NOT valid here — handleCreateLinkCode defaults it before this
|
||||||
|
// check, so a non-empty value reaching validation must be one we can store.
|
||||||
|
func validAuthSource(s string) bool {
|
||||||
|
return s == authSourceMojang || s == authSourceThirdParty
|
||||||
|
}
|
||||||
|
|
||||||
// newLinkCode returns a cryptographically random, unambiguous link code.
|
// newLinkCode returns a cryptographically random, unambiguous link code.
|
||||||
func newLinkCode() (string, error) {
|
func newLinkCode() (string, error) {
|
||||||
buf := make([]byte, linkCodeLen)
|
buf := make([]byte, linkCodeLen)
|
||||||
@@ -49,9 +64,13 @@ func newLinkCode() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// createLinkCodeRequest is the in-game /link callback body (spec §10): the
|
// createLinkCodeRequest is the in-game /link callback body (spec §10): the
|
||||||
// backend reports the verified UUID of the player who ran the command.
|
// backend reports the verified UUID of the player who ran the command, plus how
|
||||||
|
// that UUID was authenticated (auth_source). auth_source is optional — an older
|
||||||
|
// backend that omits it falls back to the Mojang-priority default — but a value
|
||||||
|
// that IS sent must be one we can store.
|
||||||
type createLinkCodeRequest struct {
|
type createLinkCodeRequest struct {
|
||||||
MCUUID string `json:"mc_uuid"`
|
MCUUID string `json:"mc_uuid"`
|
||||||
|
AuthSource string `json:"auth_source"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCreateLinkCode mints a one-time link code for a verified in-game UUID
|
// handleCreateLinkCode mints a one-time link code for a verified in-game UUID
|
||||||
@@ -69,13 +88,25 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Default an omitted source to Mojang (spec §10 priority) but reject an
|
||||||
|
// unrecognised one — a typo'd source must not silently land as a stored value
|
||||||
|
// the panel will later mislabel.
|
||||||
|
authSource := req.AuthSource
|
||||||
|
if authSource == "" {
|
||||||
|
authSource = authSourceMojang
|
||||||
|
}
|
||||||
|
if !validAuthSource(authSource) {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
||||||
|
"auth_source must be %q or %q", authSourceMojang, authSourceThirdParty))
|
||||||
|
return
|
||||||
|
}
|
||||||
code, err := newLinkCode()
|
code, err := newLinkCode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
expiresAt := a.now().Add(linkCodeTTL)
|
expiresAt := a.now().Add(linkCodeTTL)
|
||||||
if err := a.Repo.CreateLinkCode(r.Context(), code, req.MCUUID, expiresAt); err != nil {
|
if err := a.Repo.CreateLinkCode(r.Context(), code, req.MCUUID, authSource, expiresAt); err != nil {
|
||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -110,7 +141,7 @@ func (a *API) handleLinkVerify(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required"))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mcUUID, err := a.Repo.VerifyLinkCode(r.Context(), p.UserID, code, a.now())
|
mcUUID, authSource, err := a.Repo.VerifyLinkCode(r.Context(), p.UserID, code, a.now())
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrLinkCodeInvalid):
|
case errors.Is(err, ErrLinkCodeInvalid):
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "link code is invalid or expired"))
|
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "link code is invalid or expired"))
|
||||||
@@ -124,7 +155,9 @@ func (a *API) handleLinkVerify(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.audit(r, p.Email, "account.link", "")
|
a.audit(r, p.Email, "account.link", "")
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"linked": true, "mc_uuid": mcUUID})
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"linked": true, "mc_uuid": mcUUID, "auth_source": authSource,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleLinkStart reports the caller's link status and how to link (spec §10,
|
// handleLinkStart reports the caller's link status and how to link (spec §10,
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ func TestAccountLinkVertical(t *testing.T) {
|
|||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
if b := acctBody(t, w); b["linked"] != true || b["mc_uuid"] != mcUUID {
|
if b := acctBody(t, w); b["linked"] != true || b["mc_uuid"] != mcUUID || b["auth_source"] != authSourceMojang {
|
||||||
t.Fatalf("verify body = %v, want linked:true mc_uuid:%s", b, mcUUID)
|
t.Fatalf("verify body = %v, want linked:true mc_uuid:%s auth_source:%s", b, mcUUID, authSourceMojang)
|
||||||
}
|
}
|
||||||
// The link is audited as account.link by the principal's Access email.
|
// The link is audited as account.link by the principal's Access email.
|
||||||
if n := len(repo.audits); n != 1 || repo.audits[0].Action != "account.link" || repo.audits[0].Actor != "[email protected]" {
|
if n := len(repo.audits); n != 1 || repo.audits[0].Action != "account.link" || repo.audits[0].Actor != "[email protected]" {
|
||||||
@@ -107,6 +107,10 @@ func TestCreateLinkCode(t *testing.T) {
|
|||||||
if rec.mcUUID != mcUUID {
|
if rec.mcUUID != mcUUID {
|
||||||
t.Errorf("stored mc_uuid = %q, want %q", rec.mcUUID, mcUUID)
|
t.Errorf("stored mc_uuid = %q, want %q", rec.mcUUID, mcUUID)
|
||||||
}
|
}
|
||||||
|
// An omitted auth_source defaults to the Mojang-priority source.
|
||||||
|
if rec.authSource != authSourceMojang {
|
||||||
|
t.Errorf("default authSource = %q, want %q", rec.authSource, authSourceMojang)
|
||||||
|
}
|
||||||
if want := api.now().Add(linkCodeTTL); !rec.expiresAt.Equal(want) {
|
if want := api.now().Add(linkCodeTTL); !rec.expiresAt.Equal(want) {
|
||||||
t.Errorf("expiresAt = %v, want %v", rec.expiresAt, want)
|
t.Errorf("expiresAt = %v, want %v", rec.expiresAt, want)
|
||||||
}
|
}
|
||||||
@@ -116,6 +120,24 @@ func TestCreateLinkCode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
t.Run("explicit thirdparty is stored", func(t *testing.T) {
|
||||||
|
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}`
|
||||||
|
w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
code, _ := acctBody(t, w)["code"].(string)
|
||||||
|
if rec := repo.linkCodes[code]; rec.authSource != authSourceThirdParty {
|
||||||
|
t.Errorf("stored authSource = %q, want %q", rec.authSource, authSourceThirdParty)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("unrecognised auth_source -> 400", func(t *testing.T) {
|
||||||
|
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"litebans"}`
|
||||||
|
w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil)
|
||||||
|
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
|
||||||
|
t.Fatalf("code = %d body %s, want 400 bad_request", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLinkVerifyRejections is the verify failure matrix. The expired case seeds a
|
// TestLinkVerifyRejections is the verify failure matrix. The expired case seeds a
|
||||||
@@ -206,6 +228,37 @@ func TestLinkVerifyIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLinkAuthSourcePropagates proves auth_source survives the whole §10 flow: a
|
||||||
|
// thirdparty source captured in-game at mint reaches the durable link and the
|
||||||
|
// verify response — the value the web side can never originate itself.
|
||||||
|
func TestLinkAuthSourcePropagates(t *testing.T) {
|
||||||
|
const mcUUID = "55555555-5555-5555-5555-555555555555"
|
||||||
|
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
|
||||||
|
repo := newFakeRepo()
|
||||||
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
|
api.External = staticExternal{p: user}
|
||||||
|
|
||||||
|
// Mint in-game with the thirdparty Yggdrasil source.
|
||||||
|
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}`
|
||||||
|
w := do(api.InternalHandler(), "POST", "/api/v1/internal/account/link/code", body, nil)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("mint: code = %d, want 201 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
code, _ := acctBody(t, w)["code"].(string)
|
||||||
|
|
||||||
|
// Verify on the web: the response and the stored link must both carry thirdparty.
|
||||||
|
w = do(api.ExternalHandler(), "POST", "/api/v1/account/link/verify", `{"code":"`+code+`"}`, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := acctBody(t, w)["auth_source"]; got != authSourceThirdParty {
|
||||||
|
t.Errorf("verify body auth_source = %v, want %q", got, authSourceThirdParty)
|
||||||
|
}
|
||||||
|
if got := repo.linkAuthSource[mcUUID]; got != authSourceThirdParty {
|
||||||
|
t.Errorf("stored link auth_source = %q, want %q", got, authSourceThirdParty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestLinkStart pins the status endpoint handleClaim's 412 points at: it reports
|
// TestLinkStart pins the status endpoint handleClaim's 412 points at: it reports
|
||||||
// link state and instructions, and never mints (it has no UUID to mint against).
|
// link state and instructions, and never mints (it has no UUID to mint against).
|
||||||
func TestLinkStart(t *testing.T) {
|
func TestLinkStart(t *testing.T) {
|
||||||
|
|||||||
+23
-18
@@ -58,10 +58,10 @@ func (p *PGRepo) IsLinked(ctx context.Context, userID string) (bool, error) {
|
|||||||
// driven by one authoritative clock. The code is a PRIMARY KEY; a collision on
|
// driven by one authoritative clock. The code is a PRIMARY KEY; a collision on
|
||||||
// the crypto/rand value is astronomically unlikely but surfaces as a plain
|
// the crypto/rand value is astronomically unlikely but surfaces as a plain
|
||||||
// driver error (the caller can retry) rather than being masked here.
|
// driver error (the caller can retry) rather than being masked here.
|
||||||
func (p *PGRepo) CreateLinkCode(ctx context.Context, code, mcUUID string, expiresAt time.Time) error {
|
func (p *PGRepo) CreateLinkCode(ctx context.Context, code, mcUUID, authSource string, expiresAt time.Time) error {
|
||||||
_, err := p.db.ExecContext(ctx,
|
_, err := p.db.ExecContext(ctx,
|
||||||
`INSERT INTO account_link_codes (code, mc_uuid, expires_at) VALUES ($1, $2, $3)`,
|
`INSERT INTO account_link_codes (code, mc_uuid, auth_source, expires_at) VALUES ($1, $2, $3, $4)`,
|
||||||
code, mcUUID, expiresAt)
|
code, mcUUID, authSource, expiresAt)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,21 +73,21 @@ func (p *PGRepo) CreateLinkCode(ctx context.Context, code, mcUUID string, expire
|
|||||||
// owner's pending code. The UNIQUE(mc_uuid) constraint is the last-resort guard
|
// owner's pending code. The UNIQUE(mc_uuid) constraint is the last-resort guard
|
||||||
// against a concurrent racer that passed the SELECT; that loses to a 500, which
|
// against a concurrent racer that passed the SELECT; that loses to a 500, which
|
||||||
// is acceptable for this integration-only path.
|
// is acceptable for this integration-only path.
|
||||||
func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (string, error) {
|
func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (string, string, error) {
|
||||||
tx, err := p.db.BeginTx(ctx, nil)
|
tx, err := p.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
defer tx.Rollback() //nolint:errcheck // no-op after commit
|
defer tx.Rollback() //nolint:errcheck // no-op after commit
|
||||||
|
|
||||||
var mcUUID string
|
var mcUUID, authSource string
|
||||||
switch err := tx.QueryRowContext(ctx,
|
switch err := tx.QueryRowContext(ctx,
|
||||||
`SELECT mc_uuid FROM account_link_codes WHERE code = $1 AND expires_at > $2`,
|
`SELECT mc_uuid, auth_source FROM account_link_codes WHERE code = $1 AND expires_at > $2`,
|
||||||
code, now).Scan(&mcUUID); {
|
code, now).Scan(&mcUUID, &authSource); {
|
||||||
case errors.Is(err, sql.ErrNoRows):
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
return "", ErrLinkCodeInvalid
|
return "", "", ErrLinkCodeInvalid
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this UUID is already linked, only the same user may re-verify (idempotent);
|
// If this UUID is already linked, only the same user may re-verify (idempotent);
|
||||||
@@ -98,26 +98,31 @@ func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now ti
|
|||||||
case errors.Is(err, sql.ErrNoRows):
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
// not yet linked — fall through to insert
|
// not yet linked — fall through to insert
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return "", err
|
return "", "", err
|
||||||
default:
|
default:
|
||||||
if existingUser != userID {
|
if existingUser != userID {
|
||||||
return "", ErrConflict
|
return "", "", ErrConflict
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy the code's auth_source onto the durable link. On the idempotent
|
||||||
|
// re-verify path DO UPDATE refreshes it (a player who re-linked via a different
|
||||||
|
// Yggdrasil this time gets the latest source stored), keeping the persisted
|
||||||
|
// value equal to the one returned to the caller.
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`INSERT INTO account_links (user_id, mc_uuid) VALUES ($1, $2)
|
`INSERT INTO account_links (user_id, mc_uuid, auth_source) VALUES ($1, $2, $3)
|
||||||
ON CONFLICT (user_id, mc_uuid) DO NOTHING`, userID, mcUUID); err != nil {
|
ON CONFLICT (user_id, mc_uuid) DO UPDATE SET auth_source = EXCLUDED.auth_source`,
|
||||||
return "", fmt.Errorf("write account link: %w", err)
|
userID, mcUUID, authSource); err != nil {
|
||||||
|
return "", "", fmt.Errorf("write account link: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`DELETE FROM account_link_codes WHERE code = $1`, code); err != nil {
|
`DELETE FROM account_link_codes WHERE code = $1`, code); err != nil {
|
||||||
return "", fmt.Errorf("consume link code: %w", err)
|
return "", "", fmt.Errorf("consume link code: %w", err)
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
return mcUUID, nil
|
return mcUUID, authSource, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuotaAvailable treats a missing quota row or a NULL max_servers as unlimited;
|
// QuotaAvailable treats a missing quota row or a NULL max_servers as unlimited;
|
||||||
|
|||||||
+12
-9
@@ -114,18 +114,21 @@ type Repo interface {
|
|||||||
IsLinked(ctx context.Context, userID string) (bool, error)
|
IsLinked(ctx context.Context, userID string) (bool, error)
|
||||||
// CreateLinkCode mints a one-time account-link code for an in-game player
|
// CreateLinkCode mints a one-time account-link code for an in-game player
|
||||||
// (spec §10: 游戏内 /link → 生成一次性码). The code is born knowing only the
|
// (spec §10: 游戏内 /link → 生成一次性码). The code is born knowing only the
|
||||||
// verified mc_uuid (online-mode=true established it); a web user binds it to
|
// verified mc_uuid (online-mode=true established it) and the authSource that
|
||||||
// their user_id later via VerifyLinkCode. This is internal-face only — the web
|
// established it (mojang|thirdparty, spec §10 dual-Yggdrasil); a web user binds
|
||||||
// has no verified UUID to mint against (the account_link_codes schema has no
|
// it to their user_id later via VerifyLinkCode. This is internal-face only — the
|
||||||
// user_id column, which forces the in-game origin).
|
// web has no verified UUID to mint against (the account_link_codes schema has no
|
||||||
CreateLinkCode(ctx context.Context, code, mcUUID string, expiresAt time.Time) error
|
// user_id column, which forces the in-game origin), nor does it see the
|
||||||
|
// authentication, which is why authSource also originates here.
|
||||||
|
CreateLinkCode(ctx context.Context, code, mcUUID, authSource string, expiresAt time.Time) error
|
||||||
// VerifyLinkCode consumes a non-expired code for the logged-in user and writes
|
// VerifyLinkCode consumes a non-expired code for the logged-in user and writes
|
||||||
// the account_links binding, atomically (spec §10: 网页 verify 填码 → 写
|
// the account_links binding, atomically (spec §10: 网页 verify 填码 → 写
|
||||||
// account_links). It returns the bound mc_uuid. A missing or expired code →
|
// account_links). It returns the bound mc_uuid and the authSource captured at
|
||||||
|
// mint (copied from the code onto the durable link). A missing or expired code →
|
||||||
// ErrLinkCodeInvalid; a uuid already linked to a *different* user → ErrConflict;
|
// ErrLinkCodeInvalid; a uuid already linked to a *different* user → ErrConflict;
|
||||||
// re-verifying the same (user, uuid) pair is idempotent. now is the API clock so
|
// re-verifying the same (user, uuid) pair is idempotent and refreshes the stored
|
||||||
// expiry is testable.
|
// authSource. now is the API clock so expiry is testable.
|
||||||
VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (mcUUID string, err error)
|
VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (mcUUID, authSource string, err error)
|
||||||
// QuotaAvailable reports whether the user is under their max_servers quota
|
// QuotaAvailable reports whether the user is under their max_servers quota
|
||||||
// (spec §9.3 step ②, evaluated before provisioning).
|
// (spec §9.3 step ②, evaluated before provisioning).
|
||||||
QuotaAvailable(ctx context.Context, userID string) (bool, error)
|
QuotaAvailable(ctx context.Context, userID string) (bool, error)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Player onboarding (spec §10, dual-Yggdrasil): record HOW the in-game identity
|
||||||
|
-- authenticated when a link code was minted — 'mojang' (Mojang/official Yggdrasil,
|
||||||
|
-- the priority source) or 'thirdparty' (a configured alternate Yggdrasil). The
|
||||||
|
-- value is known only in-game at the moment online-mode auth established the UUID,
|
||||||
|
-- so it is captured on the code at mint time and copied onto the durable link at
|
||||||
|
-- verify. The web verify side never sees the authentication and cannot originate
|
||||||
|
-- it — the same constraint that puts mc_uuid (not user_id) on a code.
|
||||||
|
--
|
||||||
|
-- DEFAULT 'mojang' backfills any code/link rows that predate this column and gives
|
||||||
|
-- a Mojang-priority default for a mint that omits the field; the mint path supplies
|
||||||
|
-- it explicitly going forward.
|
||||||
|
CREATE TYPE link_auth_source AS ENUM ('mojang','thirdparty');
|
||||||
|
|
||||||
|
ALTER TABLE account_link_codes
|
||||||
|
ADD COLUMN auth_source link_auth_source NOT NULL DEFAULT 'mojang';
|
||||||
|
ALTER TABLE account_links
|
||||||
|
ADD COLUMN auth_source link_auth_source NOT NULL DEFAULT 'mojang';
|
||||||
Reference in new issue
Block a user