fix: simplify account password flow
This commit is contained in:
1 parent
203bfb3246
commit
df65e9f517
9 files changed
+118
-131
No files matched your search
+19
-30
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user