fix: simplify account password flow

This commit is contained in:
inman committed 2026-09-02 11:15:30 +08:00
1 parent 203bfb3246
commit df65e9f517
9 files changed
+118 -131

No files matched your search

+3 -3
View File
@@ -89,7 +89,7 @@ Auto 一旦发生 AI fallback,任务会永久绑定原 AI 会话。每次解
- `POST /api/tasks/:taskId/reparse`
- `PUT /api/parser-decisions/:decisionId/review`
迁移 `013_business_parser_modes` 增加内部固定范围的路由设置、任务快照和加密的 `parse_decisions`;迁移 `014_task_input_attachments` 增加名单输入附件元数据、加密 canonical TSV 与 `awaiting_attachment` 索引;迁移 `015_account_roles_and_task_audit` 增加账号角色、强制改密、输入/附件操作者、任务归档和账号级幂等;迁移 `016_team_lead_operations_dashboard` 增加组长角色与人工指令看板索引;迁移 `017_user_business_route_authorizations` 增加逐账号业务白名单、授权人和乐观并发 revision。原文、完整程序/AI 候选、名单 canonical 中间文本和人工说明使用字段加密保存;统计、全局审计和运行日志不复制明文业务输入。
迁移 `013_business_parser_modes` 增加内部固定范围的路由设置、任务快照和加密的 `parse_decisions`;迁移 `014_task_input_attachments` 增加名单输入附件元数据、加密 canonical TSV 与 `awaiting_attachment` 索引;迁移 `015_account_roles_and_task_audit` 增加账号角色、密码更新时间、输入/附件操作者、任务归档和账号级幂等(历史 `must_change_password` 列仅保留兼容,当前流程不启用首次强制改密);迁移 `016_team_lead_operations_dashboard` 增加组长角色与人工指令看板索引;迁移 `017_user_business_route_authorizations` 增加逐账号业务白名单、授权人和乐观并发 revision。原文、完整程序/AI 候选、名单 canonical 中间文本和人工说明使用字段加密保存;统计、全局审计和运行日志不复制明文业务输入。
## AgentBus Bot 接入
@@ -134,7 +134,7 @@ npm install
npm run check
npm run build
npm run db:migrate
ADMIN_USERNAME=admin ADMIN_PASSWORD='replace-with-12-plus-chars' npm run admin -- bootstrap
ADMIN_USERNAME=admin ADMIN_PASSWORD='replace-with-password' npm run admin -- bootstrap
npm run data:retention
npm run dev
```
@@ -148,7 +148,7 @@ npm run dev
1. 复制 `.env.production.example` 为部署机受保护的 `.env.production`,填入 `DEPLOYMENT_REVISION`、PostgreSQL URL、`DATABASE_SCHEMA`、字段加密密钥、外部解析 Key 和 OSS 凭据,并确认 `AGENTBUS_LOG_PAYLOADS=false`。生产数据库可使用现有 PostgreSQL 实例中的新 Schema;迁移程序会创建 Schema,不会触碰其他 Schema 的测试表。
2. 在正式数据库上线前执行并验证备份:`infra/backup-postgres.sh`。
3. 使用 `docker compose --env-file .env.production up -d --build` 启动;Compose 会先执行数据库迁移,再启动控制平面。Compose 中的本地 PostgreSQL 仅用于 `--profile local`,生产 `DATABASE_URL` 指向受保护的远程数据库。
4. 首次启动后在容器内通过 `docker compose exec -e ADMIN_USERNAME=admin -e ADMIN_PASSWORD='replace-with-12-plus-chars' control-plane node .build/control-plane/src/admin-cli.js bootstrap` 创建管理员;不要把密码写入镜像或 Git。
4. 首次启动后在容器内通过 `docker compose exec -e ADMIN_USERNAME=admin -e ADMIN_PASSWORD='replace-with-password' control-plane node .build/control-plane/src/admin-cli.js bootstrap` 创建管理员;不要把密码写入镜像或 Git。
5. 配置 `infra/Caddyfile` 中的正式域名和 HTTPS,然后在 Chrome 插件中加载对应生产业务页面。
6. 启动后运行 `sh infra/diagnose-server.sh --since 10m`,确认容器、readiness、`service.listening`、AgentBus session 与当前 `deployment_revision`。
+19 -30
View File
@@ -20,7 +20,6 @@ export interface AuthUser {
organizationId: string;
username: string;
role: AuthRole;
mustChangePassword: boolean;
}
export interface PublicAccount {
@@ -28,7 +27,6 @@ export interface PublicAccount {
username: string;
role: AuthRole;
is_active: boolean;
must_change_password: boolean;
authorized_business_route_ids: BusinessRouteId[];
business_authorization_revision: number;
last_login_at: string | null;
@@ -74,8 +72,8 @@ function validateUsername(value: string): string {
function validatePassword(value: string): string {
const password = String(value || '');
if (password.length < 12 || password.length > 512) {
throw new AuthError('password_invalid', '密码必须为 12—512 个字符。', 400);
if (!password) {
throw new AuthError('password_invalid', '密码不能为空。', 400);
}
return password;
}
@@ -111,8 +109,7 @@ function mapUser(row: Record<string, unknown>): AuthUser {
id: String(row.id),
organizationId: String(row.organization_id),
username: String(row.username),
role: normalizeRole(row.role),
mustChangePassword: row.must_change_password === true || String(row.must_change_password) === 'true'
role: normalizeRole(row.role)
};
}
@@ -126,7 +123,6 @@ function mapAccount(row: Record<string, unknown>): PublicAccount {
username: String(row.username),
role,
is_active: row.is_active === true || String(row.is_active) === 'true',
must_change_password: row.must_change_password === true || String(row.must_change_password) === 'true',
authorized_business_route_ids: role === 'admin' ? [...ALL_BUSINESS_ROUTE_IDS] : storedRouteIds,
business_authorization_revision: Math.max(0, Number(row.business_authorization_revision || 0)),
last_login_at: isoOrNull(row.last_login_at),
@@ -141,7 +137,7 @@ async function loadPublicAccount(
userId: string
): Promise<PublicAccount | null> {
const result = await client.query(
`SELECT u.id, u.username, u.role, u.is_active, u.must_change_password,
`SELECT u.id, u.username, u.role, u.is_active,
u.business_authorization_revision,
u.last_login_at, u.created_at, u.updated_at,
COALESCE(ARRAY(
@@ -217,13 +213,13 @@ export class AuthService {
must_change_password = false, password_changed_at = now(),
failed_login_count = 0, locked_until = NULL, updated_at = now()
WHERE id = $2
RETURNING id, organization_id, username, role, must_change_password`,
RETURNING id, organization_id, username, role`,
[passwordHash, existing.rows[0].id]
)
: await client.query(
`INSERT INTO users (organization_id, username, password_hash, role, must_change_password)
VALUES ($1, $2, $3, 'admin', false)
RETURNING id, organization_id, username, role, must_change_password`,
`INSERT INTO users (organization_id, username, password_hash, role)
VALUES ($1, $2, $3, 'admin')
RETURNING id, organization_id, username, role`,
[organization.id, normalized, passwordHash]
);
const user = mapUser(result.rows[0]);
@@ -242,7 +238,7 @@ export class AuthService {
const pool = getPool(this.config);
const lookup = await pool.query(
`SELECT id, organization_id, username, password_hash, role, is_active,
must_change_password, failed_login_count, locked_until
failed_login_count, locked_until
FROM users
WHERE organization_id = (SELECT id FROM organizations WHERE slug = $1)
AND username = $2`,
@@ -334,7 +330,7 @@ export class AuthService {
async listAccounts(actor: AuthUser): Promise<PublicAccount[]> {
this.requireAdmin(actor);
const result = await getPool(this.config).query(
`SELECT u.id, u.username, u.role, u.is_active, u.must_change_password,
`SELECT u.id, u.username, u.role, u.is_active,
u.business_authorization_revision,
u.last_login_at, u.created_at, u.updated_at,
COALESCE(ARRAY(
@@ -357,7 +353,6 @@ export class AuthService {
username: string;
password: string;
role: AuthRole;
mustChangePassword?: boolean;
businessRouteIds?: readonly string[];
},
requestId: string
@@ -366,7 +361,6 @@ export class AuthService {
const username = validateUsername(input.username);
const passwordHash = await argon2.hash(validatePassword(input.password), { type: argon2.argon2id });
const role = normalizeRole(input.role);
const mustChangePassword = input.mustChangePassword !== false;
const businessRouteIds = role === 'admin' ? [] : normalizeBusinessRouteIds(input.businessRouteIds);
return withTransaction(this.config, async (client) => {
await client.query(
@@ -380,10 +374,10 @@ export class AuthService {
if (existing.rowCount) throw new AuthError('account_exists', '该账号已存在。', 409);
const created = await client.query(
`INSERT INTO users
(organization_id, username, password_hash, role, must_change_password, password_changed_at)
VALUES ($1, $2, $3, $4, $5, now())
(organization_id, username, password_hash, role, password_changed_at)
VALUES ($1, $2, $3, $4, now())
RETURNING id`,
[actor.organizationId, username, passwordHash, role, mustChangePassword]
[actor.organizationId, username, passwordHash, role]
);
const accountId = String(created.rows[0].id);
if (businessRouteIds.length) {
@@ -399,7 +393,6 @@ export class AuthService {
if (!account) throw new AuthError('account_not_found', '账号创建后未能读取。', 500);
await this.accountAudit(client, actor, 'account.created', account.id, requestId, {
role,
must_change_password: mustChangePassword,
authorized_business_route_ids: account.authorized_business_route_ids
});
return account;
@@ -418,7 +411,7 @@ export class AuthService {
}
return withTransaction(this.config, async (client) => {
const target = await client.query(
`SELECT id, username, role, is_active, must_change_password,
`SELECT id, username, role, is_active,
last_login_at, created_at, updated_at
FROM users
WHERE organization_id = $1 AND id = $2
@@ -549,7 +542,6 @@ export class AuthService {
actor: AuthUser,
targetUserId: string,
password: string,
mustChangePassword: boolean,
requestId: string
): Promise<void> {
this.requireAdmin(actor);
@@ -562,18 +554,17 @@ export class AuthService {
if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404);
await client.query(
`UPDATE users
SET password_hash = $1, must_change_password = $2,
SET password_hash = $1, must_change_password = false,
password_changed_at = now(), failed_login_count = 0,
locked_until = NULL, updated_at = now()
WHERE id = $3`,
[passwordHash, mustChangePassword, targetUserId]
WHERE id = $2`,
[passwordHash, targetUserId]
);
const revoked = await client.query(
'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL',
[targetUserId]
);
await this.accountAudit(client, actor, 'account.password_reset', targetUserId, requestId, {
must_change_password: mustChangePassword,
sessions_revoked: revoked.rowCount || 0
});
});
@@ -657,8 +648,7 @@ export class AuthService {
async getActiveSession(token: string | undefined): Promise<ActiveSession | null> {
if (!token) return null;
const result = await getPool(this.config).query(
`SELECT s.id AS session_id, s.csrf_token_hash, u.id, u.organization_id, u.username, u.role,
u.must_change_password
`SELECT s.id AS session_id, s.csrf_token_hash, u.id, u.organization_id, u.username, u.role
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1
@@ -681,8 +671,7 @@ export class AuthService {
id: row.id,
organization_id: row.organization_id,
username: row.username,
role: row.role,
must_change_password: row.must_change_password
role: row.role
})
};
}
+14 -33
View File
@@ -51,19 +51,18 @@ export function aiServiceConnected(databaseIsReady: boolean, probe: Record<strin
const loginSchema = z.object({
username: z.string().min(1).max(160),
password: z.string().min(1).max(512)
password: z.string().min(1)
});
const changePasswordSchema = z.object({
current_password: z.string().min(1).max(512),
new_password: z.string().min(12).max(512)
current_password: z.string().min(1),
new_password: z.string().min(1)
});
const accountCreateSchema = z.object({
username: z.string().min(1).max(160),
password: z.string().min(12).max(512),
password: z.string().min(1),
role: z.enum(['admin', 'team_lead', 'user']).default('user'),
must_change_password: z.boolean().default(true),
business_route_ids: z.array(
z.string().trim().refine((routeId) => Boolean(businessRouteById(routeId)), '业务类型不存在。')
).max(BUSINESS_ROUTES.length).default([])
@@ -78,8 +77,7 @@ const accountUpdateSchema = z.object({
});
const accountPasswordResetSchema = z.object({
password: z.string().min(12).max(512),
must_change_password: z.boolean().default(true)
password: z.string().min(1)
});
const accountBusinessAuthorizationsSchema = z.object({
@@ -363,8 +361,7 @@ function publicUser(session: ActiveSession) {
return {
id: session.user.id,
username: session.user.username,
role: session.user.role,
must_change_password: session.user.mustChangePassword
role: session.user.role
};
}
@@ -516,14 +513,6 @@ export async function buildServer({
return session;
};
const getReadySession = async (request: FastifyRequest): Promise<ActiveSession> => {
const session = await getSession(request);
if (session.user.mustChangePassword) {
throw new AuthError('password_change_required', '首次登录或密码重置后必须先修改密码。', 403);
}
return session;
};
const requireAdmin = (session: ActiveSession): ActiveSession => {
if (session.user.role !== 'admin') throw new AuthError('admin_required', '需要管理员权限。', 403);
return session;
@@ -554,16 +543,10 @@ export async function buildServer({
return session;
};
const requireMutationSession = async (request: FastifyRequest): Promise<ActiveSession> => {
const session = await requireAuthenticatedMutationSession(request);
if (session.user.mustChangePassword) {
throw new AuthError('password_change_required', '首次登录或密码重置后必须先修改密码。', 403);
}
return session;
};
const requireMutationSession = requireAuthenticatedMutationSession;
const requireAdminSession = async (request: FastifyRequest): Promise<ActiveSession> => (
requireAdmin(await getReadySession(request))
requireAdmin(await getSession(request))
);
const requireAdminMutationSession = async (request: FastifyRequest): Promise<ActiveSession> => (
@@ -571,7 +554,7 @@ export async function buildServer({
);
const requireLeadershipSession = async (request: FastifyRequest): Promise<ActiveSession> => (
requireLeadership(await getReadySession(request))
requireLeadership(await getSession(request))
);
async function persistParseOutcome(
@@ -980,7 +963,6 @@ export async function buildServer({
username: body.username,
password: body.password,
role: body.role,
mustChangePassword: body.must_change_password,
businessRouteIds: body.business_route_ids
}, requestId(request));
return { ok: true, account };
@@ -1022,7 +1004,6 @@ export async function buildServer({
session.user,
userId,
body.password,
body.must_change_password,
requestId(request)
);
return { ok: true, password_reset: true, sessions_revoked: true };
@@ -1172,7 +1153,7 @@ export async function buildServer({
});
app.get('/api/tasks', async (request) => {
const session = await getReadySession(request);
const session = await getSession(request);
const query = listTasksQuerySchema.parse(request.query || {});
const page = await tasks.listTasksPage(session.user.organizationId, {
status: query.status || undefined,
@@ -1224,19 +1205,19 @@ export async function buildServer({
});
app.get('/api/tasks/:taskId', async (request) => {
const session = await getReadySession(request);
const session = await getSession(request);
const params = request.params as { taskId: string };
return { ok: true, task: await tasks.getTask(session.user.organizationId, params.taskId, contextFor(session, request)) };
});
app.get('/api/tasks/:taskId/input-history', async (request) => {
const session = await getReadySession(request);
const session = await getSession(request);
const params = request.params as { taskId: string };
return { ok: true, ...(await tasks.getTaskInputHistory(contextFor(session, request), params.taskId)) };
});
app.get('/api/tasks/:taskId/artifacts/:artifactId', async (request, reply) => {
const session = await getReadySession(request);
const session = await getSession(request);
const params = request.params as { taskId: string; artifactId: string };
const artifactId = z.string().uuid().safeParse(params.artifactId);
if (!artifactId.success) throw new TaskError('artifact_not_found', '附件不存在或无权访问。', 404);
@@ -1403,7 +1384,7 @@ export async function buildServer({
});
app.get('/api/events', async (request, reply) => {
const session = await getReadySession(request);
const session = await getSession(request);
const query = (request.query || {}) as Record<string, unknown>;
const querySince = Number(query.since || 0);
const reconnectSince = Number(request.headers['last-event-id'] || 0);
@@ -67,14 +67,16 @@ test('account lifecycle is administrator-gated and protects passwords, sessions,
assert.match(auth, /last_admin_protected/);
assert.match(auth, /UPDATE sessions SET revoked_at = now\(\)/);
assert.match(auth, /must_change_password = false/);
assert.match(auth, /function validatePassword[\s\S]+if \(!password\)/);
assert.doesNotMatch(auth, /password\.length < 12|12—512/);
assert.match(auth, /account\.password_reset/);
assert.match(auth, /account\.password_changed/);
assert.match(server, /app\.get\('\/api\/accounts'[\s\S]+requireAdminSession\(request\)/);
assert.match(server, /app\.post\('\/api\/accounts'[\s\S]+requireAdminMutationSession\(request\)/);
assert.match(server, /app\.get\('\/api\/audit'[\s\S]+requireAdminSession\(request\)/);
assert.match(server, /password_change_required/);
assert.doesNotMatch(server, /password_change_required|must_change_password|\.min\(12\)/);
const publicUser = server.slice(server.indexOf('function publicUser'), server.indexOf('async function loadExternalParser'));
assert.match(publicUser, /must_change_password/);
assert.doesNotMatch(publicUser, /must_change_password/);
assert.doesNotMatch(publicUser, /organization/);
});
@@ -213,6 +215,7 @@ test('operator UI exposes role-aware accounts, executive drill-through, original
assert.match(index, /href="\/operations-dashboard"/);
assert.match(index, /id="passwordChangeForm"/);
assert.match(index, /id="accountForm"/);
assert.doesNotMatch(index, /accountMustChangePassword|首次登录必须修改密码|minlength="12"|12—512/);
assert.match(index, /id="accountAuthorizationPanel"/);
assert.match(index, /id="accountAuthorizationTypes"/);
assert.match(index, /id="accountAuthorizationSave"/);
@@ -231,6 +234,7 @@ test('operator UI exposes role-aware accounts, executive drill-through, original
assert.doesNotMatch(index, /OPERATIONS OVERVIEW|BUSINESS TRACE|指令操作历史/);
assert.match(index, /id="historyArchiveInput"/);
assert.match(app, /authUser\?\.role === 'admin'/);
assert.doesNotMatch(app, /passwordChangeForced|must_change_password/);
assert.match(app, /crypto\.randomUUID/);
assert.match(app, /\/api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/input-history/);
assert.match(app, /创建人与原始输入审计/);