feat: add authenticated phone binding

This commit is contained in:
Codex
2026-06-29 09:53:44 +08:00
parent 6f0083bd13
commit 64d58d895e
12 changed files with 441 additions and 105 deletions

View File

@@ -1,5 +1,6 @@
import type { RouteDefinition } from '../../core/router.js';
import {
bindPhoneRoute,
logoutRoute,
meRoute,
oauthProviderPlaceholderRoute,
@@ -13,6 +14,7 @@ export const authRoutes: RouteDefinition[] = [
['POST', '/api/auth/sms/verify', verifySmsCodeRoute],
['GET', '/api/auth/me', meRoute],
['POST', '/api/auth/logout', logoutRoute],
['POST', '/api/auth/phone/bind', bindPhoneRoute],
['POST', '/api/auth/oauth/wechat', oauthProviderPlaceholderRoute],
['POST', '/api/auth/oauth/wechat-miniapp', wechatMiniappLoginRoute],
['POST', '/api/auth/oauth/qq', oauthProviderPlaceholderRoute],

View File

@@ -23,6 +23,7 @@ import {
upsertPhoneUser,
userAgentFrom,
writeLoginEvent,
type PlatformUserSummary,
} from './service.js';
interface SmsCodeRow {
@@ -54,6 +55,15 @@ type SmsVerifyResult =
session?: unknown;
};
type PhoneBindResult =
| Extract<SmsVerifyResult, { ok: false }>
| {
ok: true;
user: PlatformUserSummary;
phoneChanged: boolean;
revokedOtherSessions: number;
};
function hashEquals(left: string, right: string) {
const leftBuffer = Buffer.from(left, 'hex');
const rightBuffer = Buffer.from(right, 'hex');
@@ -64,6 +74,114 @@ function jsonObject(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
async function consumeSmsCode(
client: import('pg').PoolClient,
input: {
tenantId: string;
phone: string;
purpose: string;
code: string;
ipAddress: string;
userAgent: string;
},
): Promise<{ ok: true } | Extract<SmsVerifyResult, { ok: false }>> {
const expectedHash = hashSmsCode(input.tenantId, input.phone, input.purpose, input.code);
const codeResult = await client.query<SmsCodeRow>(
`
select id, code_hash as "codeHash", attempts, expires_at as "expiresAt", status
from public.sms_verification_codes
where tenant_id = $1
and phone = $2
and purpose = $3
and consumed_at is null
and status in ('pending', 'sent')
order by created_at desc
limit 1
for update
`,
[input.tenantId, input.phone, input.purpose],
);
const smsCode = codeResult.rows[0];
if (!smsCode) {
await writeLoginEvent(client, {
tenantId: input.tenantId,
provider: 'sms',
identifier: input.phone,
result: 'failed',
failureCode: 'SMS_CODE_NOT_FOUND',
ipAddress: input.ipAddress,
userAgent: input.userAgent,
metadata: { purpose: input.purpose },
});
return { ok: false, statusCode: 400, message: 'SMS code not found or already used', code: 'SMS_CODE_NOT_FOUND' };
}
if (new Date(smsCode.expiresAt).getTime() <= Date.now()) {
await client.query(
`
update public.sms_verification_codes
set status = 'expired'
where tenant_id = $1 and id = $2
`,
[input.tenantId, smsCode.id],
);
await writeLoginEvent(client, {
tenantId: input.tenantId,
provider: 'sms',
identifier: input.phone,
result: 'failed',
failureCode: 'SMS_CODE_EXPIRED',
ipAddress: input.ipAddress,
userAgent: input.userAgent,
metadata: { purpose: input.purpose },
});
return { ok: false, statusCode: 400, message: 'SMS code expired', code: 'SMS_CODE_EXPIRED' };
}
const matched = hashEquals(smsCode.codeHash, expectedHash);
if (!matched) {
const nextAttempts = smsCode.attempts + 1;
const blocked = nextAttempts >= 5;
await client.query(
`
update public.sms_verification_codes
set attempts = attempts + 1,
status = case when $3::boolean then 'blocked' else status end
where tenant_id = $1 and id = $2
`,
[input.tenantId, smsCode.id, blocked],
);
await writeLoginEvent(client, {
tenantId: input.tenantId,
provider: 'sms',
identifier: input.phone,
result: blocked ? 'blocked' : 'failed',
failureCode: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
ipAddress: input.ipAddress,
userAgent: input.userAgent,
metadata: { purpose: input.purpose, attempts: nextAttempts },
});
return {
ok: false,
statusCode: blocked ? 429 : 400,
message: blocked ? 'SMS code attempts exceeded' : 'Invalid SMS code',
code: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
};
}
await client.query(
`
update public.sms_verification_codes
set status = 'verified', consumed_at = now()
where tenant_id = $1 and id = $2
`,
[input.tenantId, smsCode.id],
);
return { ok: true };
}
function normalizeSmsProviderName(value: string) {
const normalized = value.toLowerCase().replace(/_/g, '-');
if (normalized === 'aliyun' || normalized === 'aliyun-sms') return 'aliyun';
@@ -210,101 +328,10 @@ export async function verifySmsCodeRoute(ctx: RequestContext) {
const purpose = normalizePurpose(optionalString(body, 'purpose') || 'login');
const ipAddress = clientIpFrom(ctx);
const userAgent = userAgentFrom(ctx);
const expectedHash = hashSmsCode(tenantId, phone, purpose, code);
const result = await transaction<SmsVerifyResult>(async client => {
const codeResult = await client.query<SmsCodeRow>(
`
select id, code_hash as "codeHash", attempts, expires_at as "expiresAt", status
from public.sms_verification_codes
where tenant_id = $1
and phone = $2
and purpose = $3
and consumed_at is null
and status in ('pending', 'sent')
order by created_at desc
limit 1
for update
`,
[tenantId, phone, purpose],
);
const smsCode = codeResult.rows[0];
if (!smsCode) {
await writeLoginEvent(client, {
tenantId,
provider: 'sms',
identifier: phone,
result: 'failed',
failureCode: 'SMS_CODE_NOT_FOUND',
ipAddress,
userAgent,
metadata: { purpose },
});
return { ok: false, statusCode: 400, message: 'SMS code not found or already used', code: 'SMS_CODE_NOT_FOUND' };
}
if (new Date(smsCode.expiresAt).getTime() <= Date.now()) {
await client.query(
`
update public.sms_verification_codes
set status = 'expired'
where tenant_id = $1 and id = $2
`,
[tenantId, smsCode.id],
);
await writeLoginEvent(client, {
tenantId,
provider: 'sms',
identifier: phone,
result: 'failed',
failureCode: 'SMS_CODE_EXPIRED',
ipAddress,
userAgent,
metadata: { purpose },
});
return { ok: false, statusCode: 400, message: 'SMS code expired', code: 'SMS_CODE_EXPIRED' };
}
const matched = hashEquals(smsCode.codeHash, expectedHash);
if (!matched) {
const nextAttempts = smsCode.attempts + 1;
const blocked = nextAttempts >= 5;
await client.query(
`
update public.sms_verification_codes
set attempts = attempts + 1,
status = case when $3::boolean then 'blocked' else status end
where tenant_id = $1 and id = $2
`,
[tenantId, smsCode.id, blocked],
);
await writeLoginEvent(client, {
tenantId,
provider: 'sms',
identifier: phone,
result: blocked ? 'blocked' : 'failed',
failureCode: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
ipAddress,
userAgent,
metadata: { purpose, attempts: nextAttempts },
});
return {
ok: false,
statusCode: blocked ? 429 : 400,
message: blocked ? 'SMS code attempts exceeded' : 'Invalid SMS code',
code: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
};
}
await client.query(
`
update public.sms_verification_codes
set status = 'verified', consumed_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, smsCode.id],
);
const consumed = await consumeSmsCode(client, { tenantId, phone, purpose, code, ipAddress, userAgent });
if (!consumed.ok) return consumed;
if (purpose !== 'login') {
await writeLoginEvent(client, {
@@ -398,6 +425,214 @@ export async function logoutRoute(ctx: RequestContext) {
return { ok: true };
}
export async function bindPhoneRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
await hydrateRequestAuth(ctx);
const session = currentSessionFromContext(ctx);
if (!session) {
throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID');
}
if (session.tenantId !== tenantId) {
throw new HttpError(403, 'Session does not belong to this tenant', 'AUTH_TENANT_MISMATCH');
}
const body = await readJsonBody(ctx);
const phone = assertChinaPhone(requiredString(body, 'phone'));
const code = requiredString(body, 'code');
const purpose = normalizePurpose(optionalString(body, 'purpose') || 'bind_phone');
if (purpose !== 'bind_phone') {
throw new HttpError(400, 'Phone binding requires a bind_phone SMS code', 'PHONE_BIND_PURPOSE_REQUIRED');
}
const ipAddress = clientIpFrom(ctx);
const userAgent = userAgentFrom(ctx);
const result = await transaction<PhoneBindResult>(async client => {
const consumed = await consumeSmsCode(client, { tenantId, phone, purpose, code, ipAddress, userAgent });
if (!consumed.ok) return consumed;
const userResult = await client.query<PlatformUserSummary>(
`
select id, username, phone, name, avatar_url as "avatarUrl",
primary_role as "primaryRole", created_at as "createdAt"
from public.platform_users
where id = $1
limit 1
for update
`,
[session.id],
);
const currentUser = userResult.rows[0];
if (!currentUser) {
return { ok: false, statusCode: 401, message: 'Authenticated user was not found', code: 'AUTH_USER_NOT_FOUND' };
}
const identityConflict = await client.query<{ userId: string }>(
`
select user_id as "userId"
from public.user_identities
where provider = 'phone'
and provider_subject = $1
limit 1
for update
`,
[phone],
);
if (identityConflict.rows[0] && identityConflict.rows[0].userId !== session.id) {
await writeLoginEvent(client, {
tenantId,
userId: session.id,
provider: 'sms',
identifier: phone,
result: 'failed',
failureCode: 'PHONE_ALREADY_BOUND',
ipAddress,
userAgent,
metadata: { purpose, bindAttempt: true },
});
return { ok: false, statusCode: 409, message: 'Phone number is already bound to another account', code: 'PHONE_ALREADY_BOUND' };
}
const profileConflict = await client.query<{ id: string }>(
`
select id
from public.platform_users
where phone = $1 and id <> $2
limit 1
`,
[phone, session.id],
);
if (profileConflict.rows[0]) {
await writeLoginEvent(client, {
tenantId,
userId: session.id,
provider: 'sms',
identifier: phone,
result: 'failed',
failureCode: 'PHONE_ALREADY_BOUND',
ipAddress,
userAgent,
metadata: { purpose, bindAttempt: true, conflictSource: 'platform_users' },
});
return { ok: false, statusCode: 409, message: 'Phone number is already bound to another account', code: 'PHONE_ALREADY_BOUND' };
}
await client.query(
`
delete from public.user_identities
where user_id = $1
and provider = 'phone'
and provider_subject <> $2
`,
[session.id, phone],
);
const identityResult = await client.query(
`
insert into public.user_identities (user_id, provider, provider_subject, phone)
values ($1, 'phone', $2, $2)
on conflict (provider, provider_subject)
do update set phone = excluded.phone,
updated_at = now()
where public.user_identities.user_id = excluded.user_id
returning id
`,
[session.id, phone],
);
if (!identityResult.rows[0]) {
return { ok: false, statusCode: 409, message: 'Phone number is already bound to another account', code: 'PHONE_ALREADY_BOUND' };
}
const updatedUser = await client.query<PlatformUserSummary>(
`
update public.platform_users
set phone = $2,
raw_profile = coalesce(raw_profile, '{}'::jsonb) || $3::jsonb,
updated_at = now()
where id = $1
returning id, username, phone, name, avatar_url as "avatarUrl",
primary_role as "primaryRole", created_at as "createdAt"
`,
[
session.id,
phone,
JSON.stringify({
phoneBound: true,
phoneBoundAt: new Date().toISOString(),
phoneBindTenantId: tenantId,
}),
],
);
await client.query(
`
update public.user_identities
set phone = $2,
updated_at = now()
where user_id = $1
and provider <> 'phone'
`,
[session.id, phone],
);
const revoked = await client.query(
`
update app_private.auth_sessions
set revoked_at = now(),
updated_at = now(),
metadata = metadata || $4::jsonb
where tenant_id = $1
and user_id = $2
and revoked_at is null
and ($3::uuid is null or id <> $3::uuid)
returning id
`,
[
tenantId,
session.id,
session.authSource === 'app_session' ? session.sessionId : null,
JSON.stringify({
revokedBy: 'phone_bind',
revokedAt: new Date().toISOString(),
}),
],
);
await writeLoginEvent(client, {
tenantId,
userId: session.id,
provider: 'sms',
identifier: phone,
result: 'success',
ipAddress,
userAgent,
metadata: {
purpose,
phoneChanged: currentUser.phone !== phone,
previousPhoneMasked: currentUser.phone ? `${currentUser.phone.slice(0, 3)}****${currentUser.phone.slice(-4)}` : null,
revokedOtherSessions: revoked.rowCount || 0,
},
});
return {
ok: true,
user: updatedUser.rows[0],
phoneChanged: currentUser.phone !== phone,
revokedOtherSessions: revoked.rowCount || 0,
};
});
if (!result.ok) {
throw new HttpError(result.statusCode, result.message, result.code);
}
return {
ok: true,
user: result.user,
phoneChanged: result.phoneChanged,
revokedOtherSessions: result.revokedOtherSessions,
};
}
function normalizeOAuthProvider(value: string) {
const normalized = value.toLowerCase().replace(/_/g, '-');
if (normalized === 'wechat' || normalized === 'wechat-web') return 'wechat-web';