feat: add admin backend and compose deployment

This commit is contained in:
2026-06-24 13:40:33 +08:00
parent bc5aff7ba7
commit 19e1ccf855
20 changed files with 3569 additions and 1511 deletions

View File

@@ -11,6 +11,8 @@ config.js
.env .env
session.json session.json
refresh.token refresh.token
auth.sqlite
data/
logs/ logs/
*.log *.log

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
STEAM_CHAT_IMAGE=steam-chat:latest
STEAM_CHAT_CONTAINER_NAME=steam-chat
STEAM_CHAT_BIND=0.0.0.0
STEAM_CHAT_PORT=3000

2
.gitignore vendored
View File

@@ -8,10 +8,12 @@ config.js
# Logs # Logs
logs/ logs/
*.log *.log
data/
# Auth / Secrets # Auth / Secrets
session.json session.json
refresh.token refresh.token
auth.sqlite
# Build # Build
dist/ dist/

View File

@@ -15,12 +15,15 @@ RUN npm run build \
FROM node:26-bookworm-slim AS runtime FROM node:26-bookworm-slim AS runtime
ENV NODE_ENV=production ENV NODE_ENV=production
ENV STEAM_CHAT_DATA_DIR=/app/data
ENV STEAM_CHAT_HOST=0.0.0.0
ENV STEAM_CHAT_PORT=3000
WORKDIR /app WORKDIR /app
RUN chown node:node /app RUN mkdir -p /app/data \
&& chown -R node:node /app
COPY --from=build --chown=node:node /app/package.json /app/package-lock.json ./ COPY --from=build --chown=node:node /app/package.json /app/package-lock.json ./
COPY --from=build --chown=node:node /app/config.example.js ./
COPY --from=build --chown=node:node /app/node_modules ./node_modules COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist COPY --from=build --chown=node:node /app/dist ./dist

32
compose.yaml Normal file
View File

@@ -0,0 +1,32 @@
services:
steam-chat:
image: ${STEAM_CHAT_IMAGE:-steam-chat:latest}
build:
context: .
dockerfile: Dockerfile
container_name: ${STEAM_CHAT_CONTAINER_NAME:-steam-chat}
restart: unless-stopped
init: true
ports:
- "${STEAM_CHAT_BIND:-0.0.0.0}:${STEAM_CHAT_PORT:-3000}:3000"
environment:
NODE_ENV: production
STEAM_CHAT_DATA_DIR: /app/data
STEAM_CHAT_HOST: 0.0.0.0
STEAM_CHAT_PORT: 3000
STEAM_CHAT_WS_PATH: /ws
volumes:
- steam-chat-data:/app/data
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
steam-chat-data:

300
docs/backend-redesign.md Normal file
View File

@@ -0,0 +1,300 @@
# Steam Chat 后台改造设计
## 目标
本次改造要把当前的 Steam Chat 从“启动时读取 `config.js` 并立即登录 Steam”改成“应用先启动后台用户登录后再由管理员在 Web 页面完成 Steam 登录”。
核心目标:
- 不再依赖 `config.js` 保存 Steam 账号密码。
- 服务无 Steam token 时也能启动并打开 Web 后台。
- 增加 SQLite 多用户后台,管理员负责用户管理和 Steam 登录,普通用户只使用已连接的 Steam 会话聊天。
- 前端完全重写为后台应用,不再在现有聊天页面上追加登录和管理功能。
- 首次 Steam 登录成功后只持久化 `refresh.token`,不保存 Steam 明文密码或 Steam Guard 验证码。
- 保留现有聊天、好友、群组、历史、图片和 WebSocket 能力。
## 当前问题
当前入口在启动时调用 Steam 登录流程。如果项目根目录没有 `config.js`,会退回 `config.example.js`;示例配置没有真实 Steam 凭据时,登录流程会报错,应用也无法作为一个可配置的后台服务正常交付。
当前持久化路径也分散在项目根目录下:
- `refresh.token`
- `logs/chat.jsonl`
- `logs/images/`
- `logs/stickers/`
容器部署后这些状态必须进入统一数据目录,否则镜像重建或容器重建会丢失登录 token、聊天记录和媒体缓存。
## 运行模型
改造后的启动顺序:
1. 读取环境变量和数据目录配置。
2. 初始化 SQLite 用户库和应用密钥。
3. 启动 HTTP/WebSocket 服务。
4. 如果数据目录存在 `refresh.token`,后台自动尝试 Steam token 登录。
5. 如果没有 tokenWeb 后台保持可访问,管理员在页面发起 Steam 登录。
Steam 登录状态由后端统一维护:
- `logged_out`:未登录 Steam聊天发送、好友、群组、表情等依赖 Steam 的接口返回 503。
- `logging_in`:正在提交 Steam 登录。
- `waiting_guard`Steam 要求邮箱码或手机 2FA等待管理员提交验证码。
- `online`Steam 登录成功,聊天功能可用。
- `error`:最近一次登录失败,状态接口返回错误信息。
- `reconnecting`:已登录后断线,后台自动重连或等待管理员处理。
Web 页面在应用登录后始终可打开。未连接 Steam 时,聊天区域显示当前 Steam 状态;管理员可看到 Steam 登录表单,普通用户只能看到等待连接状态。
## 用户系统
使用 Node 26 内置 `node:sqlite`,不引入额外数据库容器或 native npm 依赖。
SQLite 文件位置:
```text
${STEAM_CHAT_DATA_DIR}/auth.sqlite
```
建议表结构:
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
disabled INTEGER NOT NULL DEFAULT 0,
session_version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login_at TEXT
);
CREATE TABLE app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
密码规则:
- 用户名长度 3 到 64只允许字母、数字、下划线、短横线和点。
- 密码最少 8 位。
- 使用 `crypto.scryptSync` 加随机盐保存密码哈希。
- 哈希字段保存为自描述格式,例如 `scrypt:v1:<salt>:<hash>`
- 登录、重置密码、禁用、启用、角色变更时不记录明文密码。
首次访问时,如果 `users` 表为空,前端显示初始化管理员页面。初始化接口创建第一个启用的 `admin` 用户。之后该接口必须拒绝再次调用。
权限规则:
- `admin`:可管理后台用户,可执行 Steam 登录、Steam Guard 提交、Steam 退出。
- `user`:可使用已登录 Steam 会话聊天,可读取聊天所需的好友、群组、历史和媒体接口。
- 不能删除当前登录用户自己。
- 不能删除、禁用或降级最后一个启用的管理员。
- 禁用用户后,该用户已有 Cookie 必须失效。
## 应用会话
使用签名 Cookie不引入服务端 session 表。
Cookie
- 名称:`steam_chat_session`
- 属性:`HttpOnly``SameSite=Lax``Path=/`
- 过期时间:默认 7 天
- HTTPS 反代下设置 `Secure`
签名密钥:
- 首次启动时生成 32 字节随机密钥。
- 保存在 `app_meta.session_secret`
- 数据目录持久化后,容器重建不会导致所有用户退出。
Cookie payload 包含:
```json
{
"uid": 1,
"role": "admin",
"sv": 1,
"iat": 1710000000000,
"exp": 1710604800000
}
```
校验规则:
- HMAC 使用 `sha256`
- 每个受保护请求都根据 `uid` 查询数据库。
- 用户不存在、被禁用、`session_version` 不匹配、Cookie 过期或签名不正确时返回 401。
- 重置密码、禁用用户、启用用户、变更角色时递增 `session_version`,使旧 Cookie 失效。
## HTTP API
所有响应使用 JSON。未认证返回 401权限不足返回 403。
公开接口:
- `GET /healthz`
- 仅表示应用进程和 HTTP 服务可用,不要求 Steam 已登录。
- 返回:`{ "ok": true }`
- `GET /api/auth/me`
- 未登录也可访问。
- 返回是否需要初始化、当前登录用户和 Steam 状态摘要。
- `POST /api/auth/setup`
- 仅在没有任何用户时可用。
- 请求:`{ "username": "admin", "password": "..." }`
- 成功后直接写入登录 Cookie。
- `POST /api/auth/login`
- 请求:`{ "username": "...", "password": "..." }`
- 成功后写入登录 Cookie。
- `POST /api/auth/logout`
- 清除当前 Cookie。
登录用户接口:
- `POST /api/auth/password`
- 当前用户修改自己的后台密码。
- 请求:`{ "oldPassword": "...", "newPassword": "..." }`
管理员用户管理接口:
- `GET /api/users`
- 返回用户列表,不返回密码哈希。
- `POST /api/users`
- 请求:`{ "username": "...", "password": "...", "role": "user" }`
- `PATCH /api/users/:id`
- 支持修改 `role``disabled`
- `POST /api/users/:id/password`
- 管理员重置指定用户密码。
- `DELETE /api/users/:id`
- 删除非自身用户,并执行最后管理员保护。
Steam 管理接口:
- `GET /api/steam/status`
- 返回当前 Steam 状态、是否需要 Guard、最近错误、当前 SteamID。
- `POST /api/steam/login`
- 仅管理员。
- 请求:`{ "accountName": "...", "password": "...", "logonID": 123 }`
- `logonID` 可选;缺省时后端生成稳定随机值并保存在数据目录配置中。
- 如果需要 Steam Guard返回 `waiting_guard`
- `POST /api/steam/guard`
- 仅管理员。
- 请求:`{ "code": "ABCDE" }`
- 后端把验证码交给当前待处理的 Steam Guard callback。
- `POST /api/steam/logout`
- 仅管理员。
- 调用 Steam `logOff()`,删除数据目录里的 `refresh.token`,状态变为 `logged_out`
聊天相关的既有接口和 WebSocket 协议保留,但必须先通过应用登录认证。未登录 Steam 时,依赖 Steam 的接口返回:
```json
{
"error": "Steam is not logged in",
"steamStatus": "logged_out"
}
```
## Steam 登录实现
将 Steam 生命周期从“构造时绑定固定 config”调整为“运行期按请求传入登录参数”。
后端新增 `SteamLoginService`,负责:
- 启动时读取 `${STEAM_CHAT_DATA_DIR}/refresh.token` 并尝试 token 登录。
- 管理 Steam 状态机。
- 监听 `steamGuard(domain, callback, lastCodeWrong)`
- 保存待提交的 Guard callback并通过状态接口暴露 `guardType``domain``lastCodeWrong`
- 登录成功后监听 `refreshToken` 事件并写入数据目录。
- Steam 退出时删除 token。
Guard 类型规则:
- `domain` 为字符串时表示邮箱码,前端展示邮箱域名。
- `domain``null` 时表示手机 2FA。
- `lastCodeWrong``true` 时,前端提示等待新验证码后再提交,避免 2FA 循环。
安全要求:
- 请求日志、错误日志、前端状态都不能输出 Steam 密码、后台密码、Steam Guard code、refresh token。
- 同一时间只允许一个 Steam 登录流程。已有 `logging_in``waiting_guard` 时,再次登录请求返回 409。
- 管理员手动重新登录前,应先调用 Steam 退出或等待当前登录流程结束。
## 前端重写
前端必须按新的后台业务模型完全重写。旧的 `web/index.html``web/app.ts``web/style.css` 只作为功能参考,不作为增量改造基础;实现时允许删除旧 DOM 结构、旧全局状态机和旧样式组织。
重写目标:
- 首屏是后台应用入口,不是默认聊天界面。
- 先解决后台初始化和后台登录,再进入业务工作台。
- Steam 连接状态是后台工作台的一部分,而不是启动前置条件。
- 聊天能力作为已登录后台用户的一个业务模块存在。
- 管理员和普通用户看到的导航、按钮和操作必须按权限区分。
页面结构:
1. 初始化页
- 仅在没有任何后台用户时展示。
- 创建第一个管理员,成功后直接进入后台。
2. 后台登录页
- 已初始化但未登录时展示。
- 只处理后台用户登录,不处理 Steam 登录。
3. 应用外壳
- 登录后展示统一后台布局。
- 包含侧边导航、顶部状态区、当前后台用户入口、退出登录入口。
4. Steam 连接页
- 管理员可提交 Steam 账号密码。
- 需要 Steam Guard 时切换到验证码提交状态。
- 普通用户只能查看当前 Steam 状态。
5. 用户管理页
- 仅管理员可见。
- 支持新增用户、重置密码、启用/禁用、删除。
6. 聊天页
- 重新实现会话列表、好友/群组列表、消息区、输入区、图片发送、表情/贴纸选择。
- Steam 未在线时展示不可用状态,并禁用发送、图片、表情等操作。
前端启动顺序:
1. 请求 `GET /api/auth/me`
2. 如果需要初始化,渲染初始化页。
3. 如果未登录,渲染后台登录页。
4. 如果已登录,进入应用外壳。
5. 加载 `GET /api/steam/status``GET /api/config`
6. 只有后台已登录后才建立 WebSocket。
7. Steam 未在线时保留页面可访问,但禁用依赖 Steam 的交互。
实现约束:
- v1 继续使用当前 TypeScript 静态构建链路,不强制引入前端框架或 bundler。
- 可以把 `web/app.ts` 拆成多个 TypeScript 模块,但输出仍由 `tsc` 生成到 `dist/web`
- 所有前端请求默认携带同源 Cookie401 时关闭 WebSocket 并回到后台登录页。
- 前端状态中不能保存或打印 Steam 密码、后台密码、Steam Guard code、refresh token。
- 移动端必须能完成初始化、后台登录、Steam Guard 提交和基础聊天。
## 测试要求
新增或调整以下测试:
- 用户初始化:无用户时允许 setup有用户后拒绝 setup。
- 登录认证:密码正确写 Cookie密码错误拒绝禁用用户拒绝。
- Cookie 校验:签名错误、过期、`session_version` 不匹配都返回 401。
- 用户管理:管理员可新增、重置密码、禁用、启用、删除;普通用户返回 403。
- 最后管理员保护:不能删除、禁用或降级最后一个启用管理员。
- Steam 状态:无 token 时服务启动且状态为 `logged_out`
- Steam token 登录:存在 `refresh.token` 时启动后自动尝试登录。
- Steam Guard触发 `waiting_guard`,提交验证码后继续登录,错误验证码保留状态。
- 聊天接口:应用未登录返回 401应用已登录但 Steam 未登录返回 503Steam 在线后保持现有行为。
- WebSocket未认证握手拒绝认证后可连接。
验收命令:
```bash
npm run typecheck
npm test
```

