跳到主要内容
新品云 ERP 正式上线,几分钟开通专属实例

技术分享

"Connecting two independent account systems: one-time ticket SSO and three traps"

The Openstone website and Hongzhai Cloud ERP are two independently deployed sites, each with its own account system. We deliberately did not merge the user databases: the two differ in user boundary, operations cadence and security blast radius, and a merge makes "change one thing, both sites wobble" a permanent condition. Users, however, should not be blocked by that boundary — here is how one short-lived ticket connects the two at the session layer.

Why the session layer

Three more "convenient" options, ruled out first:

  • Merging the user databases: touches account logic on both sides and couples two teams' release cadences — no;
  • Sharing session cookies: two domains, two session systems; sharing cookies welds both sides' session security together — no;
  • Copying password hashes: the password systems would have to track each other's upgrades and rotations, blurring responsibility — no.

What remains is credential exchange: after signing in on one side, a short-lived, single-use credential the other side can verify is exchanged for a session there. The connection narrows to one well-defined boundary.

The ticket

The ticket is an RS256-signed JWT valid for 120 seconds, each claim with a stated job:

Claim Value Role
iss Issuer identity The consumer checks the source
aud Consumer identity Stops reuse against other services
email User's email The sole account-matching key
jti Random unique id Single-use replay protection
iat / exp Issued/expiry times The 120-second window

The private key stays with the issuer; the consumer gets only the public key — the two sites exchange public material and nothing secret.

The journey of one ticket

  1. The user, signed in on the parent site, clicks "go to console";
  2. The issuer verifies the session → signs the ticket → returns a redirect carrying it;
  3. The browser navigates; the ticket appears in the consumer's URL;
  4. The consumer verifies the signature → checks issuer, audience, expiry → writes the jti into a single-use registry (unique constraint);
  5. It looks up an account by email: found → the consumer's own standard login flow establishes a session; not found → "this email has no account yet" plus a registration link, never a silent account creation;
  6. The user lands in the console with a fresh session.

Security-properties table

Attack surface Countermeasure Verification
Replaying the ticket jti unique constraint; a second submission must collide Measured: second consume → "already used"
Using an expired ticket 120-second exp, checked at verification Measured: expired → ticket_expired
Forgery/tampering RS256, public key on the consumer only Measured: bad ticket → ticket_invalid
Cross-service reuse aud pinned to the consumer Asserted in decode options
Accidental account creation No match ⇒ prompt only, never create Measured: unknown email → prompt + register link

Three traps

1. The ticket was consumed twice

During integration the console stalled on an error: "ticket already used". Server logs showed the same ticket submitted twice within milliseconds — the first issued a session, the second hit the unique constraint.

The culprit was frontend: consumption lived in a page effect, and effects re-run when dependencies change (once language resources loaded, the translation function changed identity and triggered a second run). Two fixes:

  • A single-flight guard: a ref ensuring one execution per mount;
  • Decoupling liveness from the dependency array: move "is the component still mounted" out of the effect cleanup so it only flips on a real unmount — otherwise the first request's success response gets discarded when deps change, leaving the page on "signing you in…" forever.

Frontend logic around single-use credentials does not fit the "re-runnable effect" model; executions must be constrained explicitly.

2. The page lived in the wrong route tree

Early on the page returned 404. The product site has a rewrite rule sending locale-prefixed /auth/* paths to the root-level auth tree, where all app pages live. A page placed "intuitively" under the locale directory can never match.

Lesson: when adding a page to someone else's framework, find where sibling pages actually live before writing the file.

3. The key file was unreadable — silently

The ticket endpoint answered "not configured". The key file was mode 0600 and the container process ran as another user; a catch-all "unreadable means empty" branch swallowed the error, and the server claimed it had no configuration at all.

The fix: align key permissions with the container user (now a deploy-checklist line), report "missing configuration" as an explicit 503, and let a startup self-check surface it early. Silent degradation disguises configuration errors as business errors.

Takeaways

Connecting two account systems needs neither merged data nor shared credentials. Narrowed to "a short-lived one-time ticket + email matching + each side's standard login", it stays auditable and reversible; clear replay, routing and permission traps first, and the path is actually deliverable.

相关产品与 ERP 实践文章在宏斋博客。 宏斋云ERP · Blog

返回列表