چه چیزی عوض میشود و چرا
تا الان توکن کاربر بازینس ۱۰ سال اعتبار داشت. توی آدرسها هم جابهجا میشد:
- بازی شما صفحههای بازینس را با
?linkedToken=<token>باز میکرد؛ - اپ بازینس بازی شما را با
?linkedToken=<token>باز میکرد.
آدرسها در لاگ سرورها، ابزارهای آنالیتیکس، تاریخچهی مرورگر و هدر Referer ثبت میشوند. توکنی که از آنجا پیدا میشد، تا ده سال به سکهها و حساب بازیکن دسترسی میداد و خروج از حساب هم جلویش را نمیگرفت.
مدل جدید:
| قبل | حالا | |
|---|---|---|
توکن دسترسی (چیزی که کلاینت بهعنوان Bearer میفرستد) |
۱۰ سال اعتبار | ۱۵ دقیقه اعتبار |
| توکن تمدید (refresh token) | — | توکن دسترسی را تمدید میکند؛ روی سرور شما نگه داشته میشود، هیچوقت روی دستگاه؛ یکبارمصرف است و هر تمدید یک توکن تازه برمیگرداند |
| جابهجا کردن بازیکن بین اپها | ?linkedToken=<token> |
?linkCode=<code>: یک کد یکبارمصرف با حدود ۲ دقیقه اعتبار که بعد از اولین استفاده بیاثر است |
مهم
در دورهی مهاجرت هر دو مدل کنار هم کار میکنند. endpointهای قدیمی و
?linkedToken=کار میکنند و اپ بازینس هم?linkCode=و هم?linkedToken=را برای بازیها میفرستد. تیم پلتفرم یک تاریخ پایان اعلام میکند: بعد از آن، توکنهای قدیمی وlinkedTokenدیگر کار نمیکنند. مهاجرت را قبل از آن تاریخ انجام دهید.
جریان بعد از مهاجرت:
sequenceDiagram
autonumber
participant C as کلاینت بازی
participant S as سرور بازی
participant K as API پلتفرم
participant W as صفحهی بازینس (شیت)
C->>S: POST /api/platform/token { deviceId, linkCode? }
alt بار اول، یا ورود با ?linkCode=
S->>K: POST v2/client/create-user { deviceId, linkCode }
else بازیکن آشنا
S->>K: POST auth/refresh { refreshToken ذخیرهشده }
end
K-->>S: { userId, isLinkedToAccount, accessToken (۱۵ دقیقه), refreshToken }
Note over S: refreshToken جدید را روی بازیکن ذخیره کن
S-->>C: { token: accessToken, tokenExpiresAt, bazinessUserId, isLinked }
C->>S: POST /api/platform/link-code (قبل از باز کردن صفحه)
S->>K: POST v2/client/link-code/{userId}
K-->>S: { code } (یکبارمصرف، حدود ۲ دقیقه)
S-->>C: { linkCode }
C->>W: باز کردن صفحه (sdkType=js, linkCode, …)
W-->>C: postMessage { type: "login", data: "<token>" }
C->>S: POST /api/platform/login { deviceId, token }
S->>K: POST client/validate-user-token { token }
S-->>C: { ok } — یا { ok, switchToDeviceId }تغییرات در یک نگاه
| امروز | بعد از مهاجرت | کجا |
|---|---|---|
client/create-user با linkedToken |
v2/client/create-user با linkCode؛ tokens.refreshToken را ذخیره کنید |
سرور |
client/get-user-token/{id} در هر بار اجرا |
auth/refresh با توکن تمدید ذخیرهشده؛ v2/client/get-user-token/{id} فقط وقتی نشستی (session) نیست |
سرور |
باز کردن صفحهها با linkedToken=<token> |
باز کردن صفحهها با linkCode=<code>؛ برای هر صفحه یک کد تازه از v2/client/link-code/{id} |
سرور + کلاینت |
اپ بازینس بازی شما را با ?linkedToken= باز میکند |
?linkCode=: مثل توکن قبلی آن را به سرورتان بدهید |
کلاینت |
GET client/validate-user-token/{token} |
POST client/validate-user-token با { token } (توکن دیگر در آدرس نیست) |
سرور |
| کلاینت توکنش را سالها نگه میدارد | کلاینت فقط توکن دسترسی را نگه میدارد؛ وقتی منقضی شد دوباره از سرور خودتان میگیرد | کلاینت |
باقی چیزها عوض نمیشوند: پیامهای صفحه (login، token، close، logout)، سکهها، جایزهها و پرداخت مثل قبل کار میکنند.
سرور، قدمبهقدم
۱. برای هر بازیکن یک توکن تمدید نگه دارید
یک ستون به رکورد بازیکن اضافه کنید، مثلاً baziness_refresh_token. با آن مثل رمز عبور رفتار کنید:
- هیچوقت لاگش نکنید؛
- هیچوقت به کلاینت نفرستید؛
- اگر رازهای دیگر را رمزنگاری میکنید، این را هم رمزنگاری کنید.
همیشه با جدیدترین توکن جایگزینش کنید. هر create-user، get-user-token و refresh یک توکن تمدید تازه برمیگرداند و توکن قبلی دیگر کار نمیکند.
۲. /api/platform/token: بهجای get-user-token تمدید کنید
این endpointی است که کلاینت در شروع اجرا صدا میزند. کلاینت حالا بهجای linkedToken، linkCode میفرستد.
- کلاینت
linkCodeفرستاده، یا بازیکن هنوز کاربر بازینس ندارد:v2/client/create-userرا باdeviceIdوlinkCodeصدا بزنید. بعدuserIdبرگشتی را مثل امروز بررسی کنید:userIdمال بازیکن دیگری از بازیکنان شماست ←switchToDeviceIdبرگردانید (همان آدم روی دستگاه دیگر).- بازیکن از قبل
userIdدیگری دارد ← یک حساب بازینس دیگر رسیده است. آن را بهعنوان بازیکن جدا شروع کنید وswitchToDeviceIdبرگردانید (همان قاعدهی قدیمی «دادههای محلی را پاک کن و از نو شروع کن»). - در غیر این صورت ←
userIdو توکن تمدید را روی همین بازیکن ذخیره کنید. - اگر کد منقضی یا مصرفشده بود (
400 invalid_link_code)، طوری ادامه دهید که انگار کدی نیامده.
- بازیکن آشنا:
auth/refreshرا با توکن تمدید ذخیرهشده صدا بزنید و توکن تازه را ذخیره کنید. جوابisLinkedToAccountرا هم دارد، پس همچنان میفهمید بازیکن کی وارد حسابش شده.401 refresh_token_rotated: درخواست دیگری از همین بازیکن لحظهای قبل تمدید کرده. توکن تمدید را دوباره از دیتابیس بخوانید و یک بار دیگر امتحان کنید.- هر
401دیگر (session_revoked،refresh_token_expired،invalid_refresh_token،refresh_token_reused): نشست تمام شده. باv2/client/get-user-token/{userId}نشست تازه بگیرید و توکن تمدیدش را ذخیره کنید. 403 account_suspended: حساب مسدود است.
- به کلاینت توکن دسترسی و زمان انقضایش را بدهید:
{ token, tokenExpiresAt, bazinessUserId, isLinkedToAccount }. توکن تمدید را هرگز نفرستید.
پیشنهاد
بازیکنهایی که از قبل دارید هنوز توکن تمدید ندارند. قدم ۲ خودش آنها را درست میکند: توکن تمدید ذخیرهشدهای نیست ← یک بار
v2/client/get-user-token، و از آن به بعدauth/refresh.
۳. endpoint جدید /api/platform/link-code: یک کد برای هر صفحه
وقتی کلاینت میخواهد یکی از صفحههای بازینس را باز کند، از سرور شما کد میخواهد. سرور شما v2/client/link-code/{userId} را صدا میزند و { linkCode } را برمیگرداند.
- هر کد یک بار و حدود دو دقیقه کار میکند: برای هر صفحهای که باز میکنید کد تازه بگیرید.
- فقط صفحههای بازینس میتوانند از آن استفاده کنند. حتی اگر لو برود، نمیشود با آن وارد بازی شما شد.
- این endpoint را مثل بقیهی endpointهای بازیکن محافظت کنید: فقط خود بازیکن میتواند کد خودش را بگیرد.
۴. /api/platform/login: بررسی با POST
منطق عوض نمیشود. فقط نوع درخواست عوض میشود: POST client/validate-user-token با { token } در بدنه.
توکنی که در پیام login میرسد حالا ۱۵ دقیقهای است. همان لحظه بررسیاش کنید، همانطور که امروز هم میکنید.
اگر توکن مال کاربر بازینسی غیر از کاربر فعلی بازیکن بود، توکن تمدید ذخیرهشدهی بازیکن را پاک کنید؛ فراخوانی بعدی /token نشست تازه شروع میکند.
۵. endpoint جدید /api/platform/logout
وقتی صفحه logout میفرستد، کلاینت قبل از شروع دوباره این را صدا میزند. سرور شما نشست را میبندد (auth/logout) و توکن تمدید ذخیرهشده را پاک میکند.
کد سرور
اضافهها به کلاینت پلتفرم شما (کنار createUser / getUserAccessToken):
// platform/baziness.ts — ADD these next to createUser / getUserAccessToken (they reuse API_BASE and platformFetch).
/** A player's session: the access token goes to your client, the refresh token stays in your database. */
export interface PlatformSession {
userId: number;
isLinkedToAccount: boolean;
accessToken: string;
accessTokenExpiresAt: string;
refreshToken: string;
}
const readSession = (j: Record<string, any>): PlatformSession => {
const t = j.tokens ?? j; // v2 create-user / get-user-token nest the pair in `tokens`; auth/refresh returns it flat
return {
userId: Number(j.userId),
isLinkedToAccount: Boolean(j.isLinkedToAccount),
accessToken: String(t.accessToken),
accessTokenExpiresAt: String(t.accessTokenExpiresAt),
refreshToken: String(t.refreshToken),
};
};
/** First time, or when the client brought a ?linkCode=. Throws (status 400) for an expired or used code. */
export const createUserSession = async (deviceId: string, platform: string, store: string, linkCode: string | null) =>
readSession(await platformFetch("POST", "/v2/client/create-user", { deviceId, platform, store, linkCode }));
/** A new session for a player you know — only when you have no refresh token or the session ended. */
export const startUserSession = async (bazinessUserId: number) =>
readSession(await platformFetch("POST", `/v2/client/get-user-token/${bazinessUserId}`));
/**
* Renews a session. Returns the new session, "rotated" when a parallel request refreshed first
* (reload the stored token and retry once), or null when the session is over (start a new one).
*/
export async function refreshUserSession(refreshToken: string): Promise<PlatformSession | "rotated" | null> {
const res = await fetch(`${API_BASE}/auth/refresh`, { // no client token: the refresh token is the credential
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (res.ok) return readSession(await res.json());
const error = ((await res.json().catch(() => ({}))) as { error?: string }).error;
if (res.status === 401 && error === "refresh_token_rotated") return "rotated";
if (res.status === 403) throw Object.assign(new Error("account_suspended"), { status: 403 });
if (res.status === 401) return null;
throw new Error(`auth/refresh failed (${res.status})`); // 5xx / network: keep the old session, try later
}
/** Ends a session (player signed out). Never throws. */
export async function endUserSession(refreshToken: string): Promise<void> {
await fetch(`${API_BASE}/auth/logout`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
}).catch(() => {});
}
/** One-time code for opening ONE Baziness page as this player (~2 minutes). */
export const createPageLinkCode = async (bazinessUserId: number) =>
String((await platformFetch("POST", `/v2/client/link-code/${bazinessUserId}`)).code);
/** REPLACES the old GET version: the token now goes in the body, not in the URL. */
export async function validateUserAccessToken(token: string): Promise<number> {
const j = await platformFetch("POST", "/client/validate-user-token", { token });
if (!(j.isValid ?? j.IsValid)) throw new Error("invalid user token");
return Number(j.userId ?? j.UserId ?? 0);
}// BazinessPlatformClient.cs — ADD these members (they reuse settings, http, SendAsync and Json).
public record PlatformSession(long UserId, bool IsLinkedToAccount, string AccessToken,
DateTimeOffset AccessTokenExpiresAt, string RefreshToken);
public enum RefreshOutcome { Ok, Rotated, Ended }
static PlatformSession ReadSession(JsonElement j)
{
// v2 create-user / get-user-token nest the pair in "tokens"; auth/refresh returns it flat.
var t = j.TryGetProperty("tokens", out var nested) ? nested : j;
return new PlatformSession(
j.GetProperty("userId").GetInt64(),
j.GetProperty("isLinkedToAccount").GetBoolean(),
t.GetProperty("accessToken").GetString()!,
t.GetProperty("accessTokenExpiresAt").GetDateTimeOffset(),
t.GetProperty("refreshToken").GetString()!);
}
/// <summary>First time, or when the client brought a ?linkCode=. 400 = expired or used code.</summary>
public async Task<PlatformSession> CreateUserSessionAsync(string deviceId, string platform, string store, string? linkCode) =>
ReadSession(await SendAsync(HttpMethod.Post, "/v2/client/create-user", new { deviceId, platform, store, linkCode }));
/// <summary>A new session for a known player — only when there is no refresh token or the session ended.</summary>
public async Task<PlatformSession> StartUserSessionAsync(long bazinessUserId) =>
ReadSession(await SendAsync(HttpMethod.Post, $"/v2/client/get-user-token/{bazinessUserId}"));
/// <summary>Renews a session. Rotated = a parallel request refreshed first (reload, retry once); Ended = start a new one.</summary>
public async Task<(RefreshOutcome Outcome, PlatformSession? Session)> RefreshUserSessionAsync(string refreshToken)
{
// No client token here: the refresh token itself is the credential.
var res = await http.PostAsJsonAsync($"{settings.ApiBase}/auth/refresh", new { refreshToken }, Json);
if (res.IsSuccessStatusCode)
return (RefreshOutcome.Ok, ReadSession(await res.Content.ReadFromJsonAsync<JsonElement>(Json)));
string? error = null;
var text = await res.Content.ReadAsStringAsync();
if (!string.IsNullOrEmpty(text))
{
try { error = JsonDocument.Parse(text).RootElement.TryGetProperty("error", out var e) ? e.GetString() : null; }
catch (JsonException) { /* not JSON */ }
}
return res.StatusCode switch
{
HttpStatusCode.Unauthorized when error == "refresh_token_rotated" => (RefreshOutcome.Rotated, null),
HttpStatusCode.Unauthorized => (RefreshOutcome.Ended, null),
HttpStatusCode.Forbidden => throw new InvalidOperationException("account_suspended"),
_ => throw new HttpRequestException($"auth/refresh failed ({(int)res.StatusCode})") // keep the old session
};
}
/// <summary>Ends a session (player signed out). Never throws.</summary>
public async Task EndUserSessionAsync(string refreshToken)
{
try { await http.PostAsJsonAsync($"{settings.ApiBase}/auth/logout", new { refreshToken }, Json); }
catch { /* best effort */ }
}
/// <summary>One-time code for opening ONE Baziness page as this player (~2 minutes).</summary>
public async Task<string> CreatePageLinkCodeAsync(long bazinessUserId) =>
(await SendAsync(HttpMethod.Post, $"/v2/client/link-code/{bazinessUserId}")).GetProperty("code").GetString()!;
/// <summary>REPLACES the old GET version: the token now goes in the body, not in the URL.</summary>
public async Task<long> ValidateUserAccessTokenAsync(string token)
{
var j = await SendAsync(HttpMethod.Post, "/client/validate-user-token", new { token });
if (!j.GetProperty("isValid").GetBoolean()) throw new InvalidOperationException("invalid user token");
return j.GetProperty("userId").GetInt64();
}# baziness/platform.py — ADD these methods to BazinessPlatform (they reuse API_BASE, self._http and self._call).
@staticmethod
def _session(j: dict) -> dict:
t = j.get("tokens") or j # v2 create-user / get-user-token nest the pair; auth/refresh returns it flat
return {"userId": int(j["userId"]), "isLinkedToAccount": bool(j["isLinkedToAccount"]),
"accessToken": t["accessToken"], "accessTokenExpiresAt": t["accessTokenExpiresAt"],
"refreshToken": t["refreshToken"]}
def create_user_session(self, device_id: str, platform: str, store: str, link_code: str | None) -> dict:
"""First time, or when the client brought a ?linkCode=. HTTPError 400 = expired or used code."""
return self._session(self._call("POST", "/v2/client/create-user", {
"deviceId": device_id, "platform": platform, "store": store, "linkCode": link_code}))
def start_user_session(self, baziness_user_id: int) -> dict:
"""A new session for a known player — only when there is no refresh token or the session ended."""
return self._session(self._call("POST", f"/v2/client/get-user-token/{baziness_user_id}"))
def refresh_user_session(self, refresh_token: str):
"""Returns the new session, "rotated" (reload the stored token, retry once) or None (session over)."""
r = self._http.post(f"{API_BASE}/auth/refresh", json={"refreshToken": refresh_token}, timeout=10)
if r.ok:
return self._session(r.json())
error = (r.json() if r.content else {}).get("error")
if r.status_code == 401:
return "rotated" if error == "refresh_token_rotated" else None
if r.status_code == 403:
raise PermissionError("account_suspended")
r.raise_for_status() # 5xx: keep the old session, try later
def end_user_session(self, refresh_token: str) -> None:
"""Ends a session (player signed out). Never raises."""
try:
self._http.post(f"{API_BASE}/auth/logout", json={"refreshToken": refresh_token}, timeout=10)
except requests.RequestException:
pass
def create_page_link_code(self, baziness_user_id: int) -> str:
"""One-time code for opening ONE Baziness page as this player (~2 minutes)."""
return str(self._call("POST", f"/v2/client/link-code/{baziness_user_id}")["code"])
def validate_user_access_token(self, token: str) -> int:
"""REPLACES the old GET version: the token now goes in the body, not in the URL."""
j = self._call("POST", "/client/validate-user-token", {"token": token})
if not _pick(j, "isValid", False):
raise ValueError("invalid user token")
return int(_pick(j, "userId", 0))چهار endpoint:
import {
createUserSession, startUserSession, refreshUserSession, endUserSession, createPageLinkCode,
validateUserAccessToken, platformClientConfig, platformEnabled, type PlatformSession,
} from "./platform/baziness";
import { db } from "./db"; // your own data layer. New column on the player: baziness_refresh_token (a secret)
// What the client gets. The refresh token never leaves your server.
const forClient = (s: PlatformSession) => ({
token: s.accessToken, tokenExpiresAt: s.accessTokenExpiresAt,
bazinessUserId: s.userId, isLinkedToAccount: s.isLinkedToAccount,
});
async function keep(playerId: number, s: PlatformSession) {
await db.setRefreshToken(playerId, s.refreshToken); // always the NEWEST one: the old one stops working
return s;
}
/** A valid session for a player who already has a Baziness user. */
async function sessionFor(player: { id: number; bazinessUserId: number; bazinessRefreshToken: string | null }) {
if (player.bazinessRefreshToken) {
let r = await refreshUserSession(player.bazinessRefreshToken);
if (r === "rotated") { // another request of this player refreshed a moment ago
const latest = await db.getRefreshToken(player.id);
r = latest ? await refreshUserSession(latest) : null;
if (r === "rotated") r = null;
}
if (r) return keep(player.id, r);
}
// No refresh token yet (players from before the migration) or the session ended.
return keep(player.id, await startUserSession(player.bazinessUserId));
}
app.post("/api/platform/token", async (req, res) => {
const deviceId = String(req.body.deviceId ?? "").trim();
const linkCode: string | null = req.body.linkCode ?? null; // replaces linkedToken
if (!deviceId) return res.status(400).json({ error: "deviceId required" });
if (!platformEnabled()) return res.json({ enabled: false });
const config = { enabled: true, ...platformClientConfig() };
const me = await db.ensureUserByDevice(deviceId, { utm: req.body.utm, tag: req.body.tag });
try {
if (linkCode || !me.bazinessUserId) {
// First time, or arrived from the Baziness app with ?linkCode=: which Baziness user is this?
const s = await createUserSession(deviceId, "Web", "Direct", linkCode)
.catch((e) => (linkCode && e.status === 400 ? null : Promise.reject(e))); // expired or used code
if (s) {
const owner = await db.findUserByBazinessId(s.userId);
if (owner && owner.id !== me.id) { // the same person, another player of yours
await keep(owner.id, s);
return res.json({ ...config, switchToDeviceId: owner.deviceId });
}
if (me.bazinessUserId && me.bazinessUserId !== s.userId) {
// A different Baziness account arrived on a device that already has a player:
// it becomes its own player (the old "clear local data and start again" rule).
const fresh = await db.createPlayerForBaziness(s.userId, s.isLinkedToAccount);
await keep(fresh.id, s);
return res.json({ ...config, switchToDeviceId: fresh.deviceId });
}
await db.linkBaziness(me.id, s.userId, s.isLinkedToAccount);
await keep(me.id, s);
return res.json({ ...config, ...forClient(s) });
}
if (!me.bazinessUserId) { // stale code and no Baziness user yet
const s2 = await createUserSession(deviceId, "Web", "Direct", null);
await db.linkBaziness(me.id, s2.userId, s2.isLinkedToAccount);
await keep(me.id, s2);
return res.json({ ...config, ...forClient(s2) });
}
}
const s = await sessionFor(me);
if (s.isLinkedToAccount !== me.isBazinessLinked) await db.setLinked(me.id, s.isLinkedToAccount);
return res.json({ ...config, ...forClient(s) });
} catch (err) {
console.error("[platform] token failed:", (err as Error).message);
const suspended = (err as { status?: number }).status === 403;
return res.json({ ...config, error: suspended ? "account_suspended" : "platform_unavailable" }); // never block the boot
}
});
// Protect it like your other player endpoints: only the player themself may ask for their code.
app.post("/api/platform/link-code", async (req, res) => {
const me = await db.findUserByDevice(String(req.body.deviceId ?? ""));
if (!me?.bazinessUserId) return res.status(409).json({ error: "call /api/platform/token first" });
try {
return res.json({ linkCode: await createPageLinkCode(me.bazinessUserId) });
} catch {
return res.status(503).json({ error: "platform_unavailable" });
}
});
app.post("/api/platform/login", async (req, res) => {
const deviceId = String(req.body.deviceId ?? "").trim();
const token = String(req.body.token ?? "").trim();
if (!deviceId || !token) return res.status(400).json({ error: "deviceId and token required" });
if (!platformEnabled()) return res.json({ ok: false, reason: "disabled" });
try {
const bazinessUserId = await validateUserAccessToken(token); // POST now; verify right away (15-min token)
const owner = await db.findUserByBazinessId(bazinessUserId);
const me = await db.ensureUserByDevice(deviceId);
if (owner && owner.id !== me.id) return res.json({ ok: true, switchToDeviceId: owner.deviceId });
if (me.bazinessUserId !== bazinessUserId) await db.setRefreshToken(me.id, null); // belonged to the old user
await db.linkBaziness(me.id, bazinessUserId, true);
return res.json({ ok: true }); // the client then calls /token again
} catch {
return res.json({ ok: false, reason: "invalid" });
}
});
// The page sent `logout`: end the session before the client starts over as a new visitor.
app.post("/api/platform/logout", async (req, res) => {
const me = await db.findUserByDevice(String(req.body.deviceId ?? ""));
if (me?.bazinessRefreshToken) {
await endUserSession(me.bazinessRefreshToken);
await db.setRefreshToken(me.id, null);
}
return res.json({ ok: true });
});// IUserStore gains: SetRefreshTokenAsync(id, token?), GetRefreshTokenAsync(id), FindByDeviceAsync(deviceId),
// CreatePlayerForBazinessAsync(bazinessUserId, isLinked). Store the refresh token like a password.
// What the client gets. The refresh token never leaves your server.
static object ForClient(PlatformSettings pub, PlatformSession s) => new
{
enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName,
token = s.AccessToken, tokenExpiresAt = s.AccessTokenExpiresAt,
bazinessUserId = s.UserId, isLinkedToAccount = s.IsLinkedToAccount
};
static async Task<PlatformSession> SessionForAsync(Player player, BazinessPlatformClient platform, IUserStore users)
{
var refreshToken = player.BazinessRefreshToken;
for (var attempt = 0; refreshToken is not null && attempt < 2; attempt++)
{
var (outcome, session) = await platform.RefreshUserSessionAsync(refreshToken);
if (outcome == RefreshOutcome.Ok)
{
await users.SetRefreshTokenAsync(player.Id, session!.RefreshToken); // always the newest one
return session;
}
if (outcome == RefreshOutcome.Ended) break;
refreshToken = await users.GetRefreshTokenAsync(player.Id); // Rotated: a parallel request won
}
// No refresh token yet (players from before the migration) or the session ended.
var fresh = await platform.StartUserSessionAsync(player.BazinessUserId!.Value);
await users.SetRefreshTokenAsync(player.Id, fresh.RefreshToken);
return fresh;
}
app.MapPost("/api/platform/token", async (TokenRequest body, BazinessPlatformClient platform,
IUserStore users, PlatformSettings pub, ILogger<Program> log) =>
{
if (string.IsNullOrWhiteSpace(body.DeviceId)) return Results.BadRequest(new { error = "deviceId required" });
if (!pub.IsOn) return Results.Ok(new { enabled = false });
var me = await users.EnsureByDeviceAsync(body.DeviceId);
try
{
if (body.LinkCode is not null || me.BazinessUserId is null)
{
PlatformSession? s = null;
try { s = await platform.CreateUserSessionAsync(body.DeviceId, "Web", "Direct", body.LinkCode); }
catch (HttpRequestException e) when (body.LinkCode is not null && e.StatusCode == HttpStatusCode.BadRequest) { }
if (s is not null)
{
var owner = await users.FindByBazinessUserIdAsync(s.UserId);
if (owner is not null && owner.Id != me.Id)
{
await users.SetRefreshTokenAsync(owner.Id, s.RefreshToken);
return Results.Ok(new { enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName, switchToDeviceId = owner.DeviceId });
}
if (me.BazinessUserId is not null && me.BazinessUserId != s.UserId)
{
var created = await users.CreatePlayerForBazinessAsync(s.UserId, s.IsLinkedToAccount);
await users.SetRefreshTokenAsync(created.Id, s.RefreshToken);
return Results.Ok(new { enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName, switchToDeviceId = created.DeviceId });
}
await users.LinkBazinessAsync(me.Id, s.UserId, s.IsLinkedToAccount);
await users.SetRefreshTokenAsync(me.Id, s.RefreshToken);
return Results.Ok(ForClient(pub, s));
}
if (me.BazinessUserId is null) // stale code and no Baziness user yet
{
var s2 = await platform.CreateUserSessionAsync(body.DeviceId, "Web", "Direct", null);
await users.LinkBazinessAsync(me.Id, s2.UserId, s2.IsLinkedToAccount);
await users.SetRefreshTokenAsync(me.Id, s2.RefreshToken);
return Results.Ok(ForClient(pub, s2));
}
}
var session = await SessionForAsync(me, platform, users);
if (session.IsLinkedToAccount != me.IsBazinessLinked) await users.SetLinkedAsync(me.Id, session.IsLinkedToAccount);
return Results.Ok(ForClient(pub, session));
}
catch (Exception ex)
{
log.LogError(ex, "platform token failed");
var error = ex.Message == "account_suspended" ? "account_suspended" : "platform_unavailable";
return Results.Ok(new { enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName, error }); // never block the boot
}
});
// Protect it like your other player endpoints: only the player themself may ask for their code.
app.MapPost("/api/platform/link-code", async (DeviceRequest body, BazinessPlatformClient platform, IUserStore users) =>
{
var me = await users.FindByDeviceAsync(body.DeviceId);
if (me?.BazinessUserId is null) return Results.Conflict(new { error = "call /api/platform/token first" });
try { return Results.Ok(new { linkCode = await platform.CreatePageLinkCodeAsync(me.BazinessUserId.Value) }); }
catch { return Results.StatusCode(503); }
});
app.MapPost("/api/platform/login", async (LoginRequest body, BazinessPlatformClient platform, IUserStore users, PlatformSettings pub) =>
{
if (string.IsNullOrWhiteSpace(body.DeviceId) || string.IsNullOrWhiteSpace(body.Token))
return Results.BadRequest(new { error = "deviceId and token required" });
if (!pub.IsOn) return Results.Ok(new { ok = false, reason = "disabled" });
try
{
long bazinessUserId = await platform.ValidateUserAccessTokenAsync(body.Token); // POST now; verify right away
var owner = await users.FindByBazinessUserIdAsync(bazinessUserId);
var me = await users.EnsureByDeviceAsync(body.DeviceId);
if (owner is not null && owner.Id != me.Id) return Results.Ok(new { ok = true, switchToDeviceId = owner.DeviceId });
if (me.BazinessUserId != bazinessUserId) await users.SetRefreshTokenAsync(me.Id, null); // belonged to the old user
await users.LinkBazinessAsync(me.Id, bazinessUserId, isLinked: true);
return Results.Ok(new { ok = true }); // the client then calls /token again
}
catch { return Results.Ok(new { ok = false, reason = "invalid" }); }
});
// The page sent `logout`: end the session before the client starts over as a new visitor.
app.MapPost("/api/platform/logout", async (DeviceRequest body, BazinessPlatformClient platform, IUserStore users) =>
{
var me = await users.FindByDeviceAsync(body.DeviceId);
if (me?.BazinessRefreshToken is { } refreshToken)
{
await platform.EndUserSessionAsync(refreshToken);
await users.SetRefreshTokenAsync(me.Id, null);
}
return Results.Ok(new { ok = true });
});
public record TokenRequest(string DeviceId, string? LinkCode);
public record DeviceRequest(string DeviceId);
public record LoginRequest(string DeviceId, string Token);from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from requests import HTTPError
from baziness.platform import platform, platform_client_config, platform_enabled
from app import db # your own data layer. New column on the player: baziness_refresh_token (a secret)
app = FastAPI()
class TokenRequest(BaseModel):
deviceId: str
linkCode: str | None = None # replaces linkedToken
class DeviceRequest(BaseModel):
deviceId: str
class LoginRequest(BaseModel):
deviceId: str
token: str
def for_client(s: dict) -> dict: # the refresh token never leaves your server
return {"token": s["accessToken"], "tokenExpiresAt": s["accessTokenExpiresAt"],
"bazinessUserId": s["userId"], "isLinkedToAccount": s["isLinkedToAccount"]}
def keep(player_id: int, s: dict) -> dict:
db.set_refresh_token(player_id, s["refreshToken"]) # always the newest one
return s
def session_for(player) -> dict:
token = player.baziness_refresh_token
for _ in range(2):
if not token:
break
r = platform.refresh_user_session(token)
if isinstance(r, dict):
return keep(player.id, r)
if r is None: # the session ended
break
token = db.get_refresh_token(player.id) # "rotated": a parallel request won
# No refresh token yet (players from before the migration) or the session ended.
return keep(player.id, platform.start_user_session(player.baziness_user_id))
@app.post("/api/platform/token")
def platform_token(body: TokenRequest):
if not platform_enabled():
return {"enabled": False}
config = {"enabled": True, **platform_client_config()}
me = db.ensure_user_by_device(body.deviceId)
try:
if body.linkCode or not me.baziness_user_id:
try:
s = platform.create_user_session(body.deviceId, "Web", "Direct", body.linkCode)
except HTTPError as e:
if not (body.linkCode and e.response.status_code == 400):
raise
s = None # expired or used code
if s:
owner = db.find_user_by_baziness_user_id(s["userId"])
if owner and owner.id != me.id: # the same person, another player of yours
keep(owner.id, s)
return {**config, "switchToDeviceId": owner.device_id}
if me.baziness_user_id and me.baziness_user_id != s["userId"]:
fresh = db.create_player_for_baziness(s["userId"], s["isLinkedToAccount"])
keep(fresh.id, s)
return {**config, "switchToDeviceId": fresh.device_id}
db.link_baziness(me.id, s["userId"], s["isLinkedToAccount"])
return {**config, **for_client(keep(me.id, s))}
if not me.baziness_user_id: # stale code and no Baziness user yet
s2 = platform.create_user_session(body.deviceId, "Web", "Direct", None)
db.link_baziness(me.id, s2["userId"], s2["isLinkedToAccount"])
return {**config, **for_client(keep(me.id, s2))}
s = session_for(me)
if s["isLinkedToAccount"] != me.is_baziness_linked:
db.set_linked(me.id, s["isLinkedToAccount"])
return {**config, **for_client(s)}
except PermissionError:
return {**config, "error": "account_suspended"}
except Exception as err: # never block the boot
print("[platform] token failed:", err)
return {**config, "error": "platform_unavailable"}
@app.post("/api/platform/link-code") # protect it like your other player endpoints
def platform_link_code(body: DeviceRequest):
me = db.find_user_by_device(body.deviceId)
if not me or not me.baziness_user_id:
raise HTTPException(409, "call /api/platform/token first")
return {"linkCode": platform.create_page_link_code(me.baziness_user_id)}
@app.post("/api/platform/login")
def platform_login(body: LoginRequest):
if not platform_enabled():
return {"ok": False, "reason": "disabled"}
try:
baziness_user_id = platform.validate_user_access_token(body.token) # POST now; verify right away
except Exception:
return {"ok": False, "reason": "invalid"}
owner = db.find_user_by_baziness_user_id(baziness_user_id)
me = db.ensure_user_by_device(body.deviceId)
if owner and owner.id != me.id:
return {"ok": True, "switchToDeviceId": owner.device_id}
if me.baziness_user_id != baziness_user_id:
db.set_refresh_token(me.id, None) # belonged to the old user
db.link_baziness(me.id, baziness_user_id, True)
return {"ok": True} # the client then calls /token again
@app.post("/api/platform/logout") # the page sent `logout`
def platform_logout(body: DeviceRequest):
me = db.find_user_by_device(body.deviceId)
if me and me.baziness_refresh_token:
platform.end_user_session(me.baziness_refresh_token)
db.set_refresh_token(me.id, None)
return {"ok": True}کلاینت، قدمبهقدم
- در شروع اجرا، بهجای
linkedToken،linkCodeرا (از?linkCode=در آدرس یا از deep link) به/api/platform/tokenبفرستید. بعد از آنlinkCodeوlinkedTokenرا از آدرس حذف کنید: هر کد یک بار کار میکند. - توکن را برای اجراهای بعدی ذخیره نکنید (
localStorage،PlayerPrefs). سرور شما نشست را نگه میدارد و با هر/api/platform/tokenیک توکن تازه میدهد. - قبل از استفاده از توکن (
user/get-coin)tokenExpiresAtرا نگاه کنید. اگر تا یک دقیقهی دیگر منقضی میشود، یا درخواستی401گرفت، دوباره/api/platform/tokenرا صدا بزنید. - برای باز کردن صفحهی بازینس، از
/api/platform/link-codeکد بگیرید و بهجایlinkedToken،linkCode=<code>را در آدرس صفحه بگذارید. بقیهی پارامترها همان قبلیها هستند (صفحهها). - با پیام
logoutصفحه،/api/platform/logoutرا صدا بزنید و بعد مثل قبل از نو شروع کنید.
// PlatformController — the parts that change.
// 1. Boot: forward the one-time ?linkCode= (it replaces ?linkedToken=). Don't keep the token in
// localStorage any more: your server keeps the session and gives you a fresh token on demand.
async init(): Promise<boolean> {
const params = new URLSearchParams(location.search);
const linkCode = params.get("linkCode");
const res = await fetch("/api/platform/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceId: this.deviceId, linkCode }),
});
const data = await res.json();
if (data.switchToDeviceId) return this.switchTo(data.switchToDeviceId); // unchanged
this.token = data.token ?? null;
this.tokenExpiresAt = data.tokenExpiresAt ? Date.parse(data.tokenExpiresAt) : 0;
if (linkCode || params.has("linkedToken")) { // a code works once
params.delete("linkCode"); params.delete("linkedToken");
const rest = params.toString();
history.replaceState(null, "", rest ? `${location.pathname}?${rest}` : location.pathname);
}
return !!this.token;
}
// 2. The token lives 15 minutes: ask your server again shortly before it expires, or after a 401.
async accessToken(): Promise<string | null> {
if (!this.token || this.tokenExpiresAt - Date.now() < 60_000) await this.init();
return this.token;
}
async getCoin(): Promise<number> {
const call = async () => fetch(`${this.apiBase}/user/get-coin`, {
headers: { Authorization: `Bearer ${await this.accessToken()}` },
});
let res = await call();
if (res.status === 401) { this.token = null; res = await call(); } // renew once, then give up
return res.ok ? Number((await res.json()).amount ?? 0) : 0;
}
// 3. Pages: a fresh one-time code for every page you open, instead of the token.
async pageUrl(path: string, o: PageOptions = {}): Promise<string> {
const res = await fetch("/api/platform/link-code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceId: this.deviceId }),
});
const { linkCode } = await res.json();
const url = new URL(bazinessPageUrl(this.webBase, path, this.packageName, null, o)); // no token any more
url.searchParams.set("linkCode", linkCode);
return url.toString();
}
// 4. The page sent `logout`: tell your server first, then start over as before.
async onPageLogout() {
await fetch("/api/platform/logout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceId: this.deviceId }),
}).catch(() => {});
this.resetAndRestart(); // unchanged: delete local data, new visitor
}// BazinessLogin — the parts that change.
public DateTimeOffset TokenExpiresAt { get; private set; }
[Serializable] class LinkCodeResponse { public string linkCode; }
// 1. Boot: forward the one-time ?linkCode= / deep-link code (it replaces linkedToken).
// Stop saving the token in PlayerPrefs: your server keeps the session.
public IEnumerator Init(string deviceId, string linkCode = null)
{
var body = $"{{\"deviceId\":\"{deviceId}\",\"linkCode\":{Quote(linkCode)}}}";
using var req = Post("/api/platform/token", body);
yield return req.SendWebRequest();
var r = JsonUtility.FromJson<TokenResponse>(req.downloadHandler.text); // TokenResponse gains: string tokenExpiresAt
if (!string.IsNullOrEmpty(r.switchToDeviceId)) { SwitchTo(r.switchToDeviceId); yield break; } // unchanged
Token = r.token;
TokenExpiresAt = DateTimeOffset.TryParse(r.tokenExpiresAt, out var t) ? t : DateTimeOffset.MinValue;
}
// 2. The token lives 15 minutes: renew it through your server before using it.
public IEnumerator EnsureFreshToken(string deviceId)
{
if (string.IsNullOrEmpty(Token) || TokenExpiresAt - DateTimeOffset.UtcNow < TimeSpan.FromMinutes(1))
yield return Init(deviceId);
}
// 3. Pages: a fresh one-time code for every page you open, instead of the token.
public IEnumerator OpenPage(string deviceId, string path, Action<string> open,
string redirect = null, bool allowGuest = true, string extra = null)
{
using var req = Post("/api/platform/link-code", $"{{\"deviceId\":\"{deviceId}\"}}");
yield return req.SendWebRequest();
var linkCode = JsonUtility.FromJson<LinkCodeResponse>(req.downloadHandler.text).linkCode;
open(PageUrl(path, linkCode, redirect, allowGuest, extra));
}
public string PageUrl(string path, string linkCode, string redirect = null, bool allowGuest = true, string extra = null)
{
var url = $"{webBase.TrimEnd('/')}{path}?packageName={Uri.EscapeDataString(packageName)}&sdkType=js";
if (!string.IsNullOrEmpty(linkCode)) url += $"&linkCode={Uri.EscapeDataString(linkCode)}"; // was linkedToken
if (!string.IsNullOrEmpty(redirect)) url += $"&redirect={redirect}";
if (!allowGuest) url += "&allowGuest=false";
if (!string.IsNullOrEmpty(extra)) url += $"&{extra}";
return url;
}endpointهای جدید پلتفرم
همه زیر همان {api} همیشگی هستند. endpointهای client مثل قبل توکن کلاینت لازم دارند.
ساخت کاربر همراه با نشست
POST {api}/v2/client/create-user
Authorization: Bearer <client token>
Content-Type: application/json
{ "deviceId": "8f1c2a…", "platform": "Android", "store": "CafeBazaar", "linkCode": null }
→ 200 OK
{
"userId": 239236,
"token": "eyJhbGciOiJIUzI1NiIs…", // the access token (same as tokens.accessToken)
"isLinkedToAccount": false,
"tokens": {
"accessToken": "eyJhbGciOiJIUzI1NiIs…", // 15 minutes — the client uses this
"accessTokenExpiresAt": "2026-10-22T10:15:00+00:00",
"refreshToken": "rt1.dkUoqaDDG8YB15KAQNO1q77FE…", // keep on YOUR server only
"refreshTokenExpiresAt": "2026-11-21T10:00:00+00:00"
}
}
→ 400 { "success": false, "error": "invalid_link_code" } // the code expired or was already usedتمدید نشست
توکن کلاینت لازم نیست: خود توکن تمدید مدرک است. این را از سرور خودتان صدا بزنید.
POST {api}/auth/refresh
Content-Type: application/json
{ "refreshToken": "rt1.dkUoqaDDG8YB15KAQNO1q77FE…" }
→ 200 OK
{
"userId": 239236,
"isLinkedToAccount": true,
"accessToken": "eyJhbGciOiJIUzI1NiIs…",
"accessTokenExpiresAt": "2026-10-22T10:30:00+00:00",
"refreshToken": "rt1.Wn0QhZ…", // store THIS one — the old one no longer works
"refreshTokenExpiresAt": "2026-11-21T10:15:00+00:00"
}
→ 401 { "success": false, "error": "refresh_token_rotated" } // another request refreshed first: reload yours, retry once
→ 401 { "success": false, "error": "session_revoked" } // also invalid_refresh_token, refresh_token_expired,
// refresh_token_reused: get a new session
→ 403 { "success": false, "error": "account_suspended" }نشست تازه برای بازیکن آشنا
POST {api}/v2/client/get-user-token/239236
Authorization: Bearer <client token>
→ 200 OK
{ "userId": 239236, "token": "eyJhbGciOiJIUzI1NiIs…", "isLinkedToAccount": true,
"tokens": { "accessToken": "…", "accessTokenExpiresAt": "…", "refreshToken": "rt1.…", "refreshTokenExpiresAt": "…" } }
→ 403 { "success": false, "error": "access denied" } // the user belongs to another gameکد اتصال برای باز کردن صفحه
POST {api}/v2/client/link-code/239236
Authorization: Bearer <client token>
→ 200 OK
{ "code": "q3Jc9yX2tL0…", "expiresAt": "2026-10-22T10:02:00+00:00" } // one page, once, ~2 minutes
→ 403 { "success": false, "error": "access denied" } // the user belongs to another gameبررسی توکن (در بدنه بهجای آدرس)
POST {api}/client/validate-user-token
Authorization: Bearer <client token>
Content-Type: application/json
{ "token": "eyJhbGciOiJIUzI1NiIs…" }
→ 200 OK
{ "userId": 239236, "isValid": true }
→ 200 OK (invalid or expired: not an HTTP error)
{ "userId": 0, "isValid": false }بستن نشست
POST {api}/auth/logout با { "refreshToken": "rt1.…" }. همیشه 200 جواب میدهد.
خطاها
| پاسخ | کجا | یعنی | چه کنید |
|---|---|---|---|
401 refresh_token_rotated |
auth/refresh |
درخواست دیگری از همین بازیکن زودتر تمدید کرده | توکن تمدید را دوباره بخوانید و یک بار دیگر امتحان کنید |
401 session_revoked، refresh_token_expired، invalid_refresh_token، refresh_token_reused |
auth/refresh |
نشست تمام شده | v2/client/get-user-token/{id} و ذخیرهی توکن تمدید تازه |
403 account_suspended |
auth/refresh |
حساب مسدود است | پیام مسدودی را نشان دهید |
400 invalid_link_code |
v2/client/create-user |
کد منقضی یا مصرف شده، یا برای بازی شما نبوده | بدون کد ادامه دهید |
403 |
v2/client/link-code/{id}، v2/client/get-user-token/{id} |
کاربر مال بازی دیگری است | userId ذخیرهشده را بررسی کنید |
401 |
user/get-coin (کلاینت) |
توکن ۱۵ دقیقهای منقضی شده | دوباره /api/platform/token و یک بار تکرار |
چکلیست
روی پلتفرم توسعه تست کنید (هر شمارهای، کد 111111):
- بازیکن جدید بازی را باز میکند: کلاینت توکن میگیرد و دیتابیس شما برایش توکن تمدید دارد.
- اجرای دوباره:
auth/refreshاستفاده میشود (نهget-user-token) و توکن تمدید ذخیرهشده عوض شده. - دو اجرای همزمان یک بازیکن: هر دو موفقاند (یکی با تکرار بعد از
rotated). - بازیکنی از قبل از مهاجرت یک بار بازی را باز میکند و توکن تمدید میگیرد.
- همهی صفحههای بازینس با
linkCode=…و بدونlinkedTokenباز میشوند؛ صفحهی ورود، پروفایل، فروشگاه جایزه و صفحهی پیشنهاد همه کار میکنند. - ورود در صفحه با شمارهای که مال بازیکن دیگری از بازیکنان شماست، همچنان به آن بازیکن جابهجا میشود.
- باز کردن بازی از اپ بازینس با
?linkCode=به حساب درست میرسد؛ همان لینک را دو بار باز کنید ← بار دوم کد نادیده گرفته میشود و بازی باز هم بالا میآید. - بعد از ۱۵ دقیقه بازی، موجودی سکه هنوز بارگذاری میشود (توکن از طریق سرور شما تمدید شده).
-
logoutاز صفحه نشست را میبندد؛ اجرای بعدی یک بازدیدکنندهی جدید است. - در لاگهای شما توکن تمدید نیست و هیچ توکنی در آدرسها نیست.
نکته
تا تاریخ پایان، اپ بازینس
?linkedToken=را هم میفرستد. وقتی بازی شماlinkCodeرا میخواند،linkedTokenرا نادیده بگیرید. بعد از تاریخ پایان دیگر فرستاده نمیشود و توکنهای قدیمی هم کار نمیکنند.