forked from wangziqi/gongxue-base
feat: add authenticated phone binding
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user