fix(login): 登录标识与门户对齐 + 失败可诊断

- 端点:走 /login(已存在账号的密码校验),不走门户的 /login-or-register(会触发登录即注册)
- 新增 -u/--identifier:标识可用 用户名/已验证邮箱/已验证手机号(服务端 LoginIdentifierResolver 解析)
- 新增 --email-code:邮箱验证码登录(账号未设密码时的可用路径)
- 登录前匿名预检 /login/check:区分「标识未注册」与「密码不匹配」,避免误导性提示
- 失败提示按服务端 msg 映射可执行下一步(未验证/停用/登录方式未开放)
- 修复 readLine:TTY 下一次粘贴多行会丢弃换行之后的剩余字符(第二次读取挂起)
This commit is contained in:
ringo
2026-09-10 11:08:19 +08:00
parent 9577d2e132
commit d73e907ec4
2 changed files with 224 additions and 67 deletions
+18 -1
View File
@@ -37,7 +37,24 @@ codex plugin marketplace add https://git.deta.city/deta/deta-agent-plugins.git
codex plugin add deta@deta codex plugin add deta@deta
``` ```
登录助手也支持自建/预发环境:`node bin/deta-login.mjs --base-url https://<host>`。 登录助手选项:
```bash
node bin/deta-login.mjs -u <用户名|已验证邮箱|已验证手机号> # 免交互输入标识
node bin/deta-login.mjs --email-code <邮箱> # 邮箱验证码登录(未设密码的账号用)
node bin/deta-login.mjs --base-url https://<host> # 自建/预发环境
```
**标识(identifier)填门户登录页上用的那一个**:用户名、已验证邮箱、已验证手机号,服务端都会解析到同一账号。
助手先匿名调用 `/login/check` 预检标识是否已注册,再走 `/login` 做密码校验(不走门户的「登录即注册」端点,
不会误建账号)。失败时按服务端提示给出下一步:
| 服务端提示 | 含义与处理 |
|------------|------------|
| `该标识在 <host> 未注册` | 标识不是门户上用的那一个;邮箱验证码注册的账号标识填邮箱 |
| `用户不存在/密码错误` | 服务端不区分「账号不存在」与「密码不匹配」;已确认标识存在,即密码不符,或改用 `--email-code` |
| `该邮箱/手机号尚未验证…` | 先用验证码方式登录一次门户完成验证 |
| `该登录方式未开放` | 用 `GET <base>/prod-api/login/methods` 查当前启用项 |
## 安装(dsh) ## 安装(dsh)
+209 -69
View File
@@ -4,13 +4,19 @@
* *
* 用 deta.city 账号换取 API 令牌,供 MCP 远程端点鉴权使用: * 用 deta.city 账号换取 API 令牌,供 MCP 远程端点鉴权使用:
* *
* node bin/deta-login.mjs [--base-url https://deta.city] * node bin/deta-login.mjs [--identifier <用户名|邮箱|手机号>] [--base-url https://deta.city]
* node bin/deta-login.mjs --email-code <邮箱> # 邮箱验证码登录(无密码账号用)
*
* 标识(identifier)就是你在门户登录页填的那一个:
* 用户名 / 已验证邮箱 / 已验证手机号,服务端都会解析到同一账号。
* 注意 deta.city 门户的密码登录端点与 RuoYi 内置的 /login 不是同一个,
* 本助手统一走 /login(已存在账号的密码校验),不会触发「登录即注册」。
* *
* 流程: * 流程:
* 1. 交互式读取用户名/密码(密码不回显); * 1. 读入标识;先匿名调用 /login/check 预检该标识是否已注册;
* 2. POST <base>/prod-api/login { username, password }; * 2. 读入密码(不回显);POST <base>/prod-api/login { username: 标识, password };
* 3. 成功 → 写 ~/.deta/credentials.json 与 ~/.deta/env(均 0600); * 3. 成功 → 写 ~/.deta/credentials.json 与 ~/.deta/env(均 0600);
* 4. 失败 → 打印服务端 msg,退出码非 0。 * 4. 失败 → 打印服务端 msg 与针对性提示,退出码非 0。
* *
* 凭据纪律:token 只写在本次 stdout 与上述两个 0600 文件里, * 凭据纪律:token 只写在本次 stdout 与上述两个 0600 文件里,
* 不写日志、不写 stderr、不写其它位置。 * 不写日志、不写 stderr、不写其它位置。
@@ -28,26 +34,52 @@ function fail(message) {
process.exit(1); process.exit(1);
} }
/** `--base-url <url>` / `--base-url=<url>`;缺省 https://deta.city,末尾斜杠去掉 */ const USAGE = [
function parseBaseUrl(argv) { '用法:node bin/deta-login.mjs [选项]',
let baseUrl = process.env.DETA_BASE_URL || DEFAULT_BASE_URL; ' -u, --identifier <值> 登录标识(用户名/已验证邮箱/已验证手机号);省略则交互式输入',
' --email-code <邮箱> 用邮箱验证码登录(门户只用验证码登录、未设密码时用)',
' --base-url <url> 服务地址,缺省 https://deta.city',
' -h, --help 显示本帮助',
].join('\n');
/** 解析命令行;缺省 baseUrl 取 DETA_BASE_URL 或 https://deta.city,末尾斜杠去掉 */
function parseArgs(argv) {
const options = {
baseUrl: process.env.DETA_BASE_URL || DEFAULT_BASE_URL,
identifier: '',
emailCode: '',
};
for (let i = 0; i < argv.length; i++) { for (let i = 0; i < argv.length; i++) {
const arg = argv[i]; const arg = argv[i];
if (arg === '--base-url') { const takeValue = (name) => {
const next = argv[i + 1]; const next = argv[i + 1];
if (!next) fail('--base-url 缺少取值'); if (!next) fail(`${name} 缺少取值`);
baseUrl = next;
i++; i++;
return next;
};
if (arg === '-u' || arg === '--identifier') {
options.identifier = takeValue(arg);
} else if (arg.startsWith('--identifier=')) {
options.identifier = arg.slice('--identifier='.length);
} else if (arg === '--email-code') {
options.emailCode = takeValue(arg);
} else if (arg.startsWith('--email-code=')) {
options.emailCode = arg.slice('--email-code='.length);
} else if (arg === '--base-url') {
options.baseUrl = takeValue(arg);
} else if (arg.startsWith('--base-url=')) { } else if (arg.startsWith('--base-url=')) {
baseUrl = arg.slice('--base-url='.length); options.baseUrl = arg.slice('--base-url='.length);
} else if (arg === '-h' || arg === '--help') { } else if (arg === '-h' || arg === '--help') {
console.log('用法:node bin/deta-login.mjs [--base-url https://deta.city]'); console.log(USAGE);
process.exit(0); process.exit(0);
} else { } else {
fail(`未知参数:${arg}`); fail(`未知参数:${arg}\n${USAGE}`);
} }
} }
return baseUrl.trim().replace(/\/+$/, ''); options.baseUrl = options.baseUrl.trim().replace(/\/+$/, '');
options.identifier = options.identifier.trim();
options.emailCode = options.emailCode.trim();
return options;
} }
/** /**
@@ -56,30 +88,32 @@ function parseBaseUrl(argv) {
*/ */
let pendingInput = ''; let pendingInput = '';
/** 从暂存缓冲里切出一行(跨 chunk 保留剩余数据),无完整行返回 null */ /**
function takeLine() { * 读一行输入。hidden=true 时不回显(密码);非 TTY(管道/CI)时同样按行读取。
const index = pendingInput.search(/[\r\n]/); *
if (index < 0) return null; * 跨读取缓冲:`pendingInput` 保存上一次读取切剩的数据。TTY 下在 raw mode 逐字符处理,
const line = pendingInput.slice(0, index); * 遇到换行即切行并把该 chunk 换行之后的字符存回 `pendingInput`——因此
const skip = pendingInput[index] === '\r' && pendingInput[index + 1] === '\n' ? 2 : 1; * 一次粘贴「标识 + 密码」两行不会丢第二行。
pendingInput = pendingInput.slice(index + skip); */
return line;
}
function readLine({ hidden }) { function readLine({ hidden }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// 上一次读取可能已经缓冲了后续行(管道一次性写入多行)
const buffered = takeLine();
if (buffered !== null) {
resolve(buffered);
return;
}
const stdin = process.stdin; const stdin = process.stdin;
const isTTY = Boolean(stdin.isTTY); const isTTY = Boolean(stdin.isTTY);
let buffer = ''; let buffer = pendingInput;
pendingInput = '';
let settled = false; let settled = false;
/** 从 buffer 切出一整行(含行尾判定),并把剩余数据留到 pendingInput;无完整行返回 null */
const splitLine = () => {
const index = buffer.search(/[\r\n]/);
if (index < 0) return null;
const line = buffer.slice(0, index);
const skip = buffer[index] === '\r' && buffer[index + 1] === '\n' ? 2 : 1;
pendingInput = buffer.slice(index + skip);
buffer = '';
return line;
};
const cleanup = () => { const cleanup = () => {
stdin.removeListener('data', onData); stdin.removeListener('data', onData);
stdin.removeListener('end', onEnd); stdin.removeListener('end', onEnd);
@@ -99,13 +133,22 @@ function readLine({ hidden }) {
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8'); const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
if (!isTTY) { if (!isTTY) {
// 非交互:不回显,按行切分,剩余数据留给下一次读取 // 非交互:不回显,按行切分,剩余数据留给下一次读取
pendingInput += text; buffer += text;
const line = takeLine(); const line = splitLine();
if (line !== null) finish(line); if (line !== null) finish(line);
return; return;
} }
for (const ch of [...text]) { const chars = [...text];
if (ch === '\n' || ch === '\r') return finish(buffer); for (let i = 0; i < chars.length; i++) {
const ch = chars[i];
if (ch === '\n' || ch === '\r') {
// 本 chunk 换行符之后的字符属于后续读取(一次粘贴多行):存入 pendingInput 后立即返回
let next = i + 1;
if (ch === '\r' && chars[next] === '\n') next++; // CRLF 视作一个行尾
const rest = chars.slice(next).join('');
if (rest) pendingInput = rest + pendingInput;
return finish(buffer);
}
if (ch === '\u0003') { if (ch === '\u0003') {
// Ctrl-C // Ctrl-C
process.stdout.write('\n'); process.stdout.write('\n');
@@ -121,16 +164,7 @@ function readLine({ hidden }) {
if (!hidden) process.stdout.write(ch); if (!hidden) process.stdout.write(ch);
} }
}; };
// 输入结束:非 TTY 时把残留(末行无换行)也交出去 const onEnd = () => finish(buffer);
const onEnd = () => {
if (!isTTY && pendingInput) {
const rest = pendingInput;
pendingInput = '';
finish(rest);
return;
}
finish(buffer);
};
const onError = (err) => { const onError = (err) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
@@ -138,6 +172,14 @@ function readLine({ hidden }) {
reject(err); reject(err);
}; };
// 缓冲里已有完整行(上一次读取的遗留)→ 立即返回,不等待输入
const buffered = splitLine();
if (buffered !== null) {
if (isTTY) process.stdout.write('\n');
resolve(buffered);
return;
}
if (isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(true); if (isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(true);
stdin.resume(); stdin.resume();
stdin.on('data', onData); stdin.on('data', onData);
@@ -157,45 +199,97 @@ async function writePrivateFile(filePath, content) {
await chmod(filePath, 0o600).catch(() => undefined); await chmod(filePath, 0o600).catch(() => undefined);
} }
async function main() { /** 登录标识类型判定(与服务端 LoginIdentifierResolver 的规则一致) */
const baseUrl = parseBaseUrl(process.argv.slice(2)); const PHONE_REGEX = /^1[3-9]\d{9}$/;
const EMAIL_REGEX = /^[\w.+-]+@[\w-]+(\.[\w-]+)+$/;
process.stdout.write(`DETA 登录(${baseUrl})\n`); function identifierField(identifier) {
process.stdout.write('用户名:'); if (EMAIL_REGEX.test(identifier)) return 'email';
const username = (await readLine({ hidden: false })).trim(); if (PHONE_REGEX.test(identifier)) return 'phone';
if (!username) fail('用户名不能为空'); return 'username';
}
process.stdout.write('密码:'); /** POST JSON,返回 { response, payload }(响应体非 JSON 时 payload 为 null) */
const password = await readLine({ hidden: true }); async function postJson(baseUrl, path, body) {
if (!password) fail('密码不能为空');
const loginUrl = `${baseUrl}/prod-api/login`;
let response; let response;
try { try {
response = await fetch(loginUrl, { response = await fetch(`${baseUrl}${path}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }), body: JSON.stringify(body),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}); });
} catch (err) { } catch (err) {
fail(`无法连接 ${loginUrl}(${err instanceof Error ? err.message : String(err)})`); fail(`无法连接 ${baseUrl}${path}(${err instanceof Error ? err.message : String(err)})`);
} }
let payload = null; let payload = null;
try { try {
payload = await response.json(); payload = await response.json();
} catch { } catch {
payload = null; payload = null;
} }
return { response, payload };
const token = typeof payload?.token === 'string' ? payload.token : '';
if (!response.ok || !token || (typeof payload?.code === 'number' && payload.code !== 200)) {
const msg = payload?.msg || `登录失败(HTTP ${response.status})`;
fail(msg);
} }
const loginUsername = typeof payload.username === 'string' && payload.username ? payload.username : username; function serverMessage(payload, response) {
return (payload && typeof payload.msg === 'string' && payload.msg) || `请求失败(HTTP ${response.status})`;
}
/**
* 匿名查询标识是否已注册;接口异常返回 null(不据此阻断登录)。
* 只填标识所属类型的那一个字段:服务端按 phone → email → username 取第一个非空字段。
*/
async function accountRegistered(baseUrl, identifier) {
const { response, payload } = await postJson(baseUrl, '/prod-api/login/check', {
[identifierField(identifier)]: identifier,
});
if (!response.ok || !payload || payload.code !== 200) return null;
return payload.registered === true;
}
/** 服务端提示 → 可执行的下一步 */
function loginHint(message, identifier) {
if (message.includes('用户不存在/密码错误')) {
return [
`该提示不区分「账号不存在」与「密码不匹配」(已确认 ${identifier} 是已注册标识,故为密码不符或标识与账号不对应)。`,
'请确认:',
' 1) 标识与门户登录页填写的一致(用户名 / 已验证邮箱 / 已验证手机号);',
' 2) 密码与门户一致(区分大小写,注意首尾空格);',
' 3) 若门户只用邮箱验证码登录、从未设过密码,改用 --email-code <邮箱>。',
].join('\n ');
}
if (message.includes('尚未验证')) {
return '该邮箱/手机号在账号里尚未标记为已验证:请先用验证码方式登录一次门户完成验证,再重试。';
}
if (message.includes('停用')) return '账号已被停用,请联系管理员。';
if (message.includes('未开放')) {
return '该登录方式未开放:改用已启用的方式(GET /prod-api/login/methods 可查当前启用项)。';
}
return '';
}
/** 邮箱验证码登录:发码 → 读入验证码 → 校验 */
async function loginByEmailCode(baseUrl, email) {
const send = await postJson(baseUrl, '/prod-api/login/email/send-code', { email });
if (!send.response.ok || send.payload?.code !== 200) {
fail(`发送验证码失败:${serverMessage(send.payload, send.response)}`);
}
process.stdout.write('邮箱验证码:');
const code = (await readLine({ hidden: false })).trim();
if (!code) fail('验证码不能为空');
const { response, payload } = await postJson(baseUrl, '/prod-api/login/email', { email, code });
const token = typeof payload?.token === 'string' ? payload.token : '';
if (!response.ok || !token || (typeof payload?.code === 'number' && payload.code !== 200)) {
fail(`${serverMessage(payload, response)}\n ${loginHint(serverMessage(payload, response), email)}`.trimEnd());
}
return payload;
}
/** 写 ~/.deta/credentials.json 与 ~/.deta/env(均 0600);token 只在 stdout 出现一次 */
async function writeCredentials(baseUrl, identifier, payload) {
const token = payload.token;
const loginUsername =
typeof payload.username === 'string' && payload.username ? payload.username : identifier;
const dir = join(homedir(), '.deta'); const dir = join(homedir(), '.deta');
await mkdir(dir, { recursive: true, mode: 0o700 }); await mkdir(dir, { recursive: true, mode: 0o700 });
await chmod(dir, 0o700).catch(() => undefined); await chmod(dir, 0o700).catch(() => undefined);
@@ -216,11 +310,57 @@ async function main() {
`export DETA_TOKEN=${shellSingleQuote(token)}\n`, `export DETA_TOKEN=${shellSingleQuote(token)}\n`,
); );
// token 只在这里(stdout)出现一次;stderr 与其它文件均不含凭据
console.log(`DETA_TOKEN=${token}`); console.log(`DETA_TOKEN=${token}`);
console.log(`✅ 登录成功:${loginUsername} @ ${baseUrl}`); console.log(`✅ 登录成功:${loginUsername} @ ${baseUrl}`);
console.log(`凭据已写入 ${credentialsPath}(0600),环境变量已写入 ${envPath}(0600)`); console.log(`凭据已写入 ${credentialsPath}(0600),环境变量已写入 ${envPath}(0600)`);
console.log(`使用:source ~/.deta/env 后再启动 harness(DETA_TOKEN 随环境传给 https://deta.city/mcp-api/mcp)`); console.log(
`使用:source ~/.deta/env 后再启动 harness(DETA_TOKEN 随环境传给 https://deta.city/mcp-api/mcp)`,
);
}
async function main() {
const { baseUrl, identifier: presetIdentifier, emailCode } = parseArgs(process.argv.slice(2));
process.stdout.write(`DETA 登录(${baseUrl})\n`);
if (emailCode) {
process.stdout.write(`邮箱验证码登录:${emailCode}\n`);
return writeCredentials(baseUrl, emailCode, await loginByEmailCode(baseUrl, emailCode));
}
let identifier = presetIdentifier;
if (identifier) {
process.stdout.write(`登录标识:${identifier}\n`);
} else {
process.stdout.write('登录标识(用户名/已验证邮箱/已验证手机号):');
identifier = (await readLine({ hidden: false })).trim();
}
if (!identifier) fail('登录标识不能为空');
// 匿名预检:区分「标识未注册」与「密码不匹配」,避免密码错时给出误导性提示
if ((await accountRegistered(baseUrl, identifier)) === false) {
fail(
`该标识在 ${baseUrl} 未注册:${identifier}\n` +
' 请填门户登录页上实际使用的标识(用户名 / 已验证邮箱 / 已验证手机号)。\n' +
' 邮箱验证码注册的账号,标识请填邮箱。',
);
}
process.stdout.write('密码:');
const password = await readLine({ hidden: true });
if (!password) fail('密码不能为空');
const { response, payload } = await postJson(baseUrl, '/prod-api/login', {
username: identifier,
password,
});
const token = typeof payload?.token === 'string' ? payload.token : '';
if (!response.ok || !token || (typeof payload?.code === 'number' && payload.code !== 200)) {
const message = serverMessage(payload, response);
const hint = loginHint(message, identifier);
fail(hint ? `${message}\n ${hint}` : message);
}
return writeCredentials(baseUrl, identifier, payload);
} }
main().catch((err) => { main().catch((err) => {