View File

@@ -0,0 +1,312 @@
# Steam Chat Docker Compose 部署设计
## 目标
提供一个单服务 Docker Compose 部署方式,让服务器上可以用下面的命令完成构建和启动:
```bash
docker compose up -d --build
```
部署后,用户通过浏览器访问 Web 后台,首次初始化管理员,然后由管理员完成 Steam 登录。
本部署方案不包含反向代理、HTTPS、外部数据库或额外服务。生产公网访问建议在外层自行接入 HTTPS 反向代理。
## 交付文件
需要新增或调整:
- `compose.yaml`
- `.env.example`
- `Dockerfile`
- `.dockerignore`
- `.gitignore`
- 后台改造文档中约定的数据目录和健康检查接口
Compose 文件使用本地源码构建镜像,同时设置固定镜像名,避免只有 `image` 而没有 `build``docker compose up --build` 仍尝试拉取远端镜像。
## Compose 服务定义
目标 `compose.yaml`
```yaml
services:
steam-chat:
image: ${STEAM_CHAT_IMAGE:-steam-chat:latest}
build:
context: .
dockerfile: Dockerfile
container_name: ${STEAM_CHAT_CONTAINER_NAME:-steam-chat}
restart: unless-stopped
init: true
ports:
- "${STEAM_CHAT_BIND:-0.0.0.0}:${STEAM_CHAT_PORT:-3000}:3000"
environment:
NODE_ENV: production
STEAM_CHAT_DATA_DIR: /app/data
STEAM_CHAT_HOST: 0.0.0.0
STEAM_CHAT_PORT: 3000
STEAM_CHAT_WS_PATH: /ws
volumes:
- steam-chat-data:/app/data
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
steam-chat-data:
```
默认使用 Docker named volume而不是 `./data:/app/data`。原因是运行容器使用非 root 用户时bind mount 容易遇到宿主机目录权限问题named volume 会继承镜像内 `/app/data` 的权限,更适合快速部署。
如果必须把数据放在当前目录,可以把 volume 改成:
```yaml
volumes:
- ./data:/app/data
```
并在首次启动前执行:
```bash
mkdir -p data
sudo chown -R 1000:1000 data
```
## 环境变量
`.env.example`
```dotenv
STEAM_CHAT_IMAGE=steam-chat:latest
STEAM_CHAT_CONTAINER_NAME=steam-chat
STEAM_CHAT_BIND=0.0.0.0
STEAM_CHAT_PORT=3000
```
说明:
- Compose 会自动读取同目录 `.env` 做 YAML 变量替换。
- `.env` 中的变量不会自动进入容器,只有在 `environment:` 中显式声明的变量才会成为容器环境变量。
- 本项目不在 `.env` 中保存 Steam 账号、Steam 密码、Steam Guard code 或 refresh token。
- Steam 登录凭据只通过 Web 后台提交,登录成功后只保存 `refresh.token` 到数据卷。
## Dockerfile 调整
继续使用多阶段构建:
1. `build` 阶段执行 `npm ci``npm run build`
2. `runtime` 阶段只复制 `dist/``node_modules/``package.json``package-lock.json`
3. 运行用户使用 `node`
4. 创建 `/app/data` 并设置为 `node:node`
5. 默认命令为 `node dist/src/index.js`
运行时环境:
```dockerfile
ENV NODE_ENV=production
ENV STEAM_CHAT_DATA_DIR=/app/data
ENV STEAM_CHAT_HOST=0.0.0.0
ENV STEAM_CHAT_PORT=3000
```
镜像内不复制或生成以下敏感/状态文件:
- `config.js`
- `.env`
- `refresh.token`
- `auth.sqlite`
- `logs/`
- `data/`
`.dockerignore``.gitignore` 都应包含:
```text
data/
auth.sqlite
```
## 数据目录
容器内固定数据目录:
```text
/app/data
```
目录内容:
```text
/app/data/auth.sqlite
/app/data/refresh.token
/app/data/logs/chat.jsonl
/app/data/logs/images/
/app/data/logs/stickers/
```
其中:
- `auth.sqlite` 保存后台用户、角色、禁用状态和应用会话密钥。
- `refresh.token` 保存 Steam refresh token。
- `logs/chat.jsonl` 保存聊天历史。
- `logs/images/``logs/stickers/` 保存媒体缓存。
## 健康检查
新增 `GET /healthz`
```json
{
"ok": true
}
```
该接口只检查应用 HTTP 服务是否可用,不检查 Steam 是否已经登录。原因是首次部署时 Steam 本来就可能未登录,如果健康检查依赖 Steam会导致容器在正常初始化阶段被判定为 unhealthy。
Steam 状态通过后台接口查看:
```bash
curl http://127.0.0.1:3000/api/steam/status
```
该接口需要后台登录 Cookie主要给 Web 后台使用。
## 部署流程
首次部署:
```bash
cp .env.example .env
docker compose config --quiet
docker compose up -d --build
docker compose ps
```
访问:
```text
http://服务器地址:3000
```
首次打开页面:
1. 初始化管理员。
2. 使用管理员账号登录后台。
3. 在后台提交 Steam 账号密码。
4. 如果 Steam 要求 Guard提交邮箱码或手机 2FA。
5. Steam 登录成功后开始使用聊天功能。
更新部署:
```bash
git pull
docker compose up -d --build
docker compose ps
```
查看日志:
```bash
docker compose logs -f steam-chat
```
停止:
```bash
docker compose down
```
删除容器但保留数据卷:
```bash
docker compose down
docker compose up -d
```
删除数据卷会清空后台用户、Steam token、聊天历史和缓存只有明确要重置时才执行
```bash
docker compose down -v
```
## 备份与恢复
默认 named volume 备份:
```bash
mkdir -p backups
docker run --rm \
-v steam-chat_steam-chat-data:/data:ro \
-v "$PWD/backups:/backup" \
alpine \
tar -czf /backup/steam-chat-data.tgz -C /data .
```
恢复:
```bash
docker compose down
docker run --rm \
-v steam-chat_steam-chat-data:/data \
-v "$PWD/backups:/backup" \
alpine \
sh -c "rm -rf /data/* && tar -xzf /backup/steam-chat-data.tgz -C /data"
docker compose up -d
```
如果使用 bind mount `./data:/app/data`,直接备份宿主机 `data/` 目录即可。
## 安全建议
- 不要把 Steam 密码、后台密码、Steam Guard code 或 refresh token 写入 `.env`、compose 文件或镜像。
- 公网部署时建议使用 HTTPS 反向代理。
- 如果只允许本机反向代理访问应用端口,可设置:
```dotenv
STEAM_CHAT_BIND=127.0.0.1
```
- 后台登录 Cookie 在 HTTPS 下应设置 `Secure`;应用可以根据 `X-Forwarded-Proto: https` 或显式环境变量判断。
- 反向代理需要支持 WebSocket Upgrade并转发 `/ws`
## 验收命令
配置校验:
```bash
docker compose config --quiet
```
镜像构建:
```bash
docker compose build
```
启动:
```bash
docker compose up -d
docker compose ps
```
健康检查:
```bash
curl -fsS http://127.0.0.1:${STEAM_CHAT_PORT:-3000}/healthz
```
代码质量:
```bash
npm run typecheck
npm test
```

194
src/auth/session.ts Normal file
View File

