Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,11 @@ NewEndpoint: async (Data: object): Promise<Result> => {
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

Expand All @@ -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

Expand Down
6 changes: 5 additions & 1 deletion Source/NotificationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down
142 changes: 131 additions & 11 deletions Source/Process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Response> => {
private Fetch = async (RequestURL: URL, WithSession: boolean = true): Promise<Response> => {
Output.Log("Fetch: " + RequestURL.toString());
const Headers: Record<string, string> = {};
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",
Expand Down Expand Up @@ -286,6 +294,47 @@ export class Process {
return new Result(true, "参数检测通过");
}
public CheckToken = async (Data: object): Promise<Result> => {
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<Result> => {
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<Result> => {
ThrowErrorIfFailed(this.CheckParams(Data, {
"SessionID": "string",
"Username": "string"
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -475,8 +527,54 @@ export class Process {
return 0;
});
}
public GetProblemScoreChecker = async (ProblemID: number): Promise<number> => {
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<number> => {
const Cached = ThrowErrorIfFailed(await this.XMOJDatabase.Select("problem_score", ["score"], {
user_id: this.Username,
problem_id: ProblemID
})) as Array<Record<string, any>>;
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}
Comment thread
boomzero marked this conversation as resolved.
}).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<Record<string, any>>, () => []);
if (Latest.length > 0) {
return Math.max(Score, Latest[0]["score"]);
}
}
}
}
return Score;
}

public processCppString(inputStr: string) {
Expand Down Expand Up @@ -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 === "") {
Expand Down Expand Up @@ -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<Result> => {
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<Result> => {
ThrowErrorIfFailed(this.CheckParams(Data, {}));
if (this.SessionToken !== "") {
await RevokeSessionToken(this.XMOJDatabase, this.SessionToken);
}
return new Result(true, "登出成功");
},
GetStdList: async (Data: object): Promise<Result> => {
ThrowErrorIfFailed(this.CheckParams(Data, {}));
const ResponseData = {
Expand All @@ -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"], {
Expand Down Expand Up @@ -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;
}
}
Expand Down
86 changes: 86 additions & 0 deletions Source/SessionToken.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

// 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<string> => {
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<Record<string, any>>;
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<void> => {
ThrowErrorIfFailed(await XMOJDatabase.Delete("session_token", {
token_hash: HashSessionToken(Token)
}));
};
Loading
Loading