diff --git a/CLAUDE.md b/CLAUDE.md index 106e82f..af16d8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,8 +91,11 @@ NewEndpoint: async (Data: object): Promise => { No routing configuration needed - pathname automatically maps to function name. #### Authentication -- Uses `CheckToken` which validates session ID against XMOJ website -- Session tokens are hashed (SHA3) and cached in `phpsessid` table +- `CheckToken` accepts either a backend-issued `{Token}` (current clients) or a legacy `{SessionID, Username}` (old clients) +- `Login` (in `Source/Process.ts`) swaps a verified PHPSESSID for a token issued by `Source/SessionToken.ts`; tokens are stored as SHA3 hashes in `session_token`, expire after 30 days unused, and are revoked by `Logout`. The username always comes from the token row +- Legacy PHPSESSIDs are validated against the XMOJ website and cached as SHA3 hashes in the `phpsessid` table +- Token requests carry no PHPSESSID. Code that must scrape xmoj as the user calls `this.RequireSession()` first, which answers `SessionRequired`; the client then retries once with `{Token, SessionID}`. Keep such scrapes rare, since we want to see users' PHPSESSIDs as little as possible +- `problem_score` caches users' best scores so `GetStd` doesn't need to scrape (and so doesn't need a session) once a score has cleared the threshold - Admin users are hardcoded in `AdminUserList` array - Use `this.IsAdmin()`, `this.IsSilenced()`, `this.DenyMessage()`, `this.DenyEdit()` for permission checks @@ -117,7 +120,9 @@ Tables use snake_case naming: - `short_message`: Encrypted private messages between users - `badge`: User badges with custom colors and content - `std_answer`: Standard solution codes for problems -- `phpsessid`: Cached session tokens +- `phpsessid`: Cached hashes of verified PHPSESSIDs (legacy auth) +- `session_token`: Hashes of backend-issued session tokens +- `problem_score`: Best score per user and problem, scraped from XMOJ ### Testing Conventions diff --git a/Source/NotificationManager.ts b/Source/NotificationManager.ts index 9891829..a911b55 100644 --- a/Source/NotificationManager.ts +++ b/Source/NotificationManager.ts @@ -164,9 +164,13 @@ export class NotificationManager { }); this.addSession(userId, server); + // Only set when the worker minted a token for a client that connected + // with its PHPSESSID. It is handed over once and never stored here. + const issuedToken = url.searchParams.get("issuedToken"); server.send(JSON.stringify({ type: "connected", - timestamp: Date.now() + timestamp: Date.now(), + ...(issuedToken ? {token: issuedToken} : {}) })); return new Response(null, {status: 101, webSocket: client}); diff --git a/Source/Process.ts b/Source/Process.ts index 6747f1a..e8c7ea1 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -25,6 +25,7 @@ import * as sqlstring from 'sqlstring'; // @ts-ignore import CryptoJS from "crypto-js"; import {AnalyticsEngineDataset, D1Database, D1DatabaseSession, DurableObjectNamespace, KVNamespace} from "@cloudflare/workers-types"; +import {IssueSessionToken, ResolveSessionToken, RevokeSessionToken} from "./SessionToken"; interface Environment { API_TOKEN: string; @@ -241,18 +242,25 @@ export class Process { private readonly shortMessageEncryptKey_v1: string; private readonly API_TOKEN: string; private Username: string; - private SessionID: string; + // Empty unless this request carried a verified PHPSESSID. Token requests + // only bring one when RequireSession asked for it. + private SessionID: string = ""; + private SessionToken: string = ""; private readonly RemoteIP: string; private XMOJDatabase: Database; private readonly logs: AnalyticsEngineDataset; private readonly notifications: DurableObjectNamespace; private readonly notificationPushToken: string; private RequestData: Request; - private Fetch = async (RequestURL: URL): Promise => { + private Fetch = async (RequestURL: URL, WithSession: boolean = true): Promise => { Output.Log("Fetch: " + RequestURL.toString()); + const Headers: Record = {}; + if (WithSession && this.SessionID !== "") { + Headers["Cookie"] = "PHPSESSID=" + this.SessionID; + } const RequestData = new Request(RequestURL, { headers: { - "Cookie": "PHPSESSID=" + this.SessionID, + ...Headers, "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", "accept": "*/*", "accept-language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7", @@ -286,6 +294,47 @@ export class Process { return new Result(true, "参数检测通过"); } public CheckToken = async (Data: object): Promise => { + if (Data["Token"] !== undefined) { + return await this.CheckSessionToken(Data); + } + return await this.CheckSessionID(Data); + } + // Token requests never send the username: it comes from the token row. A + // PHPSESSID rides along only when RequireSession asked for one, and then it + // must belong to the same user, or a token could borrow someone else's cookie. + public CheckSessionToken = async (Data: object): Promise => { + const Checklist = {"Token": "string"}; + if (Data["SessionID"] !== undefined) { + Checklist["SessionID"] = "string"; + } + ThrowErrorIfFailed(this.CheckParams(Data, Checklist)); + const TokenUsername = await ResolveSessionToken(this.XMOJDatabase, Data["Token"]); + if (TokenUsername === "") { + return new Result(false, "令牌已失效", {"TokenInvalid": true}); + } + if (Data["SessionID"] !== undefined) { + const SessionResult = await this.CheckSessionID({ + "SessionID": Data["SessionID"], + "Username": TokenUsername + }); + if (!SessionResult.Success) { + this.SessionID = ""; + return SessionResult; + } + } + this.SessionToken = Data["Token"]; + this.Username = TokenUsername; + return new Result(true, "令牌匹配"); + } + // Scrapes that need a logged-in xmoj session call this first. Token clients + // answer SessionRequired by retrying once with their PHPSESSID, so we only see + // it when it is actually going to be used. + public RequireSession = (): void => { + if (this.SessionID === "") { + throw new Result(false, "需要会话", {"SessionRequired": true}); + } + } + public CheckSessionID = async (Data: object): Promise => { ThrowErrorIfFailed(this.CheckParams(Data, { "SessionID": "string", "Username": "string" @@ -355,12 +404,15 @@ export class Process { } if (ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("phpsessid", { user_id: Username + }))["TableSize"] > 0 || ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("session_token", { + user_id: Username }))["TableSize"] > 0) { return new Result(true, "用户检查成功", { "Exist": true }); } - return await this.Fetch(new URL("https://www.xmoj.tech/userinfo.php?user=" + Username)) + // userinfo.php is public, so this never needs the caller's cookie. + return await this.Fetch(new URL("https://www.xmoj.tech/userinfo.php?user=" + Username), false) .then((Response) => { return Response.text(); }).then((Response) => { @@ -475,8 +527,54 @@ export class Process { return 0; }); } - public GetProblemScoreChecker = async (ProblemID: number): Promise => { - return await this.GetProblemScore(ProblemID); + // Scores only go up, so a cached score that already clears RequiredScore is + // final. Anything less needs a fresh scrape of status.php, which needs a login. + public GetProblemScoreChecker = async (ProblemID: number, RequiredScore: number): Promise => { + const Cached = ThrowErrorIfFailed(await this.XMOJDatabase.Select("problem_score", ["score"], { + user_id: this.Username, + problem_id: ProblemID + })) as Array>; + const CachedScore: number = Cached.length === 0 ? 0 : Cached[0]["score"]; + if (CachedScore >= RequiredScore) { + return CachedScore; + } + this.RequireSession(); + const Score = await this.GetProblemScore(ProblemID); + // The cache is an optimisation, so a failed write must not fail the + // request. Two requests can both see no row; the loser's insert hits the + // primary key and falls back to raising the score, never lowering it. + if (Score > CachedScore) { + let Inserted = false; + if (Cached.length === 0) { + Inserted = await this.XMOJDatabase.Insert("problem_score", { + user_id: this.Username, + problem_id: ProblemID, + score: Score + }).then((InsertResult) => InsertResult.Success, () => false); + } + if (!Inserted) { + const Changes = await this.XMOJDatabase.Update("problem_score", {score: Score}, { + user_id: this.Username, + problem_id: ProblemID, + score: {Operator: "<", Value: Score} + }).then((UpdateResult) => UpdateResult.Data["Changes"], (Error) => { + Output.Warn("Caching problem score failed: " + JSON.stringify(Error)); + return -1; + }); + // Nothing changed: a request racing us already cached at least this + // much, and its scrape is newer than ours, so trust it. + if (Changes === 0) { + const Latest = await this.XMOJDatabase.Select("problem_score", ["score"], { + user_id: this.Username, + problem_id: ProblemID + }).then((SelectResult) => SelectResult.Data as Array>, () => []); + if (Latest.length > 0) { + return Math.max(Score, Latest[0]["score"]); + } + } + } + } + return Score; } public processCppString(inputStr: string) { @@ -1380,9 +1478,12 @@ export class Process { } return new Result(true, "此题已经有人上传标程"); } - if (await this.GetProblemScoreChecker(ProblemID) !== 100) { + if (await this.GetProblemScoreChecker(ProblemID, 100) < 100) { return new Result(false, "没有权限上传此标程"); } + // A cached score skips the check above, but the scrapes below still + // read xmoj as this user. + this.RequireSession(); let StdCode: string = ""; let PageIndex: number = 0; while (StdCode === "") { @@ -1475,6 +1576,22 @@ export class Process { await RebuildStdList(this.XMOJDatabase, this.kv); return new Result(true, "标程上传成功"); }, + // Swaps a verified PHPSESSID for a token of ours, so later requests need + // neither the cookie nor a round-trip to xmoj. + Login: async (Data: object): Promise => { + ThrowErrorIfFailed(this.CheckParams(Data, {})); + if (this.SessionID === "") { + return new Result(false, "登录需要会话"); + } + return new Result(true, "登录成功", await IssueSessionToken(this.XMOJDatabase, this.Username)); + }, + Logout: async (Data: object): Promise => { + ThrowErrorIfFailed(this.CheckParams(Data, {})); + if (this.SessionToken !== "") { + await RevokeSessionToken(this.XMOJDatabase, this.SessionToken); + } + return new Result(true, "登出成功"); + }, GetStdList: async (Data: object): Promise => { ThrowErrorIfFailed(this.CheckParams(Data, {})); const ResponseData = { @@ -1495,7 +1612,7 @@ export class Process { ThrowErrorIfFailed(this.CheckParams(Data, { "ProblemID": "number" })); - if (await this.GetProblemScoreChecker(Data["ProblemID"]) < 50) { + if (await this.GetProblemScoreChecker(Data["ProblemID"], 50) < 50) { return new Result(false, "没有权限获取此标程"); } const Std = ThrowErrorIfFailed(await this.XMOJDatabase.Select("std_answer", ["std_code"], { @@ -1963,14 +2080,17 @@ export class Process { "Version": "string", "DebugMode": "boolean" })); + // A legacy check can fail on a slow xmoj fetch, so it gets one retry. A + // token check is a database lookup and its answer will not change. let TokenFailedCount = 0; while (true) { - if ((await this.CheckToken(RequestJSON["Authentication"])).Data["Success"]) { + const TokenResult = await this.CheckToken(RequestJSON["Authentication"]); + if (TokenResult.Success) { break; } TokenFailedCount++; - if (TokenFailedCount >= 2) { - ThrowErrorIfFailed(await this.CheckToken(RequestJSON["Authentication"])); + if (TokenFailedCount >= 2 || RequestJSON["Authentication"]["Token"] !== undefined) { + ThrowErrorIfFailed(TokenResult); break; } } diff --git a/Source/SessionToken.ts b/Source/SessionToken.ts new file mode 100644 index 0000000..802d4cc --- /dev/null +++ b/Source/SessionToken.ts @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2023-2025 XMOJ-bbs contributors + * This file is part of XMOJ-bbs. + * XMOJ-bbs is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * XMOJ-bbs is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with XMOJ-bbs. If not, see . + */ + +// Session tokens are issued by us in exchange for a verified xmoj.tech +// PHPSESSID. They stand for the user, not for a PHPSESSID: xmoj logs users out +// at least once a day and the script logs them back in, and none of that should +// cost a slow round-trip to xmoj or another look at the user's cookie. A token +// only dies when it goes unused for SessionTokenLifetime or the user logs out. + +import {ThrowErrorIfFailed} from "./Result"; +import {Database} from "./Database"; +// @ts-ignore +import CryptoJS from "crypto-js"; + +export const SessionTokenLifetime = 1000 * 60 * 60 * 24 * 30; +// last_used only needs to be accurate to well within the lifetime; refreshing +// it on every request would turn every read into a D1 write. +const LastUsedResolution = 1000 * 60 * 60 * 24; + +export const IsValidSessionToken = (Token: string): boolean => { + return /^[0-9a-f]{64}$/.test(Token); +}; + +// Only the hash is stored, so a leaked table cannot be replayed. +export const HashSessionToken = (Token: string): string => { + return CryptoJS.SHA3(Token).toString(); +}; + +// No expiry is handed out: the lifetime slides with use, so any fixed time +// would go stale. +export const IssueSessionToken = async (XMOJDatabase: Database, Username: string): Promise<{ Token: string }> => { + const Bytes = new Uint8Array(32); + crypto.getRandomValues(Bytes); + const Token = Array.from(Bytes, (Byte) => Byte.toString(16).padStart(2, "0")).join(""); + const Now = new Date().getTime(); + ThrowErrorIfFailed(await XMOJDatabase.Insert("session_token", { + token_hash: HashSessionToken(Token), + user_id: Username, + create_time: Now, + last_used: Now + })); + return {Token}; +}; + +// Returns the user the token was issued to, or "" if it is unknown or expired. +export const ResolveSessionToken = async (XMOJDatabase: Database, Token: string): Promise => { + if (!IsValidSessionToken(Token)) { + return ""; + } + const TokenHash = HashSessionToken(Token); + const Rows = ThrowErrorIfFailed(await XMOJDatabase.Select("session_token", ["user_id", "last_used"], { + token_hash: TokenHash + })) as Array>; + if (Rows.length === 0) { + return ""; + } + const Now = new Date().getTime(); + if (Rows[0]["last_used"] + SessionTokenLifetime <= Now) { + await XMOJDatabase.Delete("session_token", {token_hash: TokenHash}); + return ""; + } + if (Rows[0]["last_used"] + LastUsedResolution <= Now) { + await XMOJDatabase.Update("session_token", {last_used: Now}, {token_hash: TokenHash}); + } + return Rows[0]["user_id"]; +}; + +export const RevokeSessionToken = async (XMOJDatabase: Database, Token: string): Promise => { + ThrowErrorIfFailed(await XMOJDatabase.Delete("session_token", { + token_hash: HashSessionToken(Token) + })); +}; diff --git a/Source/index.ts b/Source/index.ts index d4f3a31..726b2ad 100644 --- a/Source/index.ts +++ b/Source/index.ts @@ -18,6 +18,7 @@ import {Process, RebuildStdList} from "./Process"; import {Database} from "./Database"; import {NotificationManager} from "./NotificationManager"; +import {IssueSessionToken, ResolveSessionToken, SessionTokenLifetime} from "./SessionToken"; import type {D1Database, KVNamespace, AnalyticsEngineDataset, DurableObjectNamespace, Ai} from "@cloudflare/workers-types"; interface Environment { @@ -128,15 +129,30 @@ const HandleRequest = async (RequestData: Request, Environment: Environment): Pr const url = new URL(RequestData.url); if (url.pathname === "/ws/notifications") { + // A token is a database lookup. The PHPSESSID path costs a fetch to xmoj + // and is kept for old clients and for new ones that have no token yet; + // those ask for one with IssueToken=1 so the next connect needs neither. + const token = url.searchParams.get("Token") || ""; const sessionID = url.searchParams.get("SessionID") || ""; - if (sessionID === "") { - return new Response("Missing SessionID", {status: 401}); - } - if (!isValidSessionID(sessionID)) { - return new Response("Invalid SessionID", {status: 401}); + let userId = ""; + let issuedToken = ""; + if (token !== "") { + userId = await ResolveSessionToken(new Database(Environment.DB), token); + } else { + if (sessionID === "") { + return new Response("Missing SessionID", {status: 401}); + } + if (!isValidSessionID(sessionID)) { + return new Response("Invalid SessionID", {status: 401}); + } + userId = await ValidateSession(sessionID); + // Only mint for a real WebSocket handshake; anything else gets a 426 + // from the Durable Object and would never see the token. + if (userId !== "" && url.searchParams.get("IssueToken") === "1" && + RequestData.headers.get("Upgrade") === "websocket") { + issuedToken = (await IssueSessionToken(new Database(Environment.DB), userId)).Token; + } } - - const userId = await ValidateSession(sessionID); if (userId === "") { return new Response("Unauthorized", {status: 401}); } @@ -146,6 +162,14 @@ const HandleRequest = async (RequestData: Request, Environment: Environment): Pr const forwardURL = new URL(RequestData.url); forwardURL.searchParams.set("userId", userId); forwardURL.searchParams.delete("SessionID"); + forwardURL.searchParams.delete("Token"); + forwardURL.searchParams.delete("IssueToken"); + // The Durable Object trusts issuedToken as ours, so never pass on one + // the client supplied. + forwardURL.searchParams.delete("issuedToken"); + if (issuedToken !== "") { + forwardURL.searchParams.set("issuedToken", issuedToken); + } return await notificationStub.fetch(new Request(forwardURL.toString(), RequestData)); } @@ -154,8 +178,8 @@ const HandleRequest = async (RequestData: Request, Environment: Environment): Pr }; // Workers' automatic invocation logs are off (see wrangler.toml) because they -// record full URLs and headers, and the notification WebSocket carries the -// session ID in its query string. This is our replacement: one line per request +// record full URLs and headers, and the notification WebSocket carries a +// session ID or token in its query string. This is our replacement: one line per request // with what we need for debugging, and never the query string or headers. export const RequestLogLine = (RequestData: Request, Status: number, Duration: number, Failure?: unknown) => { const Cf: any = (RequestData as any).cf || {}; @@ -208,6 +232,12 @@ export default { "Value": new Date().getTime() - 1000 * 60 * 60 * 24 * 5 } }); + await XMOJDatabase.Delete("session_token", { + "last_used": { + "Operator": "<=", + "Value": new Date().getTime() - SessionTokenLifetime + } + }); // Reconcile the std list cache against the database. One scan per day // bounds any drift - from a dropped KV write or two uploads racing - to // 24 hours, instead of it persisting forever as it does today. diff --git a/migrations/0006_add_session_token.sql b/migrations/0006_add_session_token.sql new file mode 100644 index 0000000..fbc75b6 --- /dev/null +++ b/migrations/0006_add_session_token.sql @@ -0,0 +1,20 @@ +-- Migration number: 0006 2026-10-07 + +CREATE TABLE IF NOT EXISTS session_token ( + token_hash TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + create_time INTEGER NOT NULL, + last_used INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS session_token_user_id ON session_token (user_id); +-- The daily cleanup deletes by last_used. +CREATE INDEX IF NOT EXISTS session_token_last_used ON session_token (last_used); + +-- Best score a user has reached on a problem. Scores only go up, so once a +-- score clears a threshold it never needs to be scraped from xmoj again. +CREATE TABLE IF NOT EXISTS problem_score ( + user_id TEXT NOT NULL, + problem_id INTEGER NOT NULL, + score INTEGER NOT NULL, + PRIMARY KEY (user_id, problem_id) +); diff --git a/test/index.test.js b/test/index.test.js index 2c479f8..44d656a 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -26,3 +26,102 @@ test('RequestLogLine leaves error unset on success', () => { const line = RequestLogLine(new Request('https://api.xmoj-script.uk/GetNotice'), 200, 1); assert.strictEqual(line.error, undefined); }); + +// ---- Notification WebSocket auth ---- + +const Worker = require('../Source/index.ts').default; +const { HashSessionToken } = require('../Source/SessionToken.ts'); + +// Answers SELECTs on session_token from `tokens`, a map of token to row, +// matched on the hash the query binds, and records every query. +function wsEnvironment(tokens = {}) { + const rowsByHash = Object.fromEntries(Object.entries(tokens).map(([token, row]) => [HashSessionToken(token), row])); + const queries = []; + const forwarded = []; + const DB = { + withSession() { return this; }, + prepare(q) { + return { + bind(...args) { + return { + all: async () => { + queries.push({ q, args }); + if (q.startsWith('SELECT') && q.includes('session_token')) { + const row = rowsByHash[args[0]]; + return { results: row ? [row] : [], meta: {} }; + } + return { results: [], meta: { last_row_id: 1, changes: 1 } }; + } + }; + } + }; + } + }; + const NOTIFICATIONS = { + idFromName: (name) => name, + get: () => ({ + fetch: async (request) => { + forwarded.push(new URL(request.url)); + return new Response('forwarded'); + } + }) + }; + return { env: { DB, NOTIFICATIONS }, queries, forwarded }; +} + +// profile.php embeds the logged-in user as user_id=' +const ProfilePage = (user) => new Response("" + user + ""); + +test('WebSocket with a token resolves the user without touching xmoj', async (t) => { + const xmoj = t.mock.method(global, 'fetch', async () => ProfilePage('mallory')); + const token = 'b'.repeat(64); + const { env, forwarded } = wsEnvironment({ [token]: { user_id: 'alice', last_used: Date.now() } }); + await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?Token=' + token), env); + assert.strictEqual(xmoj.mock.calls.length, 0); + assert.strictEqual(forwarded[0].searchParams.get('userId'), 'alice'); + assert.strictEqual(forwarded[0].searchParams.get('Token'), null, 'the token is not passed on'); +}); + +test('WebSocket with an unknown token is unauthorized', async () => { + const { env, forwarded } = wsEnvironment({ ['b'.repeat(64)]: { user_id: 'alice', last_used: Date.now() } }); + const response = await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?Token=' + 'c'.repeat(64)), env); + assert.strictEqual(response.status, 401); + assert.strictEqual(forwarded.length, 0); +}); + +test('WebSocket with a PHPSESSID and IssueToken=1 mints a token for the connected message', async (t) => { + t.mock.method(global, 'fetch', async () => ProfilePage('alice')); + const { env, queries, forwarded } = wsEnvironment(); + await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?SessionID=abc123&IssueToken=1', { headers: { Upgrade: 'websocket' } }), env); + const issued = forwarded[0].searchParams.get('issuedToken'); + assert.match(issued, /^[0-9a-f]{64}$/); + assert.strictEqual(forwarded[0].searchParams.get('SessionID'), null); + assert.strictEqual(forwarded[0].searchParams.get('IssueToken'), null); + const insert = queries.find((query) => query.q.startsWith('INSERT INTO `session_token`')); + assert.ok(insert.args.includes(HashSessionToken(issued))); + assert.ok(insert.args.includes('alice')); +}); + +test('WebSocket with only a PHPSESSID behaves as before', async (t) => { + t.mock.method(global, 'fetch', async () => ProfilePage('alice')); + const { env, queries, forwarded } = wsEnvironment(); + await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?SessionID=abc123'), env); + assert.strictEqual(forwarded[0].searchParams.get('userId'), 'alice'); + assert.strictEqual(forwarded[0].searchParams.get('issuedToken'), null); + assert.strictEqual(queries.length, 0, 'no token minted'); +}); + +test('WebSocket mints no token for a request that is not a WebSocket handshake', async (t) => { + t.mock.method(global, 'fetch', async () => ProfilePage('alice')); + const { env, queries, forwarded } = wsEnvironment(); + await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?SessionID=abc123&IssueToken=1'), env); + assert.strictEqual(queries.length, 0); + assert.strictEqual(forwarded[0].searchParams.get('issuedToken'), null); +}); + +test('WebSocket drops an issuedToken the client supplied', async (t) => { + t.mock.method(global, 'fetch', async () => ProfilePage('alice')); + const { env, forwarded } = wsEnvironment(); + await Worker.fetch(new Request('https://api.xmoj-script.uk/ws/notifications?SessionID=abc123&issuedToken=' + 'd'.repeat(64)), env); + assert.strictEqual(forwarded[0].searchParams.get('issuedToken'), null); +}); diff --git a/test/process.test.js b/test/process.test.js index 3168f44..3e7d3f4 100644 --- a/test/process.test.js +++ b/test/process.test.js @@ -1295,3 +1295,249 @@ test('EditBadge refuses the edit when there is no AI at all', async () => { assert.strictEqual(result.Success, false); assert.strictEqual(result.Message, '内容审核服务暂时不可用,请稍后重试'); }); + +// ---- Session tokens ---- + +const { HashSessionToken } = require('../Source/SessionToken.ts'); +const ValidToken = 'a'.repeat(64); + +// Select stub that answers per table, so token lookups and score lookups can +// be told apart from everything else. +function selectByTable(tables) { + return test.mock.fn(async (table) => new Result(true, '', tables[table] || [])); +} + +test('CheckToken with a token takes the username from the token row', async () => { + const proc = createProcess({ + db: { Select: selectByTable({ session_token: [{ user_id: 'alice', last_used: Date.now() }] }) } + }); + proc.Username = ''; + proc.SessionID = ''; + const result = await proc.CheckToken({ Token: ValidToken }); + assert.ok(result.Success); + assert.strictEqual(proc.Username, 'alice'); + assert.strictEqual(proc.SessionID, '', 'a token alone brings no PHPSESSID'); + assert.strictEqual(proc.Fetch.mock.calls.length, 0, 'no round-trip to xmoj'); +}); + +test('CheckToken rejects a username sent alongside a token', async () => { + const proc = createProcess(); + await assert.rejects(proc.CheckToken({ Token: ValidToken, Username: 'mallory' }), (thrown) => thrown.Success === false); +}); + +test('CheckToken reports an unknown token as invalid', async () => { + const proc = createProcess({ db: { Select: selectByTable({}) } }); + const result = await proc.CheckToken({ Token: ValidToken }); + assert.strictEqual(result.Success, false); + assert.strictEqual(result.Data.TokenInvalid, true); +}); + +test('CheckToken reports a malformed token as invalid without a lookup', async () => { + const select = selectByTable({}); + const proc = createProcess({ db: { Select: select } }); + const result = await proc.CheckToken({ Token: 'not-a-token' }); + assert.strictEqual(result.Data.TokenInvalid, true); + assert.strictEqual(select.mock.calls.length, 0); +}); + +test('CheckToken expires and deletes a token unused for 30 days', async () => { + const del = test.mock.fn(async () => new Result(true, '')); + const proc = createProcess({ + db: { + Select: selectByTable({ session_token: [{ user_id: 'alice', last_used: Date.now() - 1000 * 60 * 60 * 24 * 31 }] }), + Delete: del + } + }); + const result = await proc.CheckToken({ Token: ValidToken }); + assert.strictEqual(result.Data.TokenInvalid, true); + assert.strictEqual(del.mock.calls[0].arguments[0], 'session_token'); +}); + +test('CheckToken refreshes last_used at most once a day', async () => { + const update = test.mock.fn(async () => new Result(true, '')); + const fresh = createProcess({ + db: { Select: selectByTable({ session_token: [{ user_id: 'alice', last_used: Date.now() - 1000 * 60 }] }), Update: update } + }); + await fresh.CheckToken({ Token: ValidToken }); + assert.strictEqual(update.mock.calls.length, 0); + const stale = createProcess({ + db: { Select: selectByTable({ session_token: [{ user_id: 'alice', last_used: Date.now() - 1000 * 60 * 60 * 25 }] }), Update: update } + }); + await stale.CheckToken({ Token: ValidToken }); + assert.strictEqual(update.mock.calls.length, 1); + assert.strictEqual(update.mock.calls[0].arguments[0], 'session_token'); +}); + +test('CheckToken refuses a PHPSESSID that belongs to someone else', async () => { + const proc = createProcess({ + db: { Select: selectByTable({ session_token: [{ user_id: 'alice', last_used: Date.now() }] }) }, + fetch: async () => new Response("user_id=mallory'") + }); + const result = await proc.CheckToken({ Token: ValidToken, SessionID: 'malloryssession' }); + assert.strictEqual(result.Success, false); + assert.strictEqual(proc.SessionID, '', 'the borrowed cookie must not be kept'); +}); + +test('CheckToken accepts the token owner\'s own PHPSESSID', async () => { + const proc = createProcess({ + db: { Select: selectByTable({ session_token: [{ user_id: 'alice', last_used: Date.now() }] }) }, + fetch: async () => new Response("user_id=alice'") + }); + const result = await proc.CheckToken({ Token: ValidToken, SessionID: 'alicessession' }); + assert.ok(result.Success); + assert.strictEqual(proc.Username, 'alice'); + assert.strictEqual(proc.SessionID, 'alicessession'); +}); + +test('GetStd asks for a session only when the score is not cached', async () => { + const proc = createProcess({ db: { Select: selectByTable({}) } }); + proc.SessionID = ''; + await assert.rejects(proc.ProcessFunctions['GetStd']({ ProblemID: 1000 }), (thrown) => { + assert.strictEqual(thrown.Data.SessionRequired, true); + return true; + }); + assert.strictEqual(proc.Fetch.mock.calls.length, 0); +}); + +test('GetStd serves a cached passing score without a session or a scrape', async () => { + const proc = createProcess({ + db: { + Select: selectByTable({ + problem_score: [{ score: 100 }], + std_answer: [{ std_code: 'int main(){}' }] + }) + } + }); + proc.SessionID = ''; + const result = await proc.ProcessFunctions['GetStd']({ ProblemID: 1000 }); + assert.ok(result.Success); + assert.strictEqual(proc.Fetch.mock.calls.length, 0); +}); + +test('A scraped score is cached for next time', async () => { + const insert = test.mock.fn(async () => new Result(true, '', { InsertID: 1 })); + const proc = createProcess({ db: { Select: selectByTable({}), Insert: insert } }); + proc.GetProblemScore = async () => 100; + assert.strictEqual(await proc.GetProblemScoreChecker(1000, 50), 100); + assert.strictEqual(insert.mock.calls[0].arguments[0], 'problem_score'); + assert.deepStrictEqual(insert.mock.calls[0].arguments[1], { user_id: 'testuser', problem_id: 1000, score: 100 }); +}); + +test('IfUserExist never sends the caller\'s cookie', async () => { + const proc = createProcess({ + db: { GetTableSize: async () => new Result(true, '', { TableSize: 0 }) }, + fetch: async () => new Response('some content') + }); + await proc.IfUserExist('someone'); + assert.strictEqual(proc.Fetch.mock.calls[0].arguments[1], false); +}); + +test('IfUserExist counts users who only have a session token', async () => { + const proc = createProcess({ + db: { GetTableSize: async (table) => new Result(true, '', { TableSize: table === 'session_token' ? 1 : 0 }) } + }); + const result = await proc.IfUserExist('alice'); + assert.strictEqual(result.Data.Exist, true); + assert.strictEqual(proc.Fetch.mock.calls.length, 0); +}); + +test('Login issues a token and stores only its hash', async () => { + const insert = test.mock.fn(async () => new Result(true, '', { InsertID: 1 })); + const proc = createProcess({ db: { Insert: insert } }); + const result = await proc.ProcessFunctions['Login']({}); + assert.ok(result.Success); + assert.match(result.Data.Token, /^[0-9a-f]{64}$/); + const [table, row] = insert.mock.calls[0].arguments; + assert.strictEqual(table, 'session_token'); + assert.strictEqual(row.user_id, 'testuser'); + assert.strictEqual(row.token_hash, HashSessionToken(result.Data.Token)); + assert.ok(!JSON.stringify(row).includes(result.Data.Token)); +}); + +test('Login refuses a request that only carried a token', async () => { + const proc = createProcess(); + proc.SessionID = ''; + const result = await proc.ProcessFunctions['Login']({}); + assert.strictEqual(result.Success, false); +}); + +test('Logout revokes the token the request came with', async () => { + const del = test.mock.fn(async () => new Result(true, '')); + const proc = createProcess({ + db: { Select: selectByTable({ session_token: [{ user_id: 'alice', last_used: Date.now() }] }), Delete: del } + }); + await proc.CheckToken({ Token: ValidToken }); + await proc.ProcessFunctions['Logout']({}); + assert.strictEqual(del.mock.calls[0].arguments[0], 'session_token'); + assert.deepStrictEqual(del.mock.calls[0].arguments[1], { token_hash: HashSessionToken(ValidToken) }); +}); + +function apiRequest(path, authentication) { + return new Request('https://api.xmoj-script.uk/' + path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ Authentication: authentication, Data: {}, Version: '4.0.1', DebugMode: false }) + }); +} + +test('Process checks a token once and passes TokenInvalid to the client', async () => { + const select = selectByTable({}); + const proc = createProcess({ db: { Select: select }, req: apiRequest('GetBoards', { Token: ValidToken }) }); + const body = await (await proc.Process()).json(); + assert.strictEqual(body.Success, false); + assert.strictEqual(body.Data.TokenInvalid, true); + assert.strictEqual(select.mock.calls.length, 1, 'no retry for a database answer'); +}); + +test('Process checks a valid legacy session once, not three times', async () => { + const proc = createProcess({ + db: { Select: async () => new Result(true, '', [{ user_id: 'testuser', create_time: Date.now() }]) }, + req: apiRequest('GetBoards', { SessionID: 'testsession', Username: 'testuser' }) + }); + const check = test.mock.method(proc, 'CheckToken'); + await proc.Process(); + assert.strictEqual(check.mock.calls.length, 1); +}); + +test('Losing the race to cache a score raises the cached one instead of failing the request', async () => { + const update = test.mock.fn(async () => new Result(true, '', { Changes: 1 })); + const proc = createProcess({ + db: { + Select: selectByTable({}), + Insert: async () => { throw new Result(false, 'UNIQUE constraint failed'); }, + Update: update + } + }); + proc.GetProblemScore = async () => 100; + assert.strictEqual(await proc.GetProblemScoreChecker(1000, 50), 100); + const [table, data, condition] = update.mock.calls[0].arguments; + assert.strictEqual(table, 'problem_score'); + assert.deepStrictEqual(data, { score: 100 }); + assert.deepStrictEqual(condition.score, { Operator: '<', Value: 100 }, 'never lowers a cached score'); +}); + +test('A failed score cache write does not fail the request', async () => { + const proc = createProcess({ + db: { + Select: selectByTable({}), + Insert: async () => { throw new Result(false, 'db down'); }, + Update: async () => { throw new Result(false, 'db down'); } + } + }); + proc.GetProblemScore = async () => 100; + assert.strictEqual(await proc.GetProblemScoreChecker(1000, 50), 100); +}); + +test('A score cached by a racing request wins over our older, lower scrape', async () => { + let selects = 0; + const proc = createProcess({ + db: { + // Empty before the scrape; by the time we write, a racer cached 100. + Select: async () => new Result(true, '', selects++ === 0 ? [] : [{ score: 100 }]), + Insert: async () => { throw new Result(false, 'UNIQUE constraint failed'); }, + Update: async () => new Result(true, '', { Changes: 0 }) + } + }); + proc.GetProblemScore = async () => 40; + assert.strictEqual(await proc.GetProblemScoreChecker(1000, 50), 100); +});