@@ -0,0 +1,194 @@
'use strict';
import type { IncomingMessage } from 'node:http';
import type { PublicUser } from './store';
import { isRecord } from '../types';
const crypto = require('node:crypto');
type AuthStoreLike = {
getOrCreateSessionSecret: () => string;
getUserById: (id: unknown) => (PublicUser & { passwordHash?: string }) | null;
};
type SessionPayload = {
uid: number;
role: 'admin' | 'user';
sv: number;
iat: number;
exp: number;
};
export type AppSession = {
payload: SessionPayload;
user: PublicUser;
};
type SessionManagerOptions = {
store: AuthStoreLike;
cookieName?: string;
maxAgeMs?: number;
};
const DEFAULT_COOKIE_NAME = 'steam_chat_session';
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
function base64urlJson(value: unknown): string {
return Buffer.from(JSON.stringify(value)).toString('base64url');
}
function sign(value: string, secret: string): string {
return crypto.createHmac('sha256', secret).update(value).digest('base64url');
}
function timingSafeTextEqual(left: unknown, right: unknown): boolean {
const leftBuffer = Buffer.from(String(left || ''));
const rightBuffer = Buffer.from(String(right || ''));
if (leftBuffer.length !== rightBuffer.length) return false;
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function parseCookieHeader(header: unknown): Record<string, string> {
const cookies: Record<string, string> = {};
for (const part of String(header || '').split(';')) {
const index = part.indexOf('=');
if (index === -1) continue;
const key = part.slice(0, index).trim();
if (!key) continue;
cookies[key] = decodeURIComponent(part.slice(index + 1).trim());
}
return cookies;
}
function secureCookieFor(req?: IncomingMessage): boolean {
if (!req) return false;
const socket = req.socket as IncomingMessage['socket'] & { encrypted?: boolean };
if (socket?.encrypted) return true;
const forwardedProto = req.headers['x-forwarded-proto'];
return String(Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto || '').split(',')[0].trim() === 'https';
}
function serializeCookie(name: string, value: string, options: {
maxAge?: number;
expires?: Date;
secure?: boolean;
} = {}) {
const parts = [
`${name}=${encodeURIComponent(value)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax'
];
if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`);
if (options.secure) parts.push('Secure');
return parts.join('; ');
}
function userToPublic(user: PublicUser & { passwordHash?: string }): PublicUser {
const { passwordHash: _passwordHash, ...safeUser } = user;
return safeUser;
}
function createSessionManager(options: SessionManagerOptions) {
const store = options.store;
const cookieName = options.cookieName || DEFAULT_COOKIE_NAME;
const maxAgeMs = options.maxAgeMs || DEFAULT_MAX_AGE_MS;
const secret = store.getOrCreateSessionSecret();
function encode(payload: SessionPayload): string {
const encoded = base64urlJson(payload);
return `${encoded}.${sign(encoded, secret)}`;
}
function decode(raw: unknown): SessionPayload | null {
const text = String(raw || '');
const index = text.lastIndexOf('.');
if (index <= 0) return null;
const encoded = text.slice(0, index);
const signature = text.slice(index + 1);
if (!timingSafeTextEqual(signature, sign(encoded, secret))) return null;
try {
const parsed: unknown = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
if (!isRecord(parsed)) return null;
const payload: SessionPayload = {
uid: Number(parsed.uid || 0),
role: parsed.role === 'admin' ? 'admin' : 'user',
sv: Number(parsed.sv || 0),
iat: Number(parsed.iat || 0),
exp: Number(parsed.exp || 0)
};
if (!payload.uid || !payload.sv || !payload.iat || !payload.exp) return null;
return payload;
} catch (_) {
return null;
}
}
function getSession(req: IncomingMessage): AppSession | null {
const raw = parseCookieHeader(req.headers.cookie)[cookieName];
const payload = decode(raw);
if (!payload || payload.exp <= Date.now()) return null;
const user = store.getUserById(payload.uid);
if (!user || user.disabled || user.sessionVersion !== payload.sv || user.role !== payload.role) return null;
return { payload, user: userToPublic(user) };
}
function createSetCookie(user: PublicUser, req?: IncomingMessage) {
const iat = Date.now();
const payload: SessionPayload = {
uid: user.id,
role: user.role,
sv: user.sessionVersion,
iat,
exp: iat + maxAgeMs
};
return serializeCookie(cookieName, encode(payload), {
maxAge: maxAgeMs / 1000,
expires: new Date(payload.exp),
secure: secureCookieFor(req)
});
}
function createClearCookie(req?: IncomingMessage) {
return serializeCookie(cookieName, '', {
maxAge: 0,
expires: new Date(0),
secure: secureCookieFor(req)
});
}
function requireSession(req: IncomingMessage): AppSession {
const session = getSession(req);
if (!session) throw Object.assign(new Error('Unauthorized'), { statusCode: 401 });
return session;
}
function requireAdmin(req: IncomingMessage): AppSession {
const session = requireSession(req);
if (session.user.role !== 'admin') throw Object.assign(new Error('Forbidden'), { statusCode: 403 });
return session;
}
return {
cookieName,
createClearCookie,
createSetCookie,
decode,
encode,
getSession,
maxAgeMs,
requireAdmin,
requireSession
};
}
module.exports = {
DEFAULT_COOKIE_NAME,
DEFAULT_MAX_AGE_MS,
createSessionManager,
parseCookieHeader,
secureCookieFor,
serializeCookie,
timingSafeTextEqual
};

326
src/auth/store.ts Normal file
View File

@@ -0,0 +1,326 @@
'use strict';
import type { DatabaseSync } from 'node:sqlite';
import type { UnknownRecord } from '../types';
import { isRecord } from '../types';
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const { DatabaseSync: NodeDatabaseSync } = require('node:sqlite');
const { AUTH_DB_PATH } = require('../paths');
type SQLRow = Record<string, unknown>;
export type UserRole = 'admin' | 'user';
export type PublicUser = {
id: number;
username: string;
role: UserRole;
disabled: boolean;
sessionVersion: number;
createdAt: string;
updatedAt: string;
lastLoginAt: string | null;
};
type UserRow = PublicUser & {
passwordHash: string;
};
type AuthStoreOptions = {
dbPath?: string;
database?: DatabaseSync;
};
const USERNAME_PATTERN = /^[A-Za-z0-9_.-]{3,64}$/;
const PASSWORD_MIN_LENGTH = 8;
const HASH_PREFIX = 'scrypt:v1';
function nowIso() {
return new Date().toISOString();
}
function httpError(statusCode: number, message: string) {
return Object.assign(new Error(message), { statusCode });
}
function asNumber(value: unknown): number {
return typeof value === 'bigint' ? Number(value) : Number(value || 0);
}
function asRole(value: unknown): UserRole {
return value === 'admin' ? 'admin' : 'user';
}
function validateUsername(username: unknown): string {
const value = String(username || '').trim();
if (!USERNAME_PATTERN.test(value)) {
throw httpError(400, 'Username must be 3-64 characters and contain only letters, numbers, underscores, hyphens, or dots');
}
return value;
}
function validatePassword(password: unknown): string {
const value = String(password || '');
if (value.length < PASSWORD_MIN_LENGTH) {
throw httpError(400, 'Password must be at least 8 characters');
}
return value;
}
function validateRole(role: unknown): UserRole {
if (role === 'admin' || role === 'user') return role;
throw httpError(400, 'Role must be admin or user');
}
function hashPassword(password: string) {
const salt = crypto.randomBytes(16).toString('base64url');
const hash = crypto.scryptSync(password, salt, 64).toString('base64url');
return `${HASH_PREFIX}:${salt}:${hash}`;
}
function verifyPassword(password: unknown, stored: unknown): boolean {
const text = String(stored || '');
const [scheme, version, salt, expected] = text.split(':');
if (`${scheme}:${version}` !== HASH_PREFIX || !salt || !expected) return false;
const actual = crypto.scryptSync(String(password || ''), salt, 64).toString('base64url');
const left = Buffer.from(actual);
const right = Buffer.from(expected);
if (left.length !== right.length) return false;
return crypto.timingSafeEqual(left, right);
}
function userFromRow(row: SQLRow | undefined): UserRow | null {
if (!row) return null;
return {
id: asNumber(row.id),
username: String(row.username || ''),
passwordHash: String(row.password_hash || ''),
role: asRole(row.role),
disabled: Boolean(asNumber(row.disabled)),
sessionVersion: asNumber(row.session_version),
createdAt: String(row.created_at || ''),
updatedAt: String(row.updated_at || ''),
lastLoginAt: typeof row.last_login_at === 'string' ? row.last_login_at : null
};
}
function publicUser(user: UserRow): PublicUser {
const { passwordHash: _passwordHash, ...safeUser } = user;
return safeUser;
}
function createAuthStore(options: AuthStoreOptions = {}) {
const dbPath = options.dbPath || AUTH_DB_PATH;
if (!options.database && dbPath !== ':memory:') {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
}
const db: DatabaseSync = options.database || new NodeDatabaseSync(dbPath, { timeout: 5000 });
db.exec(`
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
disabled INTEGER NOT NULL DEFAULT 0,
session_version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login_at TEXT
);
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
function getUserById(id: unknown): UserRow | null {
return userFromRow(db.prepare('SELECT * FROM users WHERE id = ?').get(asNumber(id)));
}
function getUserByUsername(username: unknown): UserRow | null {
return userFromRow(db.prepare('SELECT * FROM users WHERE username = ?').get(String(username || '').trim()));
}
function countUsers(): number {
const row = db.prepare('SELECT COUNT(*) AS count FROM users').get();
return asNumber(row?.count);
}
function enabledAdminCount(): number {
const row = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND disabled = 0").get();
return asNumber(row?.count);
}
function ensureCanRemoveAdmin(user: UserRow) {
if (user.role === 'admin' && !user.disabled && enabledAdminCount() <= 1) {
throw httpError(409, 'Cannot remove the last enabled admin');
}
}
function createUser(input: UnknownRecord): PublicUser {
const username = validateUsername(input.username);
const password = validatePassword(input.password);
const role = validateRole(input.role || 'user');
const at = nowIso();
try {
const result = db.prepare(`
INSERT INTO users (username, password_hash, role, disabled, session_version, created_at, updated_at)
VALUES (?, ?, ?, 0, 1, ?, ?)
`).run(username, hashPassword(password), role, at, at);
const user = getUserById(result.lastInsertRowid);
if (!user) throw httpError(500, 'Created user cannot be loaded');
return publicUser(user);
} catch (error) {
if (isRecord(error) && String(error.message || '').includes('UNIQUE')) {
throw httpError(409, 'Username already exists');
}
throw error;
}
}
function createInitialAdmin(input: UnknownRecord): PublicUser {
if (countUsers() !== 0) throw httpError(409, 'Setup has already been completed');
return createUser({ ...input, role: 'admin' });
}
function authenticate(username: unknown, password: unknown): PublicUser | null {
const user = getUserByUsername(username);
if (!user || user.disabled || !verifyPassword(password, user.passwordHash)) return null;
const at = nowIso();
db.prepare('UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?').run(at, at, user.id);
return publicUser({ ...user, lastLoginAt: at, updatedAt: at });
}
function listUsers(): PublicUser[] {
return db.prepare('SELECT * FROM users ORDER BY id ASC')
.all()
.map((row: SQLRow) => publicUser(userFromRow(row)!));
}
function updateUser(id: unknown, patch: UnknownRecord): PublicUser {
const user = getUserById(id);
if (!user) throw httpError(404, 'User not found');
const nextRole = Object.prototype.hasOwnProperty.call(patch, 'role') ? validateRole(patch.role) : user.role;
const nextDisabled = Object.prototype.hasOwnProperty.call(patch, 'disabled') ? Boolean(patch.disabled) : user.disabled;
if (user.role === 'admin' && !user.disabled && (nextRole !== 'admin' || nextDisabled)) {
ensureCanRemoveAdmin(user);
}
if (nextRole === user.role && nextDisabled === user.disabled) return publicUser(user);
const at = nowIso();
db.prepare(`
UPDATE users
SET role = ?, disabled = ?, session_version = session_version + 1, updated_at = ?
WHERE id = ?
`).run(nextRole, nextDisabled ? 1 : 0, at, user.id);
const next = getUserById(user.id);
if (!next) throw httpError(500, 'Updated user cannot be loaded');
return publicUser(next);
}
function setPassword(id: unknown, password: unknown): PublicUser {
const user = getUserById(id);
if (!user) throw httpError(404, 'User not found');
const nextPassword = validatePassword(password);
const at = nowIso();
db.prepare(`
UPDATE users
SET password_hash = ?, session_version = session_version + 1, updated_at = ?
WHERE id = ?
`).run(hashPassword(nextPassword), at, user.id);
const next = getUserById(user.id);
if (!next) throw httpError(500, 'Updated user cannot be loaded');
return publicUser(next);
}
function changeOwnPassword(id: unknown, oldPassword: unknown, newPassword: unknown): PublicUser {
const user = getUserById(id);
if (!user) throw httpError(404, 'User not found');
if (!verifyPassword(oldPassword, user.passwordHash)) throw httpError(401, 'Old password is incorrect');
return setPassword(user.id, newPassword);
}
function deleteUser(id: unknown, currentUserId: unknown): void {
const user = getUserById(id);
if (!user) throw httpError(404, 'User not found');
if (user.id === asNumber(currentUserId)) throw httpError(400, 'Cannot delete the current user');
ensureCanRemoveAdmin(user);
db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
}
function getMeta(key: string): string | null {
const row = db.prepare('SELECT value FROM app_meta WHERE key = ?').get(key);
return typeof row?.value === 'string' ? row.value : null;
}
function setMeta(key: string, value: string): void {
db.prepare(`
INSERT INTO app_meta (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`).run(key, value, nowIso());
}
function getOrCreateSessionSecret(): string {
const existing = getMeta('session_secret');
if (existing) return existing;
const secret = crypto.randomBytes(32).toString('base64url');
setMeta('session_secret', secret);
return secret;
}
function getOrCreateSteamLogonID(): number {
const existing = Number.parseInt(String(getMeta('steam.logon_id') || ''), 10);
if (Number.isFinite(existing) && existing > 0) return existing;
const generated = crypto.randomInt(1, 0x7fffffff);
setMeta('steam.logon_id', String(generated));
return generated;
}
function requiresSetup(): boolean {
return countUsers() === 0;
}
return {
db,
dbPath,
authenticate,
changeOwnPassword,
close() {
db.close();
},
countUsers,
createInitialAdmin,
createUser,
deleteUser,
enabledAdminCount,
getMeta,
getOrCreateSessionSecret,
getOrCreateSteamLogonID,
getUserById,
getUserByUsername,
listUsers,
requiresSetup,
setMeta,
setPassword,
updateUser
};
}
module.exports = {
PASSWORD_MIN_LENGTH,
USERNAME_PATTERN,
createAuthStore,
hashPassword,
publicUser,
validatePassword,
validateRole,
validateUsername,
verifyPassword
};

View File

@@ -7,14 +7,26 @@ const path = require('node:path');
const { PROJECT_ROOT } = require('../paths'); const { PROJECT_ROOT } = require('../paths');
function loadConfig(): UnknownRecord { function loadConfig(): UnknownRecord {
let fileConfig: UnknownRecord = {};
try { try {
const loaded: unknown = require(path.join(PROJECT_ROOT, 'config')); const loaded: unknown = require(path.join(PROJECT_ROOT, 'config'));
return isRecord(loaded) ? loaded : {}; fileConfig = isRecord(loaded) ? loaded : {};
} catch (error) { } catch (error) {
if (!isRecord(error) || error.code !== 'MODULE_NOT_FOUND') throw error; if (!isRecord(error) || error.code !== 'MODULE_NOT_FOUND') throw error;
const example: unknown = require(path.join(PROJECT_ROOT, 'config.example'));
return isRecord(example) ? example : {};
} }
const chatInput = isRecord(fileConfig.chat) ? fileConfig.chat : {};
const chat = {
...chatInput,
host: process.env.STEAM_CHAT_HOST || chatInput.host || '0.0.0.0',
port: Number.parseInt(String(process.env.STEAM_CHAT_PORT || chatInput.port || 3000), 10),
wsPath: process.env.STEAM_CHAT_WS_PATH || chatInput.wsPath || '/ws'
};
return {
...fileConfig,
chat
};
} }
function isChatEnabled(chatConfig: unknown) { function isChatEnabled(chatConfig: unknown) {

View File

@@ -5,10 +5,12 @@ import { errorMessage, isRecord } from './types';
const winston = require('winston'); const winston = require('winston');
const { loadConfig, isChatEnabled } = require('./config/load'); const { createAuthStore } = require('./auth/store');
const { REFRESH_TOKEN_PATH } = require('./paths'); const { createSessionManager } = require('./auth/session');
const { loadConfig } = require('./config/load');
const { AUTH_DB_PATH, REFRESH_TOKEN_PATH } = require('./paths');
const { createChatService } = require('./server/chat-service'); const { createChatService } = require('./server/chat-service');
const { createSteamLifecycle } = require('./steam/lifecycle'); const { createSteamLoginService } = require('./steam/lifecycle');
const { const {
steamIdToString steamIdToString
} = require('./storage/chat-log'); } = require('./storage/chat-log');
@@ -66,6 +68,8 @@ const config = loadConfig();
const steamUser = new SteamUser({ renewRefreshTokens: true }); const steamUser = new SteamUser({ renewRefreshTokens: true });
const steamCommunity = new SteamCommunity(); const steamCommunity = new SteamCommunity();
const users: Record<string, Persona> = {}; const users: Record<string, Persona> = {};
const authStore = createAuthStore({ dbPath: AUTH_DB_PATH });
const sessionManager = createSessionManager({ store: authStore });
const logger = winston.createLogger({ const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info', level: process.env.LOG_LEVEL || 'info',
@@ -122,12 +126,16 @@ async function getSelfName() {
return info.player_name || info.personaName || 'Me'; return info.player_name || info.personaName || 'Me';
} }
const lifecycle = createSteamLifecycle({ const lifecycle = createSteamLoginService({
steamUser, steamUser,
steamCommunity, steamCommunity,
config, config: {
steamID: typeof config.steamID === 'string' ? config.steamID : undefined,
identitySecret: typeof config.identitySecret === 'string' ? config.identitySecret : undefined
},
logger, logger,
refreshTokenPath: REFRESH_TOKEN_PATH refreshTokenPath: REFRESH_TOKEN_PATH,
getDefaultLogonID: authStore.getOrCreateSteamLogonID
}); });
createSteamMessageLogger({ createSteamMessageLogger({
@@ -142,9 +150,12 @@ let steamWebLoginPromise = lifecycle.waitForWebSession();
let chatService: ChatServiceRuntime | null = null; let chatService: ChatServiceRuntime | null = null;
function start() { function start() {
steamLoginPromise = lifecycle.start(); const tokenLoginPromise = lifecycle.start().catch((error: unknown) => {
logger.warn('Steam token login failed', { error: errorMessage(error) });
return false;
});
steamLoginPromise = lifecycle.waitForLogin();
steamWebLoginPromise = lifecycle.waitForWebSession(); steamWebLoginPromise = lifecycle.waitForWebSession();
if (isChatEnabled(config.chat)) {
chatService = createChatService({ chatService = createChatService({
config, config,
steamUser, steamUser,
@@ -152,29 +163,32 @@ function start() {
logger, logger,
getUserInfo, getUserInfo,
getSelfName, getSelfName,
waitForLogin: steamLoginPromise, waitForLogin: () => lifecycle.waitForLogin(),
waitForWebSession: steamWebLoginPromise, waitForWebSession: () => lifecycle.waitForWebSession(),
refreshWebSession: lifecycle.refreshWebSession refreshWebSession: lifecycle.refreshWebSession,
authStore,
sessionManager,
steamLoginService: lifecycle
}); });
chatService.start(); chatService.start();
} return tokenLoginPromise;
return steamLoginPromise;
} }
if (process.env.STEAM_CHAT_DISABLE_AUTOSTART !== '1') { if (process.env.STEAM_CHAT_DISABLE_AUTOSTART !== '1') {
start().catch((error: unknown) => { start().catch((error: unknown) => {
logger.error('Steam startup failed', { error: errorMessage(error) }); logger.error('Steam startup failed', { error: errorMessage(error) });
process.exitCode = 1;
}); });
} }
module.exports = { module.exports = {
authStore,
chatService, chatService,
config, config,
getSelfName, getSelfName,
getUserInfo, getUserInfo,
lifecycle, lifecycle,
logger, logger,
sessionManager,
start, start,
steamCommunity, steamCommunity,
steamLoginPromise, steamLoginPromise,

View File

@@ -5,14 +5,18 @@ const path = require('node:path');
const BUILD_ROOT = path.resolve(__dirname, '..'); const BUILD_ROOT = path.resolve(__dirname, '..');
const PROJECT_ROOT = path.basename(BUILD_ROOT) === 'dist' ? path.resolve(BUILD_ROOT, '..') : BUILD_ROOT; const PROJECT_ROOT = path.basename(BUILD_ROOT) === 'dist' ? path.resolve(BUILD_ROOT, '..') : BUILD_ROOT;
const WEB_DIR = path.join(BUILD_ROOT, 'web'); const WEB_DIR = path.join(BUILD_ROOT, 'web');
const LOG_DIR = path.join(PROJECT_ROOT, 'logs'); const DATA_DIR = path.resolve(process.env.STEAM_CHAT_DATA_DIR || path.join(PROJECT_ROOT, 'data'));
const LOG_DIR = path.join(DATA_DIR, 'logs');
const CHAT_LOG_PATH = path.join(LOG_DIR, 'chat.jsonl'); const CHAT_LOG_PATH = path.join(LOG_DIR, 'chat.jsonl');
const IMAGE_CACHE_DIR = path.join(LOG_DIR, 'images'); const IMAGE_CACHE_DIR = path.join(LOG_DIR, 'images');
const STICKER_CACHE_DIR = path.join(LOG_DIR, 'stickers'); const STICKER_CACHE_DIR = path.join(LOG_DIR, 'stickers');
const REFRESH_TOKEN_PATH = path.join(PROJECT_ROOT, 'refresh.token'); const REFRESH_TOKEN_PATH = path.join(DATA_DIR, 'refresh.token');
const AUTH_DB_PATH = path.join(DATA_DIR, 'auth.sqlite');
module.exports = { module.exports = {
AUTH_DB_PATH,
CHAT_LOG_PATH, CHAT_LOG_PATH,
DATA_DIR,
IMAGE_CACHE_DIR, IMAGE_CACHE_DIR,
LOG_DIR, LOG_DIR,
PROJECT_ROOT, PROJECT_ROOT,

View File

@@ -3,6 +3,8 @@
import type { IncomingMessage, Server, ServerResponse } from 'node:http'; import type { IncomingMessage, Server, ServerResponse } from 'node:http';
import type { Duplex } from 'node:stream'; import type { Duplex } from 'node:stream';
import type { RawData, WebSocket as WsConnection, WebSocketServer as WsServer } from 'ws'; import type { RawData, WebSocket as WsConnection, WebSocketServer as WsServer } from 'ws';
import type { AppSession } from '../auth/session';
import type { PublicUser } from '../auth/store';
import type { import type {
AuthConfig, AuthConfig,
CallbackStyleFunction, CallbackStyleFunction,
@@ -97,6 +99,47 @@ type ChatServiceOptions = {
getEmoticons?: (options: GetEmoticonsOptions) => Promise<EmoticonPayload>; getEmoticons?: (options: GetEmoticonsOptions) => Promise<EmoticonPayload>;
fetchImpl?: typeof fetch; fetchImpl?: typeof fetch;
server?: Server; server?: Server;
authStore?: AuthStoreLike;
sessionManager?: SessionManagerLike;
steamLoginService?: SteamLoginServiceLike;
};
type AuthStoreLike = {
authenticate: (username: unknown, password: unknown) => PublicUser | null;
changeOwnPassword: (id: unknown, oldPassword: unknown, newPassword: unknown) => PublicUser;
createInitialAdmin: (input: UnknownRecord) => PublicUser;
createUser: (input: UnknownRecord) => PublicUser;
deleteUser: (id: unknown, currentUserId: unknown) => void;
listUsers: () => PublicUser[];
requiresSetup: () => boolean;
setPassword: (id: unknown, password: unknown) => PublicUser;
updateUser: (id: unknown, patch: UnknownRecord) => PublicUser;
};
type SessionManagerLike = {
createClearCookie: (req?: IncomingMessage) => string;
createSetCookie: (user: PublicUser, req?: IncomingMessage) => string;
getSession: (req: IncomingMessage) => AppSession | null;
requireAdmin: (req: IncomingMessage) => AppSession;
requireSession: (req: IncomingMessage) => AppSession;
};
type SteamStatusSummary = {
status: string;
requiresGuard?: boolean;
guardType?: string | null;
domain?: string | null;
lastCodeWrong?: boolean;
error?: string | null;
steamId?: string | null;
};
type SteamLoginServiceLike = {
ensureOnline: () => void;
getStatus: () => SteamStatusSummary;
login: (input: UnknownRecord) => SteamStatusSummary;
logout: () => SteamStatusSummary;
submitGuard: (code: unknown) => SteamStatusSummary;
}; };
type WsPayload = UnknownRecord & { type WsPayload = UnknownRecord & {
@@ -196,6 +239,16 @@ function jsonResponse(res: ServerResponse, statusCode: number, payload: unknown,
res.end(JSON.stringify(payload)); res.end(JSON.stringify(payload));
} }
function errorPayload(error: unknown): UnknownRecord {
const payload: UnknownRecord = {
error: errorMessage(error) || 'Internal Server Error'
};
if (isRecord(error) && typeof error.steamStatus === 'string') {
payload.steamStatus = error.steamStatus;
}
return payload;
}
function textResponse(res: ServerResponse, statusCode: number, payload: string | Buffer, headers: Record<string, string> = {}) { function textResponse(res: ServerResponse, statusCode: number, payload: string | Buffer, headers: Record<string, string> = {}) {
res.writeHead(statusCode, headers); res.writeHead(statusCode, headers);
res.end(payload); res.end(payload);
@@ -252,6 +305,14 @@ function staticFileForUrl(pathname: string) {
return filePath; return filePath;
} }
function isStaticRequest(pathname: string) {
return pathname === '/'
|| pathname === '/index.html'
|| pathname === '/style.css'
|| pathname === '/app.js'
|| pathname === '/favicon.ico';
}
async function serveStatic(req: IncomingMessage, res: ServerResponse, pathname: string) { async function serveStatic(req: IncomingMessage, res: ServerResponse, pathname: string) {
const filePath = staticFileForUrl(pathname); const filePath = staticFileForUrl(pathname);
if (!filePath) { if (!filePath) {
@@ -342,7 +403,10 @@ function createChatService(options: ChatServiceOptions = {}) {
const getSelfName = options.getSelfName || (async () => 'Me'); const getSelfName = options.getSelfName || (async () => 'Me');
const getEmoticons = options.getEmoticons || defaultGetEmoticons; const getEmoticons = options.getEmoticons || defaultGetEmoticons;
const fetchImpl = options.fetchImpl; const fetchImpl = options.fetchImpl;
const auth = createAuthChecker(config.auth); const authStore = options.authStore;
const sessionManager = options.sessionManager;
const steamLoginService = options.steamLoginService;
const legacyAuth = createAuthChecker(config.auth);
const clients = new Set<WsConnection>(); const clients = new Set<WsConnection>();
const recentSentText = new Map<string, number>(); const recentSentText = new Map<string, number>();
const recentSentImages = new Map<string, number>(); const recentSentImages = new Map<string, number>();
@@ -365,7 +429,175 @@ function createChatService(options: ChatServiceOptions = {}) {
} }
} }
function currentSteamStatus(): SteamStatusSummary {
return steamLoginService?.getStatus?.() || {
status: 'online',
requiresGuard: false,
guardType: null,
domain: null,
lastCodeWrong: false,
error: null,
steamId: null
};
}
function requireLegacyOrSession(req: IncomingMessage): AppSession | null {
if (sessionManager) return sessionManager.requireSession(req);
if (!legacyAuth.isAuthorized(req)) throw Object.assign(new Error('Unauthorized'), { statusCode: 401, legacyChallenge: true });
return null;
}
function requireAdminSession(req: IncomingMessage): AppSession | null {
if (sessionManager) return sessionManager.requireAdmin(req);
if (!legacyAuth.isAuthorized(req)) throw Object.assign(new Error('Unauthorized'), { statusCode: 401, legacyChallenge: true });
return null;
}
function requireSteamOnline() {
steamLoginService?.ensureOnline?.();
}
function writeAuthError(req: IncomingMessage, res: ServerResponse, error: unknown) {
if (isRecord(error) && error.legacyChallenge && !sessionManager) {
legacyAuth.challenge(res);
return;
}
jsonResponse(res, statusCodeForError(error), errorPayload(error));
}
function authMe(req: IncomingMessage) {
const session = sessionManager?.getSession(req) || null;
return {
needsSetup: authStore?.requiresSetup?.() ?? false,
user: session?.user || null,
steam: currentSteamStatus()
};
}
async function handleAuthApi(req: IncomingMessage, res: ServerResponse, pathname: string) {
if (!authStore || !sessionManager) return false;
if (req.method === 'GET' && pathname === '/api/auth/me') {
jsonResponse(res, 200, authMe(req));
return true;
}
if (req.method === 'POST' && pathname === '/api/auth/setup') {
const body = await readJsonBody(req);
const user = authStore.createInitialAdmin(body);
jsonResponse(res, 200, { ok: true, user, steam: currentSteamStatus() }, {
'Set-Cookie': sessionManager.createSetCookie(user, req)
});
return true;
}
if (req.method === 'POST' && pathname === '/api/auth/login') {
const body = await readJsonBody(req);
const user = authStore.authenticate(body.username, body.password);
if (!user) throw Object.assign(new Error('Invalid username or password'), { statusCode: 401 });
jsonResponse(res, 200, { ok: true, user, steam: currentSteamStatus() }, {
'Set-Cookie': sessionManager.createSetCookie(user, req)
});
return true;
}
if (req.method === 'POST' && pathname === '/api/auth/logout') {
jsonResponse(res, 200, { ok: true }, {
'Set-Cookie': sessionManager.createClearCookie(req)
});
return true;
}
if (req.method === 'POST' && pathname === '/api/auth/password') {
const session = sessionManager.requireSession(req);
const body = await readJsonBody(req);
const user = authStore.changeOwnPassword(session.user.id, body.oldPassword, body.newPassword);
jsonResponse(res, 200, { ok: true, user }, {
'Set-Cookie': sessionManager.createSetCookie(user, req)
});
return true;
}
return false;
}
async function handleUsersApi(req: IncomingMessage, res: ServerResponse, pathname: string) {
if (!authStore || !sessionManager) return false;
if (pathname === '/api/users') {
requireAdminSession(req);
if (req.method === 'GET') {
jsonResponse(res, 200, { users: authStore.listUsers() });
return true;
}
if (req.method === 'POST') {
const user = authStore.createUser(await readJsonBody(req));
jsonResponse(res, 201, { ok: true, user });
return true;
}
return false;
}
const passwordMatch = pathname.match(/^\/api\/users\/(\d+)\/password$/);
if (passwordMatch && req.method === 'POST') {
requireAdminSession(req);
const body = await readJsonBody(req);
const user = authStore.setPassword(passwordMatch[1], body.password);
jsonResponse(res, 200, { ok: true, user });
return true;
}
const userMatch = pathname.match(/^\/api\/users\/(\d+)$/);
if (userMatch) {
const session = requireAdminSession(req);
if (req.method === 'PATCH') {
const user = authStore.updateUser(userMatch[1], await readJsonBody(req));
jsonResponse(res, 200, { ok: true, user });
return true;
}
if (req.method === 'DELETE') {
authStore.deleteUser(userMatch[1], session?.user.id);
jsonResponse(res, 200, { ok: true });
return true;
}
}
return false;
}
async function handleSteamApi(req: IncomingMessage, res: ServerResponse, pathname: string) {
if (!steamLoginService) return false;
if (req.method === 'GET' && pathname === '/api/steam/status') {
requireLegacyOrSession(req);
jsonResponse(res, 200, currentSteamStatus());
return true;
}
if (req.method === 'POST' && pathname === '/api/steam/login') {
requireAdminSession(req);
const status = steamLoginService.login(await readJsonBody(req));
jsonResponse(res, 200, status);
return true;
}
if (req.method === 'POST' && pathname === '/api/steam/guard') {
requireAdminSession(req);
const body = await readJsonBody(req);
jsonResponse(res, 200, steamLoginService.submitGuard(body.code));
return true;
}
if (req.method === 'POST' && pathname === '/api/steam/logout') {
requireAdminSession(req);
jsonResponse(res, 200, steamLoginService.logout());
return true;
}
return false;
}
async function withSteamRetry<T>(operation: () => Promise<T> | T, needsWebSession = false): Promise<T> { async function withSteamRetry<T>(operation: () => Promise<T> | T, needsWebSession = false): Promise<T> {
requireSteamOnline();
await resolveWaiter(waitForLogin); await resolveWaiter(waitForLogin);
if (needsWebSession) { if (needsWebSession) {
await resolveWaiter(waitForWebSession); await resolveWaiter(waitForWebSession);
@@ -481,26 +713,39 @@ function createChatService(options: ChatServiceOptions = {}) {
async function handleHttpRequest(req: IncomingMessage, res: ServerResponse) { async function handleHttpRequest(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
try { try {
if (!auth.isAuthorized(req)) { if (req.method === 'GET' && url.pathname === '/healthz') {
auth.challenge(res); jsonResponse(res, 200, { ok: true });
return; return;
} }
if (req.method === 'GET' && isStaticRequest(url.pathname)) {
if (await serveStatic(req, res, url.pathname)) return;
}
if (await handleAuthApi(req, res, url.pathname)) return;
if (await handleSteamApi(req, res, url.pathname)) return;
if (await handleUsersApi(req, res, url.pathname)) return;
requireLegacyOrSession(req);
if (req.method === 'GET') { if (req.method === 'GET') {
if (url.pathname === '/api/config') { if (url.pathname === '/api/config') {
jsonResponse(res, 200, { wsPath: config.wsPath }); jsonResponse(res, 200, { wsPath: config.wsPath });
return; return;
} }
if (url.pathname === '/api/emoticons') { if (url.pathname === '/api/emoticons') {
requireSteamOnline();
const data = await getEmoticons({ steamUser, waitForLogin, waitForWebSession }); const data = await getEmoticons({ steamUser, waitForLogin, waitForWebSession });
jsonResponse(res, 200, data); jsonResponse(res, 200, data);
return; return;
} }
if (url.pathname === '/api/friends') { if (url.pathname === '/api/friends') {
requireSteamOnline();
jsonResponse(res, 200, await listFriends(steamUser)); jsonResponse(res, 200, await listFriends(steamUser));
return; return;
} }
if (url.pathname === '/api/groups') { if (url.pathname === '/api/groups') {
requireSteamOnline();
jsonResponse(res, 200, await listGroups(steamUser)); jsonResponse(res, 200, await listGroups(steamUser));
return; return;
} }
@@ -556,7 +801,7 @@ function createChatService(options: ChatServiceOptions = {}) {
jsonResponse(res, req.method === 'GET' ? 404 : 405, { error: 'Not found' }); jsonResponse(res, req.method === 'GET' ? 404 : 405, { error: 'Not found' });
} catch (error) { } catch (error) {
jsonResponse(res, statusCodeForError(error), { error: errorMessage(error) || 'Internal Server Error' }); writeAuthError(req, res, error);
} }
} }
@@ -604,20 +849,23 @@ function createChatService(options: ChatServiceOptions = {}) {
return; return;
} }
if (type === 'get_emoticons' || type === 'emoticons') { if (type === 'get_emoticons' || type === 'emoticons') {
requireSteamOnline();
reply({ type: 'emoticons', ...(await getEmoticons({ steamUser, waitForLogin, waitForWebSession })) }); reply({ type: 'emoticons', ...(await getEmoticons({ steamUser, waitForLogin, waitForWebSession })) });
return; return;
} }
if (type === 'get_friends' || type === 'friends') { if (type === 'get_friends' || type === 'friends') {
requireSteamOnline();
reply({ type: 'friends', friends: await listFriends(steamUser) }); reply({ type: 'friends', friends: await listFriends(steamUser) });
return; return;
} }
if (type === 'get_groups' || type === 'groups') { if (type === 'get_groups' || type === 'groups') {
requireSteamOnline();
reply({ type: 'groups', groups: await listGroups(steamUser) }); reply({ type: 'groups', groups: await listGroups(steamUser) });
return; return;
} }
reply({ type: 'error', error: `Unsupported WebSocket type: ${type || 'unknown'}` }); reply({ type: 'error', error: `Unsupported WebSocket type: ${type || 'unknown'}` });
} catch (error) { } catch (error) {
reply({ type: 'error', error: errorMessage(error) || 'Request failed' }); reply({ type: 'error', error: errorMessage(error) || 'Request failed', statusCode: statusCodeForError(error), ...(errorPayload(error)) });
} }
} }
@@ -627,8 +875,21 @@ function createChatService(options: ChatServiceOptions = {}) {
socket.destroy(); socket.destroy();
return; return;
} }
if (!auth.isAuthorized(req)) { try {
auth.challengeUpgrade(socket); requireLegacyOrSession(req);
} catch (error) {
if (sessionManager) {
socket.write([
'HTTP/1.1 401 Unauthorized',
'Content-Type: application/json; charset=utf-8',
'Connection: close',
'',
JSON.stringify({ error: 'Unauthorized' })
].join('\r\n'));
socket.destroy();
return;
}
legacyAuth.challengeUpgrade(socket);
return; return;
} }
wss.handleUpgrade(req, socket, head, (ws: WsConnection) => { wss.handleUpgrade(req, socket, head, (ws: WsConnection) => {

View File

@@ -51,9 +51,11 @@ type SteamUserLifecycleLike = {
(event: 'loggedOn', listener: () => void): void; (event: 'loggedOn', listener: () => void): void;
(event: 'webSession', listener: (sessionID: string, cookies: string[]) => void): void; (event: 'webSession', listener: (sessionID: string, cookies: string[]) => void): void;
(event: 'refreshToken', listener: (refreshToken: string) => void): void; (event: 'refreshToken', listener: (refreshToken: string) => void): void;
(event: 'steamGuard', listener: (domain: string | null, callback: (code: string) => void, lastCodeWrong: boolean) => void): void;
(event: 'error', listener: (error: unknown) => void): void; (event: 'error', listener: (error: unknown) => void): void;
(event: 'disconnected', listener: (eresult: unknown, message?: string) => void): void; (event: 'disconnected', listener: (eresult: unknown, message?: string) => void): void;
}; };
steamID?: unknown;
logOn: (options: LogOnOptions) => void; logOn: (options: LogOnOptions) => void;
webLogOn: () => void; webLogOn: () => void;
setPersona?: (state: number) => void; setPersona?: (state: number) => void;
@@ -76,6 +78,34 @@ type SteamLifecycleOptions = {
timers?: TimerApi; timers?: TimerApi;
}; };
type SteamStatus = 'logged_out' | 'logging_in' | 'waiting_guard' | 'online' | 'error' | 'reconnecting';
type SteamGuardInfo = {
guardType: 'email' | 'device';
domain: string | null;
lastCodeWrong: boolean;
};
type SteamStatusSummary = {
status: SteamStatus;
requiresGuard: boolean;
guardType: 'email' | 'device' | null;
domain: string | null;
lastCodeWrong: boolean;
error: string | null;
steamId: string | null;
};
type SteamLoginRequest = {
accountName?: unknown;
password?: unknown;
logonID?: unknown;
};
type SteamLoginServiceOptions = SteamLifecycleOptions & {
getDefaultLogonID?: () => number;
};
function createDeferred<T = unknown>(): Deferred<T> { function createDeferred<T = unknown>(): Deferred<T> {
let resolve: (value: T | PromiseLike<T>) => void; let resolve: (value: T | PromiseLike<T>) => void;
let reject: (reason?: unknown) => void; let reject: (reason?: unknown) => void;
@@ -141,6 +171,34 @@ function readRefreshToken(refreshTokenPath: string, fileSystem: RefreshTokenFile
} }
} }
function deleteRefreshToken(refreshTokenPath: string) {
try {
fs.unlinkSync(refreshTokenPath);
} catch (error) {
if (!isRecord(error) || error.code !== 'ENOENT') throw error;
}
}
function steamIdToText(value: unknown): string | null {
if (!value) return null;
if (isRecord(value) && typeof value.getSteamID64 === 'function') return String(value.getSteamID64.call(value));
if (isRecord(value) && value.steamid) return String(value.steamid);
return String(value);
}
function steamUnavailableError(status: SteamStatus) {
return Object.assign(new Error('Steam is not logged in'), {
statusCode: 503,
steamStatus: status
});
}
function createHandledDeferred<T = unknown>(): Deferred<T> {
const deferred = createDeferred<T>();
deferred.promise.catch(() => {});
return deferred;
}
function buildLogOnOptions(config: SteamLifecycleConfig, refreshTokenPath: string, fileSystem: RefreshTokenFileSystem = fs): LogOnOptions { function buildLogOnOptions(config: SteamLifecycleConfig, refreshTokenPath: string, fileSystem: RefreshTokenFileSystem = fs): LogOnOptions {
const refreshToken = readRefreshToken(refreshTokenPath, fileSystem); const refreshToken = readRefreshToken(refreshTokenPath, fileSystem);
const base: LogOnOptions = {}; const base: LogOnOptions = {};
@@ -306,13 +364,296 @@ function createSteamLifecycle(options: SteamLifecycleOptions) {
}; };
} }
function createSteamLoginService(options: SteamLoginServiceOptions) {
const {
steamUser,
steamCommunity,
config = {},
logger = console,
refreshTokenPath = DEFAULT_REFRESH_TOKEN_PATH,
fileSystem = fs,
timers = { setTimeout, clearTimeout },
getDefaultLogonID
} = options;
if (!steamUser) {
throw new Error('steamUser is required');
}
let status: SteamStatus = 'logged_out';
let lastError: string | null = null;
let guard: SteamGuardInfo | null = null;
let pendingGuardCallback: ((code: string) => void) | null = null;
let loginDeferred = createHandledDeferred<boolean>();
let webDeferred = createHandledDeferred<WebSession>();
let retryTimer: TimerHandle | null = null;
let retryDelayMs = INITIAL_RETRY_DELAY_MS;
let latestWebSession: WebSession | null = null;
let stopped = false;
let manualLogoff = false;
let steamId: string | null = null;
function log(level: 'info' | 'warn' | 'error', message: string, meta?: unknown) {
const method = logger[level] || logger.log || (() => {});
method.call(logger, message, meta);
}
function resetDeferreds() {
loginDeferred = createHandledDeferred<boolean>();
webDeferred = createHandledDeferred<WebSession>();
}
function clearRetryTimer() {
if (retryTimer) {
timers.clearTimeout(retryTimer);
retryTimer = null;
}
}
function baseLoginOptions(): LogOnOptions {
const base: LogOnOptions = {};
if (config.steamID) base.steamID = config.steamID;
return base;
}
function beginLogin(logOnOptions: LogOnOptions, label: string) {
clearRetryTimer();
resetDeferreds();
status = 'logging_in';
guard = null;
pendingGuardCallback = null;
lastError = null;
try {
log('info', `Logging on Steam with ${label}`);
steamUser.logOn(logOnOptions);
} catch (error) {
handleLoginFailure(error);
}
return loginDeferred.promise;
}
function tryTokenLogin(nextStatus: SteamStatus = 'logging_in'): Promise<boolean> {
const refreshToken = readRefreshToken(refreshTokenPath, fileSystem);
if (!refreshToken) {
status = 'logged_out';
guard = null;
pendingGuardCallback = null;
return Promise.resolve(false);
}
status = nextStatus;
const logOnOptions = { ...baseLoginOptions(), refreshToken };
return beginLogin(logOnOptions, 'refresh token');
}
function scheduleReconnect(reason: unknown) {
if (stopped || retryTimer) return;
const refreshToken = readRefreshToken(refreshTokenPath, fileSystem);
if (!refreshToken) {
status = 'logged_out';
return;
}
status = 'reconnecting';
lastError = errorMessage(reason);
const delay = retryDelayMs;
retryDelayMs = Math.min(retryDelayMs * 2, MAX_RETRY_DELAY_MS);
log('warn', `Steam reconnect scheduled in ${delay} ms`, { reason: errorMessage(reason) });
retryTimer = timers.setTimeout(() => {
retryTimer = null;
tryTokenLogin('reconnecting').catch(() => {});
}, delay);
}
function handleLoginFailure(error: unknown) {
if (stopped) return;
lastError = errorMessage(error);
guard = null;
pendingGuardCallback = null;
if (isRecoverableLoginError(error)) {
scheduleReconnect(error);
return;
}
status = 'error';
log('error', 'Steam login failed', { error: lastError });
loginDeferred.reject(error);
webDeferred.reject(error);
}
function refreshWebSession(): Promise<WebSession> {
if (stopped) return Promise.reject(new Error('Steam login service is stopped'));
webDeferred = createHandledDeferred<WebSession>();
try {
steamUser.webLogOn();
} catch (error) {
webDeferred.reject(error);
}
return webDeferred.promise;
}
steamUser.on('steamGuard', (domain: string | null, callback: (code: string) => void, lastCodeWrong: boolean) => {
status = 'waiting_guard';
guard = {
guardType: typeof domain === 'string' ? 'email' : 'device',
domain: typeof domain === 'string' ? domain : null,
lastCodeWrong: Boolean(lastCodeWrong)
};
pendingGuardCallback = callback;
lastError = lastCodeWrong ? 'Steam Guard code was rejected' : null;
});
steamUser.on('loggedOn', () => {
clearRetryTimer();
retryDelayMs = INITIAL_RETRY_DELAY_MS;
status = 'online';
guard = null;
pendingGuardCallback = null;
lastError = null;
steamId = steamIdToText(steamUser.steamID);
loginDeferred.resolve(true);
log('info', 'Steam logged on');
try {
steamUser.setPersona?.(steamUser.EPersonaState?.Online || 1);
} catch (_) {
// setPersona is optional and not important enough to break runtime login.
}
refreshWebSession().catch((error: unknown) => {
log('warn', 'Steam webLogOn failed after login', { error: errorMessage(error) });
});
});
steamUser.on('webSession', (sessionID: string, cookies: string[]) => {
latestWebSession = { sessionID, cookies };
if (steamCommunity && typeof steamCommunity.setCookies === 'function') {
steamCommunity.setCookies(cookies);
}
if (steamCommunity && config.identitySecret && typeof steamCommunity.startConfirmationChecker === 'function') {
steamCommunity.startConfirmationChecker(10 * 1000, config.identitySecret);
}
webDeferred.resolve(latestWebSession);
});
steamUser.on('refreshToken', (refreshToken: string) => {
if (!refreshToken) return;
fileSystem.mkdirSync(path.dirname(refreshTokenPath), { recursive: true });
fileSystem.writeFileSync(refreshTokenPath, `${refreshToken}\n`, 'utf8');
log('info', 'Steam refresh token saved');
});
steamUser.on('error', handleLoginFailure);
steamUser.on('disconnected', (eresult: unknown, message?: string) => {
if (manualLogoff) {
manualLogoff = false;
return;
}
const error = new Error(message || `Steam disconnected: ${eresult || 'unknown'}`);
if (status === 'online' || status === 'reconnecting') {
scheduleReconnect(error);
return;
}
handleLoginFailure(error);
});
function getStatus(): SteamStatusSummary {
return {
status,
requiresGuard: status === 'waiting_guard',
guardType: guard?.guardType || null,
domain: guard?.domain || null,
lastCodeWrong: Boolean(guard?.lastCodeWrong),
error: lastError,
steamId
};
}
return {
start() {
stopped = false;
return tryTokenLogin();
},
stop() {
stopped = true;
clearRetryTimer();
if (typeof steamUser.logOff === 'function') steamUser.logOff();
},
login(input: SteamLoginRequest) {
if (status === 'logging_in' || status === 'waiting_guard' || status === 'reconnecting' || status === 'online') {
throw Object.assign(new Error('Steam login is already active'), { statusCode: 409 });
}
const accountName = String(input.accountName || '').trim();
const password = String(input.password || '');
if (!accountName || !password) {
throw Object.assign(new Error('accountName and password are required'), { statusCode: 400 });
}
const parsedLogonID = Number(input.logonID);
const logonID = Number.isFinite(parsedLogonID) && parsedLogonID > 0
? Math.floor(parsedLogonID)
: getDefaultLogonID?.();
const optionsForLogin: LogOnOptions = {
...baseLoginOptions(),
accountName,
password
};
if (logonID) optionsForLogin.logonID = logonID;
beginLogin(optionsForLogin, 'account credentials').catch(() => {});
return getStatus();
},
submitGuard(code: unknown) {
const value = String(code || '').trim();
if (!value) throw Object.assign(new Error('Steam Guard code is required'), { statusCode: 400 });
if (status !== 'waiting_guard' || !pendingGuardCallback) {
throw Object.assign(new Error('Steam Guard is not pending'), { statusCode: 409 });
}
const callback = pendingGuardCallback;
pendingGuardCallback = null;
guard = null;
lastError = null;
status = 'logging_in';
callback(value);
return getStatus();
},
logout() {
clearRetryTimer();
manualLogoff = true;
guard = null;
pendingGuardCallback = null;
latestWebSession = null;
steamId = null;
lastError = null;
status = 'logged_out';
resetDeferreds();
deleteRefreshToken(refreshTokenPath);
if (typeof steamUser.logOff === 'function') steamUser.logOff();
return getStatus();
},
ensureOnline() {
if (status !== 'online') throw steamUnavailableError(status);
},
waitForLogin() {
return status === 'online' ? Promise.resolve(true) : loginDeferred.promise;
},
waitForWebSession() {
return latestWebSession ? Promise.resolve(latestWebSession) : webDeferred.promise;
},
refreshWebSession,
getLatestWebSession() {
return latestWebSession;
},
getStatus,
getRetryDelayMs() {
return retryDelayMs;
}
};
}
module.exports = { module.exports = {
DEFAULT_REFRESH_TOKEN_PATH, DEFAULT_REFRESH_TOKEN_PATH,
INITIAL_RETRY_DELAY_MS, INITIAL_RETRY_DELAY_MS,
MAX_RETRY_DELAY_MS, MAX_RETRY_DELAY_MS,
buildLogOnOptions, buildLogOnOptions,
createDeferred, createDeferred,
createSteamLoginService,
createSteamLifecycle, createSteamLifecycle,
deleteRefreshToken,
isRecoverableLoginError, isRecoverableLoginError,
isUnrecoverableLoginError, isUnrecoverableLoginError,
readRefreshToken readRefreshToken

83
test/backend-auth.test.ts Normal file
View File

@@ -0,0 +1,83 @@
'use strict';
import type { IncomingMessage } from 'node:http';
import type { TestContext } from 'node:test';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const { createAuthStore } = require('../src/auth/store');
const { createSessionManager } = require('../src/auth/session');
function tempDbPath() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'steam-chat-auth-'));
return path.join(dir, 'auth.sqlite');
}
function requestWithCookie(cookie: string): IncomingMessage {
return {
headers: { cookie },
socket: { remoteAddress: '127.0.0.1' }
} as IncomingMessage;
}
function cookiePair(setCookie: string) {
return setCookie.split(';')[0];
}
test('auth setup creates the first admin once and login rejects bad or disabled users', (t: TestContext) => {
const store = createAuthStore({ dbPath: tempDbPath() });
t.after(() => store.close());
assert.equal(store.requiresSetup(), true);
const admin = store.createInitialAdmin({ username: 'admin', password: 'password123' });
assert.equal(admin.role, 'admin');
assert.equal(admin.disabled, false);
assert.equal(store.requiresSetup(), false);
assert.throws(() => store.createInitialAdmin({ username: 'next', password: 'password123' }), /already/);
assert.equal(store.authenticate('admin', 'wrong'), null);
assert.equal(store.authenticate('admin', 'password123').username, 'admin');
const user = store.createUser({ username: 'worker', password: 'password123', role: 'user' });
store.updateUser(user.id, { disabled: true });
assert.equal(store.authenticate('worker', 'password123'), null);
});
test('session cookies reject tampering, expiry, and stale session_version', (t: TestContext) => {
const store = createAuthStore({ dbPath: tempDbPath() });
t.after(() => store.close());
const admin = store.createInitialAdmin({ username: 'admin', password: 'password123' });
const sessions = createSessionManager({ store });
const cookie = cookiePair(sessions.createSetCookie(admin));
assert.equal(sessions.getSession(requestWithCookie(cookie))?.user.username, 'admin');
assert.equal(sessions.getSession(requestWithCookie(`${cookie}x`)), null);
const expiredSessions = createSessionManager({ store, maxAgeMs: -1000 });
const expired = cookiePair(expiredSessions.createSetCookie(admin));
assert.equal(expiredSessions.getSession(requestWithCookie(expired)), null);
store.setPassword(admin.id, 'password456');
assert.equal(sessions.getSession(requestWithCookie(cookie)), null);
});
test('user management protects the current user and the last enabled admin', (t: TestContext) => {
const store = createAuthStore({ dbPath: tempDbPath() });
t.after(() => store.close());
const admin = store.createInitialAdmin({ username: 'admin', password: 'password123' });
const user = store.createUser({ username: 'worker', password: 'password123', role: 'user' });
assert.throws(() => store.updateUser(admin.id, { role: 'user' }), /last enabled admin/);
assert.throws(() => store.updateUser(admin.id, { disabled: true }), /last enabled admin/);
assert.throws(() => store.deleteUser(admin.id, user.id), /last enabled admin/);
assert.throws(() => store.deleteUser(admin.id, admin.id), /current user/);
const admin2 = store.createUser({ username: 'admin2', password: 'password123', role: 'admin' });
store.updateUser(admin.id, { role: 'user' });
assert.equal(store.getUserById(admin.id).role, 'user');
store.deleteUser(admin.id, admin2.id);
assert.equal(store.getUserById(admin.id), null);
});

View File

@@ -0,0 +1,185 @@
'use strict';
import type { EventEmitter as EventEmitterType } from 'node:events';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { TestContext } from 'node:test';
import type { WebSocket as WsConnection } from 'ws';
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const WebSocket = require('ws');
const { createAuthStore } = require('../src/auth/store');
const { createSessionManager } = require('../src/auth/session');
const { createChatService } = require('../src/server/chat-service');
type TestSteamUser = EventEmitterType & {
chat: {
sendFriendMessage: (id: unknown, msg: unknown, callback: (error: Error | null) => void) => void;
};
};
type ChatServiceRuntime = {
server: Server;
stop: () => Promise<void>;
};
type SteamStatus = {
status: string;
requiresGuard: boolean;
guardType: null;
domain: null;
lastCodeWrong: boolean;
error: null;
steamId: null;
};
type WsConstructor = new (url: string, options?: Record<string, unknown>) => WsConnection;
const WsClient = WebSocket as WsConstructor;
function listen(server: Server): Promise<number> {
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve((server.address() as AddressInfo).port);
});
});
}
function cookiePair(setCookie: string | null) {
assert.ok(setCookie);
return setCookie.split(';')[0];
}
function tempPath(name: string) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `steam-chat-${name}-`));
return { dir, dbPath: path.join(dir, 'auth.sqlite'), logPath: path.join(dir, 'chat.jsonl') };
}
function offlineSteamService() {
const status: SteamStatus = {
status: 'logged_out',
requiresGuard: false,
guardType: null,
domain: null,
lastCodeWrong: false,
error: null,
steamId: null
};
return {
getStatus() {
return status;
},
ensureOnline() {
throw Object.assign(new Error('Steam is not logged in'), { statusCode: 503, steamStatus: status.status });
},
login() {
return status;
},
logout() {
return status;
},
submitGuard() {
return status;
}
};
}
function wsOpen(url: string, cookie: string): Promise<WsConnection> {
return new Promise((resolve, reject) => {
const ws = new WsClient(url, { headers: { Cookie: cookie } });
ws.once('open', () => resolve(ws));
ws.once('error', reject);
});
}
function wsRejects(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const ws = new WsClient(url);
ws.once('open', () => {
ws.close();
reject(new Error('WebSocket unexpectedly opened'));
});
ws.once('error', () => resolve());
});
}
test('backend auth gates chat HTTP APIs and reports Steam offline as 503', async (t: TestContext) => {
const paths = tempPath('backend-http');
const store = createAuthStore({ dbPath: paths.dbPath });
t.after(() => store.close());
const sessions = createSessionManager({ store });
const admin = store.createInitialAdmin({ username: 'admin', password: 'password123' });
const cookie = cookiePair(sessions.createSetCookie(admin));
const steamUser = new EventEmitter() as TestSteamUser;
steamUser.chat = {
sendFriendMessage(_id: unknown, _msg: unknown, callback: (error: Error | null) => void) {
callback(null);
}
};
const service = createChatService({
config: { host: '127.0.0.1', port: 0, wsPath: '/ws' },
steamUser,
logPath: paths.logPath,
authStore: store,
sessionManager: sessions,
steamLoginService: offlineSteamService(),
logger: { info() {}, warn() {}, error() {} }
}) as ChatServiceRuntime;
t.after(() => service.stop().catch(() => {}));
const port = await listen(service.server);
assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status, 200);
assert.equal((await fetch(`http://127.0.0.1:${port}/history`)).status, 401);
const configResponse = await fetch(`http://127.0.0.1:${port}/api/config`, {
headers: { Cookie: cookie }
});
assert.equal(configResponse.status, 200);
const sendResponse = await fetch(`http://127.0.0.1:${port}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ id: '42', msg: 'hello' })
});
assert.equal(sendResponse.status, 503);
assert.deepEqual(await sendResponse.json(), {
error: 'Steam is not logged in',
steamStatus: 'logged_out'
});
});
test('backend WebSocket rejects missing cookies and accepts authenticated users', async (t: TestContext) => {
const paths = tempPath('backend-ws');
const store = createAuthStore({ dbPath: paths.dbPath });
t.after(() => store.close());
const sessions = createSessionManager({ store });
const admin = store.createInitialAdmin({ username: 'admin', password: 'password123' });
const cookie = cookiePair(sessions.createSetCookie(admin));
const steamUser = new EventEmitter() as TestSteamUser;
steamUser.chat = {
sendFriendMessage(_id: unknown, _msg: unknown, callback: (error: Error | null) => void) {
callback(null);
}
};
const service = createChatService({
config: { host: '127.0.0.1', port: 0, wsPath: '/ws' },
steamUser,
logPath: paths.logPath,
authStore: store,
sessionManager: sessions,
steamLoginService: offlineSteamService(),
logger: { info() {}, warn() {}, error() {} }
}) as ChatServiceRuntime;
t.after(() => service.stop().catch(() => {}));
const port = await listen(service.server);
await wsRejects(`ws://127.0.0.1:${port}/ws`);
const ws = await wsOpen(`ws://127.0.0.1:${port}/ws`, cookie);
t.after(() => ws.close());
assert.equal(ws.readyState, WebSocket.OPEN);
});

View File

@@ -0,0 +1,110 @@
'use strict';
import type { EventEmitter as EventEmitterType } from 'node:events';
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const { createSteamLoginService } = require('../src/steam/lifecycle');
type LogOnOptions = {
accountName?: string;
password?: string;
refreshToken?: string;
logonID?: number;
};
type LoginTestUser = EventEmitterType & {
steamID?: unknown;
logOn: (options: LogOnOptions) => void;
webLogOn: () => void;
logOff: () => void;
};
function tempTokenPath() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'steam-chat-login-'));
return path.join(dir, 'refresh.token');
}
function createUser(logOnCalls: LogOnOptions[]): LoginTestUser {
const user = new EventEmitter() as LoginTestUser;
user.logOn = (options: LogOnOptions) => logOnCalls.push(options);
user.webLogOn = () => user.emit('webSession', 'session-id', ['a=b']);
user.logOff = () => {};
return user;
}
test('SteamLoginService starts without token as logged_out and token login does not require account credentials', async () => {
const tokenPath = tempTokenPath();
const calls: LogOnOptions[] = [];
const user = createUser(calls);
const service = createSteamLoginService({
steamUser: user,
refreshTokenPath: tokenPath,
logger: { info() {}, warn() {}, error() {} }
});
assert.equal(await service.start(), false);
assert.equal(calls.length, 0);
assert.equal(service.getStatus().status, 'logged_out');
fs.writeFileSync(tokenPath, 'refresh-value\n');
const tokenLogin = service.start();
assert.equal(calls.length, 1);
assert.deepEqual(calls[0], { refreshToken: 'refresh-value' });
user.emit('loggedOn');
await tokenLogin;
assert.equal(service.getStatus().status, 'online');
});
test('SteamLoginService exposes Guard state and submits the pending callback', () => {
const calls: LogOnOptions[] = [];
const user = createUser(calls);
const service = createSteamLoginService({
steamUser: user,
refreshTokenPath: tempTokenPath(),
getDefaultLogonID: () => 12345,
logger: { info() {}, warn() {}, error() {} }
});
const status = service.login({ accountName: 'name', password: 'secret' });
assert.equal(status.status, 'logging_in');
assert.equal(calls.length, 1);
assert.equal(calls[0].accountName, 'name');
assert.equal(calls[0].password, 'secret');
assert.equal(calls[0].logonID, 12345);
let submitted = '';
user.emit('steamGuard', null, (code: string) => {
submitted = code;
}, false);
const waiting = service.getStatus();
assert.equal(waiting.status, 'waiting_guard');
assert.equal(waiting.guardType, 'device');
assert.equal(waiting.requiresGuard, true);
service.submitGuard('ABCDE');
assert.equal(submitted, 'ABCDE');
assert.equal(service.getStatus().status, 'logging_in');
});
test('SteamLoginService saves refresh tokens and logout deletes the persisted token', () => {
const tokenPath = tempTokenPath();
const calls: LogOnOptions[] = [];
const user = createUser(calls);
const service = createSteamLoginService({
steamUser: user,
refreshTokenPath: tokenPath,
logger: { info() {}, warn() {}, error() {} }
});
user.emit('refreshToken', 'next-token');
assert.equal(fs.readFileSync(tokenPath, 'utf8'), 'next-token\n');
service.logout();
assert.equal(fs.existsSync(tokenPath), false);
assert.equal(service.getStatus().status, 'logged_out');
});

