Skip to content
dsh.fish
Bundle

dsh-auth-plugin

DSH Web 用户授权插件 — 用户名密码 + 通用 OAuth 2.0(内置 GitHub/Google/Discord 等模板)+ Solana/EVM 钱包登录。零依赖核心,纯配置接入

Source
v1xingyue
stars
1 stars
License
MIT
Updated
Updated 13 days ago

Readme

# DSH Auth Plugin — DeepSeek Harness 登录认证插件

给 DeepSeek Harness Web GUI 添加登录保护的 Cordis 插件。
**零第三方依赖、单文件、极简配置**,同时支持:

- 🔑 **用户名 + 密码登录**(scrypt 哈希存储)
- 🦖 **Solana 钱包登录**(ed25519 签名验证 + 公钥白名单)
- 🛡️ **统一会话管理**(HMAC-SHA256 令牌 + HttpOnly Cookie + 登出吊销)

---

## 目录

1. [特性总览](#特性总览)
2. [架构与工作原理](#架构与工作原理)
3. [安装](#安装)
4. [快速开始](#快速开始)
5. [配置参考](#配置参考)
6. [API 参考](#api-参考)
7. [前端集成](#前端集成)
8. [安全设计](#安全设计)
9. [用户与密钥管理](#用户与密钥管理)
10. [测试](#测试)
11. [已知问题与故障排除](#已知问题与故障排除)
12. [升级与回滚](#升级与回滚)
13. [生产部署建议](#生产部署建议)
14. [许可证](#许可证)

---

## 特性总览

| 特性 | 说明 | 状态 |
|------|------|------|
| 用户名密码登录 | scrypt 哈希存储,支持明文(开发环境)与哈希 | ✅ |
| **通用 OAuth 2.0 登录** | 任意授权码 provider(GitHub/Google/Discord…),纯配置接入 | ✅ |
| Solana 钱包登录 | Phantom/Solflare,ed25519 challenge-response | ✅ |
| **EVM 钱包登录** | MetaMask 等,EIP-191 personal_sign + ecrecover | ✅ 新增 |
| 白名单强制 | 钱包登录 `allowlist`/`allowlist` **必填**,禁止任意钱包 | ✅ |
| 会话令牌 | HMAC-SHA256 自包含 token(JWT 风格) | ✅ |
| 防重放 | 一次性 nonce/state,消费即删 | ✅ |
| 登出吊销 | 服务端内存黑名单,防被窃 cookie 复用 | ✅ |
| 统一门卫 | 包装 webserver fallback,保护全部 SPA 页面与静态资源 | ✅ |
| 核心零依赖 | `node:crypto`(scrypt/ed25519/HMAC)+ Node 全局 fetch + 手写 base58 | ✅ |
| 可选增强 | EVM 需要纯 JS 库 `@noble/curves`+`@noble/hashes`(缺失时自动禁用) | ⚙️ |
| 极简安装 | 复制 1 个文件 + 2 行配置,无需 pnpm/npm | ✅ |
| npm 发布就绪 | `exports`/`files`/`engines`/`prepack` 校验,`npm pack` 验证通过 | ✅ |
| bundle 化 | `dsh.bundle` 声明,`dsh plugin add` 一行安装即生效 | ✅ |

**版本**:`1.8.0`(`package.json`)· 测试:60 项(34 单元 + 26 端到端)

---

## 架构与工作原理

### 模块解析基础

DSH 的 Loader 以 **profile 目录**(`~/.dsh/profiles/web/`)为 `baseUrl`,
插件行的 `name` 直接传给 Node `import()`,因此:

- `name: "./dsh-auth-plugin.js"` → 相对 profile 目录加载文件
- `name: "@scope/pkg"` → 从 profile 的 `node_modules` 解析
- `name: "file:///abs/path"` → 绝对路径加载

`$DSH_HOME/profiles/node_modules` 是 dsh 全依赖闭包的**扁平回退目录**
(含全部 195+ 个 `@deepseek-ai/*` 包),所以插件里
`import "@deepseek-ai/schemastery"` 等依赖**无需安装即可解析**——
这是"复制即用"机制的根基。

### 请求流(门卫架构)

```
浏览器请求
   │
   ▼
webserver.match(pathname)
   │
   ├─ 精确路由表命中(exact)        → 直接处理
   │    /login                          → 登录页
   │    /api/auth/login                 → 密码登录 API
   │    /api/auth/logout                → 登出 API
   │    /api/auth/solana/challenge      → Solana 钱包 challenge
   │    /api/auth/solana/verify         → Solana 钱包 verify
   │    /api/auth/evm/challenge         → EVM 钱包 challenge
   │    /api/auth/evm/verify            → EVM 钱包 verify
   │
   ├─ 最长前缀路由命中(prefix)
   │    /api/auth/oauth/<id>/start      → OAuth 授权跳转(本插件)
   │    /api/auth/oauth/<id>/callback   → OAuth 回调(本插件)
   │    /api/*(client-connection 拥有)→ 保持 DSH 自带 loopback/
   │                                      trustedHosts 篱笆(刻意不拦截)
   │
   └─ 未命中 → fallback 座位(本插件包装)
        ├─ 公开路径?                → 放行
        ├─ 有有效会话 Cookie?       → 放行(用户信息挂 req.authUser)
        ├─ 浏览器请求(Accept: html)→ 302 → /login
        └─ API 调用                  → 401 JSON { code: "auth_required" }
              │
              ▼
        frontend-static(原始 dist 服务,SPA 页面与静态资源)
```

**关键设计**:插件注册为**唯一 fallback 座位持有者**(替换 frontend-static
的占座,认证通过后转交原始 handler)。这比注册 `prefix "/"` 路由可靠——
webserver 的 prefix 匹配 `pathname.startsWith("/" + "/")` 对非根路径恒为
false,`prefix "/"` 实际拦不住任何路径(早期版本的 bug,已修复)。

### 会话令牌

- 结构:`<base64url(payload)>.<HMAC-SHA256(base64url(payload))>`
- Payload:`{ u: 用户名, r: 角色, exp: 过期时间戳 }`
- 传输:HttpOnly + SameSite=Strict + Max-Age 的 Cookie
- 吊销:登出时 token 进内存黑名单(重启清空)

### Solana 登录流程

```
登录页点击「使用 Solana 钱包登录」
   │
   ├─ 1. POST /api/auth/solana/challenge { publicKey }
   │      校验 base58(32B) → 白名单检查(403) → 签发一次性 nonce
   │      返回 { nonce, message: "DSH Login <nonce>", expiresAt }
   │
   ├─ 2. 钱包 signMessage(message)(用户在钱包中确认)
   │
   └─ 3. POST /api/auth/solana/verify { publicKey, signature, nonce }
          白名单检查(403) → 消费 nonce(401 防重放) → ed25519 验签(401)
          → 通过则签发会话 Cookie
```

消息编码兼容两种:Phantom `signMessage` 直接 UTF-8 字节,以及 SIWS v0
官方格式(`\xff` + 小写 `"solana offchain message"` + preamble,见
[Agave 规范](https://docs.anza.xyz/proposals/off-chain-message-signing))。

### OAuth 授权码流程

```
登录页点击「使用 GitHub 登录」
   │
   ├─ 1. GET /api/auth/oauth/<id>/start
   │      签发一次性 state(绑定 provider,防 CSRF)
   │      302 → provider 授权页 ?client_id&redirect_uri&state[&scope]
   │
   ├─ 2. 用户在 provider 页面授权
   │
   ├─ 3. provider 302 → /api/auth/oauth/<id>/callback?code=&state=
   │      消费 state(一次性+过期+绑定)→ 失败 302 /login?error=oauth_bad_state
   │      服务端 code 换 token(client_secret 不下发浏览器)
   │      带 Bearer 取 userinfo → 映射 id/name/email
   │      签发会话 Cookie → 302 /
```

零依赖:token 交换与 userinfo 用 Node 全局 `fetch`(Node 18+)。

### EVM 钱包流程(MetaMask 等)

```
登录页点击「⬡ 使用 EVM 钱包登录」
   │
   ├─ 1. POST /api/auth/evm/challenge { address }
   │      校验 0x 地址 → 白名单检查(403) → 签发一次性 nonce
   │      返回 { nonce, message: "DSH Login <nonce>", expiresAt }
   │
   ├─ 2. 钱包 personal_sign(message, address)(用户在钱包中确认)
   │      返回 65 字节 r||s||v 十六进制签名
   │
   └─ 3. POST /api/auth/evm/verify { address, signature, nonce }
          白名单检查(403) → 消费 nonce(401 防重放)
          → EIP-191 消息哈希 → ecrecover 恢复地址(secp256k1)
          → 恢复地址 === 声明地址 → 签发会话 Cookie
```

依赖可选的 `@noble/curves` + `@noble/hashes`(纯 JS、无 native);
缺失时 EVM 登录自动禁用并打印警告,**其他登录方式不受影响**。
安装(仅启用 EVM 时需要):

```bash
dsh plugin --profile web add @noble/curves @noble/hashes
```

---

## 安装

### 方式 A:直接复制(推荐,已验证)

```bash
# 1. 复制单文件到 web profile 目录
cp lib/index.js ~/.dsh/profiles/web/dsh-auth-plugin.js
```

```yaml
# 2. ~/.dsh/profiles/web/cordis.patch.yml 追加
- insert:
    - id: auth
      name: "./dsh-auth-plugin.js"
      config:
        users:
          admin: admin123
```

重启 `dsh web` 生效。**不需要**改 `package.json`、跑 pnpm 或安装依赖。

### 方式 B:npm 包安装(发布到 registry 后)

```bash
# 从 npm registry 安装到 web profile
dsh plugin --profile web add dsh-auth-plugin
```

然后同样在 `cordis.patch.yml` insert 一行(name 用包名,依赖由
`dsh plugin`/pnpm 解析):

```yaml
- insert:
    - id: auth
      name: "dsh-auth-plugin"
      config:
        users:
          admin: admin123
```

> `dsh plugin` 是 pnpm 转发器;本插件为普通插件(无 `dsh.bundle`),
> 安装后需 insert 一行声明(bundle 形态见方式 C)。

### 发布到 npm(维护者)

插件已做好发布就绪(`package.json` 含 `exports`/`files`/`engines`/
`prepack` 校验):

```bash
npm pack                       # 本地验证产物(prepack 会先跑语法检查)
npm login
npm publish --access public
```

```bash
# 本地测试 tarball 安装
npm pack
dsh plugin --profile web add file:/path/to/your-org-dsh-auth-plugin-1.6.0.tgz
```

### 方式 C:bundle 化(推荐给发布后的 npm 包)✅ 已实现

本插件已声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,
**安装即自动成为配置层**,连 `cordis.patch.yml` 的 insert 都省了:

```bash
dsh plugin --profile web add dsh-auth-plugin
# → pnpm 安装 → 自动加入 bundles 层 → auth 插件行自动生效
#    (默认 admin/admin123,启动警告,请立即覆盖配置)
```

安装后在 profile 的 `cordis.patch.yml` 按 id 覆盖 config 即可:

```yaml
- id: auth
  config:
    users:
      admin: { password: "scrypt$...", role: admin }
    oauth:
      providers:
        github: { builtin: github, clientId: "...", clientSecret: "..." }
```

> 已在隔离 DSH_HOME 完整验证:`dsh plugin add <tarball>` → bundles 列表
> 自动追加 → `--dump-config` 出现 `# == dsh-auth-plugin` 段。

### 安装后文件布局

```
~/.dsh/profiles/web/
├── cordis.patch.yml         # 认证配置(insert auth 行)
├── dsh-auth-plugin.js       # 插件本体(单文件)
├── package.json             # profile manifest(可选加 "type": "module")
└── pnpm-workspace.yaml
```

---

## 快速开始

```yaml
# cordis.patch.yml —— 最简配置:用户名密码登录
- insert:
    - id: auth
      name: "./dsh-auth-plugin.js"
      config:
        users:
          admin: admin123
```

```yaml
# 用户名密码 + Solana 钱包登录(allowlist 必填)
- insert:
    - id: auth
      name: "./dsh-auth-plugin.js"
      config:
        users:
          admin:
            password: "scrypt$<salt>$<hash>"
            role: "admin"
        solana:
          enabled: true
          allowlist:
            - "Dy6mBH4YeqJCRZohd39iSFaf4jyLaxPeBakbZwt1jToL"
```

重启 `dsh web` 后访问 `http://127.0.0.1:3080` 即重定向到登录页。

---

## 配置参考

### 顶层配置

| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `enabled` | boolean | `true` | 总开关 |
| `secret` | string | 每次启动随机 | 会话签名密钥;可用环境变量 `DSH_AUTH_SECRET` 固定 |
| `ttlHours` | number | `24` | 会话有效期(小时) |
| `title` | string | `DSH 登录` | 登录页标题 |
| `cookie` | string | `dsh_session` | 会话 Cookie 名 |
| `users` | dict | 空→`admin/admin123` | 用户表(见下) |
| `solana` | `false` \| object | `false` | Solana 钱包登录配置(见下) |
| `evm` | `false` \| object | `false` | EVM 钱包登录配置(见下) |
| `oauth` | object | `{providers:{}}` | 通用 OAuth 配置(见下) |
| `publicPaths` | string[] | 见下 | 免认证路径(精确或前缀匹配) |

默认 `publicPaths`:`/login`、`/api/auth/login`、`/api/auth/logout`、
`/favicon.ico`

> ⚠️ **`secret` 为空时每次启动随机**——重启后所有会话失效需重新登录。
> 这是保守设计(无需持久化);生产环境建议固定:`secret: !!js process.env.DSH_AUTH_SECRET`。

### `users` 三种写法

```yaml
# 1. 极简:用户名: 明文密码(开发环境)
users:
  admin: admin123

# 2. 带角色
users:
  admin: { password: admin123, role: admin }

# 3. 推荐:scrypt 哈希
users:
  admin: { password: "scrypt$<salt>$<hash>", role: admin }
```

明文密码启动时会打印警告(仅限开发环境)。

### `solana` 配置

| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `enabled` | boolean | `true`(对象形式) | 开关;`solana: false` 或省略 = 禁用 |
| `challengeTtlMs` | number | `300000` | nonce 有效期(5 分钟) |
| `allowlist` | string[] | **必填** | 公钥白名单(base58),至少 1 个 |
| `role` | string | `user` | 钱包登录默认角色 |

> ⚠️ **`allowlist` 必填**:启用时缺字段或空数组都会在加载时报配置错误
> (schema 层拦截)——**不允许"任何钱包可登录"**。

### `evm` 配置(MetaMask 等 EVM 钱包)

```yaml
evm:
  enabled: true
  allowlist:                # 必填:只允许这些 0x 地址(小写或混合大小写均可)
    - "0x4e984616e2dd9dffe7f2413efc7da35ef64c4117"
  role: user
```

| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `enabled` | boolean | `true`(对象形式) | 开关;`evm: false` 或省略 = 禁用 |
| `challengeTtlMs` | number | `300000` | nonce 有效期(5 分钟) |
| `allowlist` | string[] | **必填** | 允许的 0x 地址,至少 1 个 |
| `role` | string | `user` | 登录成功角色 |

> ⚠️ 同 Solana:`allowlist` 必填;且需要可选依赖
> `@noble/curves` + `@noble/hashes`(`dsh plugin --profile web add @noble/curves @noble/hashes`),
> 缺失时该功能自动禁用(其余登录不受影响)。

### `oauth` 通用 OAuth 2.0 配置

每个 provider 一条,纯配置接入任意标准授权码 OAuth 服务。
**支持内置模板**:填 `builtin` + 凭据即可,端点/字段映射/scope 自动填充。

#### 最简方式:内置模板(推荐)

```yaml
oauth:
  providers:
    github:                       # provider id
      builtin: "github"           # ← 内置模板:端点/字段/scope 自动填充
      clientId: "Ov23li..."
      clientSecret: "ghp_..."     # 仅服务端使用,绝不下发浏览器
```

内置模板一览(`BUILTIN_OAUTH`,显式字段可覆盖模板):

| builtin | 授权端点 | token 端点 | userinfo 端点 | 默认 scope | idField |
|---------|---------|-----------|--------------|-----------|---------|
| `github` | github.com/login/oauth/authorize | …/access_token | api.github.com/user | `read:user` | `id` |
| `google` | accounts.google.com/o/oauth2/v2/auth | oauth2.googleapis.com/token | …/oauth2/v3/userinfo | `openid email profile` | `sub` |
| `discord` | discord.com/oauth2/authorize | discord.com/api/oauth2/token | discord.com/api/users/@me | `identify email` | `id` |
| `gitlab` | gitlab.com/oauth/authorize | gitlab.com/oauth/token | gitlab.com/api/v4/user | `read_user` | `id` |
| `microsoft` | login.microsoftonline.com/common/oauth2/v2.0/authorize | …/token | graph.microsoft.com/v1.0/me | `User.Read` | `id` |
| `bitbucket` | bitbucket.org/site/oauth2/authorize | …/access_token | api.bitbucket.org/2.0/user | `account` | `uuid` |

#### 完整方式:显式配置(任意标准 OAuth 服务)

```yaml
oauth:
  providers:
    custom:
      label: "我的服务"
      clientId: "..."
      clientSecret: "..."
      authorizeUrl: "https://.../authorize"
      tokenUrl: "https://.../token"
      userInfoUrl: "https://.../userinfo"
      scope: "read"                # 可选
      idField: "id"                # 可选,默认 id
      nameField: "name"            # 可选
      emailField: "email"          # 可选
      role: "user"                 # 可选
      # redirectUri: "https://..." # 可选:显式回调地址
```

| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `builtin` | string | `""` | 内置模板名(见上表);填了则端点/字段/scope 用模板 |
| `label` | string | provider id / 模板 | 登录页按钮文案 |
| `clientId` / `clientSecret` | string | **必填** | OAuth 应用凭据 |
| `authorizeUrl` / `tokenUrl` / `userInfoUrl` | string | 模板值 | 三个端点 |
| `responseType` | string | `code` | 授权响应类型(授权码模式) |
| `scope` | string | 模板值/空 | 请求的 scope(空格分隔) |
| `redirectUri` | string | 自动 | 显式回调地址;留空 = `http://<Host>/api/auth/oauth/<id>/callback` |
| `idField` / `nameField` / `emailField` | string | `id` / 模板 | userinfo 字段映射 |
| `emailDomains` | string[] | `[]` | 邮箱域名白名单;非空时邮箱域名必须命中,否则拒绝(`oauth_email_not_allowed`) |
| `role` | string | `user` | 登录成功角色 |
| `userInfoHeaders` | dict | `{}` | 取 userinfo 附加请求头 |
| `tokenParams` | dict | `{}` | token 请求附加参数 |
| `stateTtlMs` | number | `600000` | state 有效期(10 分钟) |

> 回调地址(OAuth 应用后台填写):`http(s)://<你的地址>/api/auth/oauth/<id>/callback`
> 例如:`http://127.0.0.1:3080/api/auth/oauth/github/callback`
> 未知 `builtin` 名或合并后缺必需字段会在启动时报配置错误。

---

## API 参考

### `GET /login`

返回内置登录页(含密码表单 + 可选 Solana 钱包区块)。状态码:`200`。

### `POST /api/auth/login`

用户名密码登录。支持 JSON 与 `application/x-www-form-urlencoded`。

```bash
curl -X POST http://127.0.0.1:3080/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"secret"}'
```

| 结果 | 状态码 | 说明 |
|------|--------|------|
| 成功(JSON 调用) | `200` | `{ ok, username, role }` + `Set-Cookie` |
| 成功(浏览器表单) | `302` | 重定向 `/` + `Set-Cookie` |
| 凭据错误(JSON) | `401` | `{ error: "invalid credentials" }` |
| 凭据错误(表单) | `401` | 登录页 + 错误提示 |
| 缺参 | `400` | `{ error: "username and password required" }` |
| 方法错误 | `405` | 非 GET/POST |

### `POST /api/auth/logout`

登出。服务端将当前 token 加入吊销黑名单并清除 Cookie。
成功:`200 { ok: true }`(JSON)或 `302 → /login`(浏览器)。

### `POST /api/auth/solana/challenge`

签发一次性 nonce(绑定公钥)。

```bash
curl -X POST http://127.0.0.1:3080/api/auth/solana/challenge \
  -H 'Content-Type: application/json' \
  -d '{"publicKey":"<32字节base58>"}'
```

| 结果 | 状态码 | 说明 |
|------|--------|------|
| 成功 | `200` | `{ nonce, message, expiresAt }` |
| 公钥非法 | `400` | `{ error: "invalid public key" }` |
| 不在白名单 | `403` | `{ error: "public key not allowed" }` |

### `POST /api/auth/solana/verify`

提交钱包签名换取会话。

```bash
curl -X POST http://127.0.0.1:3080/api/auth/solana/verify \
  -H 'Content-Type: application/json' \
  -d '{"publicKey":"<base58>","signature":"<base58 64B>","nonce":"<challenge 返回>"}'
```

| 结果 | 状态码 | 说明 |
|------|--------|------|
| 成功 | `200` | `{ ok, username: "solana:<pubkey>", role, publicKey }` + `Set-Cookie` |
| 不在白名单 | `403` | 拒绝 |
| nonce 无效/过期/重放 | `401` | `{ error: "invalid or expired challenge" }` |
| 签名验证失败 | `401` | `{ error: "signature verification failed" }` |
| 缺参 | `400` | `{ error: "publicKey, signature and nonce required" }` |

### `POST /api/auth/evm/challenge`

EVM 钱包登录第一步:签发一次性 nonce(绑定 0x 地址)。

```bash
curl -X POST http://127.0.0.1:3080/api/auth/evm/challenge \
  -H 'Content-Type: application/json' \
  -d '{"address":"0x4e984616e2dd9dffe7f2413efc7da35ef64c4117"}'
```

| 结果 | 状态码 | 说明 |
|------|--------|------|
| 成功 | `200` | `{ nonce, message: "DSH Login <nonce>", expiresAt }` |
| 地址非法 | `400` | `{ error: "invalid address" }` |
| 不在白名单 | `403` | `{ error: "address not allowed" }` |

### `POST /api/auth/evm/verify`

提交 `personal_sign` 签名换取会话(需要可选依赖 @noble)。

```bash
curl -X POST http://127.0.0.1:3080/api/auth/evm/verify \
  -H 'Content-Type: application/json' \
  -d '{"address":"0x...","signature":"0x<65字节r||s||v>","nonce":"<challenge 返回>"}'
```

| 结果 | 状态码 | 说明 |
|------|--------|------|
| 成功 | `200` | `{ ok, username: "evm:<address>", role, address }` + `Set-Cookie` |
| 不在白名单 | `403` | 拒绝 |
| nonce 无效/过期/重放 | `401` | `{ error: "invalid or expired challenge" }` |
| ecrecover 不匹配 | `401` | `{ error: "signature verification failed" }` |
| 缺参/地址非法 | `400` | `{ error: "address, signature and nonce required" }` |

### `GET /api/auth/oauth/<id>/start`

发起 OAuth 登录。签发一次性 state 后 **302** 到 provider 授权页
(`authorizeUrl?client_id&redirect_uri&state[&scope]`)。
未知 provider → `404`。

### `GET /api/auth/oauth/<id>/callback`

OAuth 回调(用户从 provider 授权页跳回)。浏览器直接访问即可,无需手动调用。

```bash
# 模拟(真实流程由浏览器跳转完成)
curl -i "http://127.0.0.1:3080/api/auth/oauth/github/callback?code=xxx&state=<start 返回的 state>"
```

| 结果 | 状态码 | 行为 |
|------|--------|------|
| 成功 | `302` | 重定向 `/` + `Set-Cookie`(会话已建立) |
| state 无效/过期/重放 | `302` | 重定向 `/login?error=oauth_bad_state`(防 CSRF) |
| token 交换失败 | `302` | `/login?error=oauth_token_failed` |
| userinfo 获取失败 | `302` | `/login?error=oauth_userinfo_failed` |
| userinfo 缺 id 字段 | `302` | `/login?error=oauth_no_id` |
| 未知 provider | `302` | `/login?error=oauth_unknown_provider` |

会话身份:`oauth:<providerId>:<id>`(如 `oauth:github:user-42`)。

---

## 前端集成

### 密码表单

内置登录页为自包含 HTML(无外部资源),POST 到 `/api/auth/login`,
成功即设置 Cookie 并跳转 `/`。

### Solana 钱包登录

登录页内联脚本(零外部依赖):

1. 检测钱包:`window.solana.isPhantom` → `window.phantom.solana`
2. 点击按钮 → `provider.connect()` 取公钥
3. 请求 challenge → `provider.signMessage(TextEncoder.encode(message), "utf8")`
4. 签名 base58 编码后 POST verify → 成功 `location.href = "/"`

支持 Phantom、Solflare 等注入 `window.solana` 的钱包;未安装钱包时
按钮下方提示"未检测到钱包"。

### OAuth 第三方登录

登录页按配置为每个 provider 渲染一个按钮(`<a href="/api/auth/oauth/<id>/start">`),
点击即进入标准 OAuth 授权码流程,无需前端脚本。

### EVM 钱包登录

登录页「⬡ 使用 EVM 钱包登录」按钮,内联脚本检测 `window.ethereum`:
`eth_requestAccounts` 取地址 → challenge → `personal_sign` → 提交 verify。

---

## 安全设计

| 项 | 实现 |
|----|------|
| 密码存储 | `node:crypto` scrypt(随机盐),支持明文仅限开发 |
| 令牌完整性 | HMAC-SHA256 签名,`timingSafeEqual` 比较 |
| Cookie | `HttpOnly; SameSite=Strict; Path=/; Max-Age` |
| 登出吊销 | 服务端内存黑名单(`revoked` Set) |
| nonce 防重放 | 一次性消费,`consume` 即删;绑定公钥/地址;TTL 5 分钟 + 惰性 sweep |
| OAuth state 防 CSRF | 一次性 + 绑定 provider + 10 分钟 TTL;伪造/重用 state → `oauth_bad_state` |
| client_secret 保密 | 仅服务端持有,token 交换在服务端完成,绝不下发浏览器 |
| userinfo 服务端获取 | Bearer token 不出服务端 |
| EVM 验签 | EIP-191 `personal_sign` 消息哈希 + secp256k1 ecrecover 恢复地址,与声明地址比对 |
| 登录审计 | 每次登录成功 `info` / 失败 `warn` 打点(`[dsh-auth] login ok/failed: <method> <身份> [原因]`) |
| 白名单强制 | 钱包登录 `allowlist`/`allowlist` 必填,challenge 与 verify 双重 403 |
| 消息重建 | 服务端 `DSH Login <nonce>` 重建,不信任客户端回传 message |
| 定时器依赖 | `inject: ["webServer", "timer"]` 显式声明(Cordis 强制) |
| `/api` 边界 | 保持 DSH 自带 loopback/trustedHosts 篱笆,插件不越权 |

---

## 用户与密钥管理

### 生成 scrypt 哈希

```bash
# 方式一:内置脚本
node scripts/manage-users.js hash 你的密码

# 方式二:一行命令
node --input-type=module -e "
import { scrypt, randomBytes } from 'node:crypto';
const salt = randomBytes(16).toString('hex');
scrypt(process.argv[1], salt, 32, (e,k)=>{ if(e) throw e; console.log('scrypt\$'+salt+'\$'+k.toString('hex')); });
" 你的密码
```

### 添加/修改/删除用户

直接编辑 `cordis.patch.yml` 的 `users` 表后重启即可。支持多用户、多角色
(`admin` / `user`,角色目前仅作标识,可由其他插件消费 `req.authUser`)。

### Solana 公钥获取

钱包连接后从 `publicKey.toString()` 得到 base58 地址,填入 `allowlist`。
验证合法性:`node -e "import('./dsh-auth-plugin.js').then(m => console.log(m.base58Decode('...').length === 32))"`

---

## 测试

```bash
# 单元测试(核心零依赖,直接可跑;EVM 组需要 @noble,缺失时自动跳过)
node --test test/auth.test.js
# → 34 项:scrypt、HMAC 令牌、base58、ed25519 验签(含 SIWS v0)、
#   nonce/state 管理、EVM ecrecover、OAuth 模板合并

# 端到端测试(需要 DSH 的 node_modules 环境解析 schematery)
node --test test/e2e.test.js
# → 26 项:密码登录、Cookie、登出吊销、Solana/EVM 钱包、白名单 403、
#   fake OAuth provider 授权码流程(含内置模板)、邮箱域名白名单、防重放
```

---

## 已知问题与故障排除

### 已修复:`cannot get property "timer" without inject`(v1.1.0 → v1.2.0)

启用 Solana 后启动失败:

```text
Error: dsh: plugin tree failed to load:
failed to apply loader entry auth (./dsh-auth-plugin.js):
cannot get property "timer" without inject
```

**根因**:`ctx.setInterval()` 由 Cordis 的 `timer` 服务提供,新版 Cordis
要求显式声明所有经 `ctx` 使用的服务;原 `inject = ["webServer"]` 遗漏了
`timer`。

**修复**:`const inject = ["webServer", "timer"]`。

完整排查记录见 [`docs/repair-timer-inject.md`](docs/repair-timer-inject.md)。

### 常见问题

| 现象 | 处理 |
|------|------|
| 启动报 `failed to import loader entry auth` | 确认 `./dsh-auth-plugin.js` 相对 profile 目录路径正确、文件存在 |
| 报找不到 `@deepseek-ai/schemastery` | 确认 `~/.dsh/profiles/node_modules` 存在(dsh 首次启动自动生成) |
| 登录后又被弹回登录页 | `secret` 未固定 → 重启后旧会话失效,属正常;或系统时间异常 |
| `cannot get property "timer"` | 确认 `inject` 含 `"timer"`(v1.2.0 起已内置) |
| Solana 按钮不显示 | 确认 `solana.enabled: true` 且 allowlist 非空 |
| 钱包登录报 403 | 钱包公钥不在 `allowlist` |
| 钱包签名后 401 | nonce 过期(5 分钟)或已被使用,重新点击登录 |
| OAuth 按钮不显示 | 确认 `oauth.providers` 至少配置了一个 provider |
| OAuth 回调 `oauth_bad_state` | 刷新/重放旧回调,或 state 过期(10 分钟);重新从登录页发起 |
| OAuth 回调 `oauth_token_failed` | 检查 clientId/clientSecret 与回调地址是否与 provider 后台一致 |
| OAuth 回调 `oauth_no_id` | userinfo 响应里没有 `idField` 指定字段,按 provider 响应调整映射 |
| EVM 按钮不显示 | 确认 `evm.enabled: true` 且 allowlist 非空,且已安装 @noble(缺失时启动日志有警告) |
| EVM 登录报 403 | 钱包地址不在 `allowlist` |
| EVM 签名后 401 | nonce 过期(5 分钟)或已被使用,重新点击登录;或签名格式非 0x + 65 字节 |
| 启动时的 `MODULE_TYPELESS` 警告 | 无碍;在 profile 的 `package.json` 加 `"type": "module"` 消除 |

---

## 升级与回滚

### 升级

```bash
cp <新版本>/lib/index.js ~/.dsh/profiles/web/dsh-auth-plugin.js
# 重启 dsh web
```

### 回滚

```bash
cp ~/.dsh/profiles/web/cordis.patch.yml.bak ~/.dsh/profiles/web/cordis.patch.yml
rm -f ~/.dsh/profiles/web/dsh-auth-plugin.js
```

(安装时自动备份了 `cordis.patch.yml.bak`;再次改动前有 `.bak2`。)

---

## 生产部署建议

1. **固定 secret**:`secret: !!js process.env.DSH_AUTH_SECRET`(环境变量注入)
2. **HTTPS 反向代理**:Nginx/Caddy 终结 TLS,转发 `127.0.0.1:3080`
   (注意 WebSocket upgrade 头:`Upgrade` / `Connection: upgrade`)
3. **强密码**:全部用户使用 scrypt 哈希,避免明文
4. **钱包白名单**:Solana 登录务必维护 `allowlist`,公钥即身份
5. **OS 边界**:防火墙限制端口暴露;`/api` 仍由 DSH 自带篱笆保护
6. **日志审计**:如需登录审计日志,可 fork 插件在 `loginHandler` /
   `verifyHandler` 成功后打点

---

## 许可证

MIT

Install

dsh plugin --profile web add github:v1xingyue/dsh-auth-plugin

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source