1781
web/app.ts

File diff suppressed because it is too large Load Diff

View File

@@ -3,98 +3,16 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Steam Chat</title> <title>Steam Chat Admin</title>
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body> <body>
<div class="shell"> <div id="app"></div>
<aside id="sidebar" class="sidebar">
<header class="side-head">
<div>
<h1>Steam Chat</h1>
<p id="connectionText">连接中</p>
</div>
<button id="closeSidebar" class="icon-btn" type="button" title="关闭侧栏">×</button>
</header>
<form id="openForm" class="open-form">
<input id="targetInput" inputmode="numeric" autocomplete="off" placeholder="SteamID64">
<button type="submit">打开</button>
</form>
<div class="side-tools">
<label>历史 <input id="historyLimit" type="number" min="1" max="500" step="10"></label>
<button id="refreshAll" type="button">刷新</button>
</div>
<div class="tabs" role="tablist">
<button class="is-active" type="button" data-tab="conversations">最近</button>
<button type="button" data-tab="friends">好友</button>
<button type="button" data-tab="groups">群组</button>
</div>
<section class="panel is-active" data-panel="conversations">
<div id="conversationList" class="list"></div>
</section>
<section class="panel" data-panel="friends">
<div id="friendList" class="list"></div>
</section>
<section class="panel" data-panel="groups">
<div id="groupList" class="list"></div>
</section>
</aside>
<div id="backdrop" class="backdrop"></div>
<main class="chat">
<header class="chat-head">
<button id="openSidebar" class="icon-btn menu-btn" type="button" title="打开侧栏"></button>
<div class="chat-title">
<h2 id="chatTitle">未选择会话</h2>
<p id="chatSubtitle">选择好友、群组或输入 SteamID64</p>
</div>
<div id="feedback" class="feedback">就绪</div>
</header>
<section id="messages" class="messages" aria-live="polite"></section>
<footer class="composer">
<div id="uploadTray" class="upload-tray"></div>
<div id="picker" class="picker" hidden>
<div class="picker-head">
<button class="is-active" type="button" data-picker="emoticons">表情</button>
<button type="button" data-picker="stickers">贴纸</button>
<input id="pickerSearch" type="search" placeholder="搜索">
</div>
<div id="pickerGrid" class="picker-grid"></div>
</div>
<div id="autocomplete" class="autocomplete" hidden></div>
<div class="compose-row">
<button id="pickerToggle" class="icon-btn" type="button" title="表情和贴纸"></button>
<button id="fileButton" class="icon-btn" type="button" title="选择图片"></button>
<input id="fileInput" type="file" accept="image/*" multiple hidden>
<textarea id="messageInput" rows="1" placeholder="发送消息"></textarea>
<button id="sendButton" type="button">发送</button>
</div>
<form id="imageUrlForm" class="url-row">
<input id="imageUrlInput" type="url" placeholder="图片 URL">
<button type="submit">发送图片</button>
</form>
</footer>
</main>
</div>
<div id="dropOverlay" class="drop-overlay">释放发送图片</div> <div id="dropOverlay" class="drop-overlay">释放发送图片</div>
<div id="lightbox" class="lightbox" hidden> <div id="lightbox" class="lightbox" hidden>
<button id="lightboxClose" class="icon-btn lightbox-close" type="button" title="关闭">×</button> <button id="lightboxClose" class="icon-btn lightbox-close" type="button" title="关闭">x</button>
<div class="lightbox-tools">
<button id="zoomOut" type="button">-</button>
<button id="zoomReset" type="button">100%</button>
<button id="zoomIn" type="button">+</button>
</div>
<img id="lightboxImage" alt=""> <img id="lightboxImage" alt="">
</div> </div>
<script type="module" src="/app.js"></script> <script type="module" src="/app.js"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff