forked from wangziqi/gongxue-base
282 lines
7.9 KiB
TypeScript
282 lines
7.9 KiB
TypeScript
import crypto from 'node:crypto';
|
|
import type pg from 'pg';
|
|
import { config } from '../../core/config.js';
|
|
import { HttpError, getHeader, type RequestContext } from '../../core/http.js';
|
|
import { queryOne } from '../../core/db.js';
|
|
|
|
export interface PlatformUserSummary {
|
|
id: string;
|
|
username: string | null;
|
|
phone: string | null;
|
|
name: string | null;
|
|
avatarUrl: string | null;
|
|
primaryRole: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface LoginSessionSummary {
|
|
token: string;
|
|
expiresAt: string;
|
|
}
|
|
|
|
export function clientIpFrom(ctx: RequestContext) {
|
|
const forwarded = getHeader(ctx.req, 'x-forwarded-for');
|
|
return (forwarded.split(',')[0] || getHeader(ctx.req, 'x-real-ip') || ctx.req.socket.remoteAddress || '').trim();
|
|
}
|
|
|
|
export function userAgentFrom(ctx: RequestContext) {
|
|
return getHeader(ctx.req, 'user-agent');
|
|
}
|
|
|
|
export function normalizeChinaPhone(phone: string) {
|
|
return phone.replace(/\s+/g, '').replace(/^\+?86/, '');
|
|
}
|
|
|
|
export function assertChinaPhone(phone: string) {
|
|
const normalized = normalizeChinaPhone(phone);
|
|
if (!/^1[3-9]\d{9}$/.test(normalized)) {
|
|
throw new HttpError(400, 'Invalid China mainland phone number', 'INVALID_PHONE');
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
export function normalizePurpose(value: string) {
|
|
if (['login', 'bind_phone', 'reset_password'].includes(value)) return value;
|
|
throw new HttpError(400, 'Unsupported SMS purpose', 'UNSUPPORTED_SMS_PURPOSE');
|
|
}
|
|
|
|
export function generateSmsCode() {
|
|
return crypto.randomInt(100000, 1000000).toString();
|
|
}
|
|
|
|
export function hashSmsCode(tenantId: string, phone: string, purpose: string, code: string) {
|
|
return crypto
|
|
.createHmac('sha256', config.authCodePepper)
|
|
.update([tenantId, phone, purpose, code].join(':'))
|
|
.digest('hex');
|
|
}
|
|
|
|
export function createSessionToken() {
|
|
return `tk_${crypto.randomBytes(32).toString('base64url')}`;
|
|
}
|
|
|
|
export function hashSessionToken(token: string) {
|
|
return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex');
|
|
}
|
|
|
|
export async function findUserBySessionToken(token: string) {
|
|
const tokenHash = hashSessionToken(token);
|
|
return queryOne<
|
|
PlatformUserSummary & {
|
|
tenantId: string;
|
|
sessionId: string;
|
|
sessionExpiresAt: string;
|
|
}
|
|
>(
|
|
`
|
|
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
|
u.primary_role as "primaryRole", u.created_at as "createdAt",
|
|
s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt"
|
|
from app_private.auth_sessions s
|
|
join public.platform_users u on u.id = s.user_id
|
|
where s.token_hash = $1
|
|
and s.revoked_at is null
|
|
and s.expires_at > now()
|
|
limit 1
|
|
`,
|
|
[tokenHash],
|
|
);
|
|
}
|
|
|
|
export function bearerTokenFrom(ctx: RequestContext) {
|
|
const authorization = getHeader(ctx.req, 'authorization');
|
|
const match = authorization.match(/^Bearer\s+(.+)$/i);
|
|
return match?.[1]?.trim() || '';
|
|
}
|
|
|
|
export async function createLoginSession(
|
|
client: pg.PoolClient,
|
|
input: {
|
|
tenantId: string;
|
|
userId: string;
|
|
provider: string;
|
|
ipAddress?: string;
|
|
userAgent?: string;
|
|
metadata?: Record<string, unknown>;
|
|
},
|
|
): Promise<LoginSessionSummary> {
|
|
const token = createSessionToken();
|
|
const tokenHash = hashSessionToken(token);
|
|
const expiresAt = new Date(Date.now() + config.authSessionTtlSeconds * 1000).toISOString();
|
|
|
|
await client.query(
|
|
`
|
|
insert into app_private.auth_sessions (
|
|
tenant_id, user_id, token_hash, provider, expires_at, ip_address, user_agent, metadata
|
|
)
|
|
values ($1, $2, $3, $4, $5::timestamptz, $6, $7, $8::jsonb)
|
|
`,
|
|
[
|
|
input.tenantId,
|
|
input.userId,
|
|
tokenHash,
|
|
input.provider,
|
|
expiresAt,
|
|
input.ipAddress || null,
|
|
input.userAgent || null,
|
|
JSON.stringify(input.metadata || {}),
|
|
],
|
|
);
|
|
|
|
return { token, expiresAt };
|
|
}
|
|
|
|
export async function upsertPhoneUser(
|
|
client: pg.PoolClient,
|
|
input: {
|
|
tenantId: string;
|
|
phone: string;
|
|
},
|
|
) {
|
|
const existing = await client.query<PlatformUserSummary>(
|
|
`
|
|
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
|
u.primary_role as "primaryRole", u.created_at as "createdAt"
|
|
from public.user_identities i
|
|
join public.platform_users u on u.id = i.user_id
|
|
where i.provider = 'phone'
|
|
and i.provider_subject = $1
|
|
limit 1
|
|
`,
|
|
[input.phone],
|
|
);
|
|
|
|
if (existing.rows[0]) {
|
|
const user = existing.rows[0];
|
|
await ensureStudentTenantRecords(client, input.tenantId, user.id);
|
|
return { user, isNewUser: false };
|
|
}
|
|
|
|
const legacyPhoneUser = await client.query<PlatformUserSummary>(
|
|
`
|
|
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
|
u.primary_role as "primaryRole", u.created_at as "createdAt"
|
|
from public.platform_users u
|
|
where u.phone = $1
|
|
order by u.created_at asc
|
|
limit 1
|
|
`,
|
|
[input.phone],
|
|
);
|
|
|
|
if (legacyPhoneUser.rows[0]) {
|
|
const user = legacyPhoneUser.rows[0];
|
|
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 user_id = excluded.user_id,
|
|
phone = excluded.phone,
|
|
updated_at = now()
|
|
`,
|
|
[user.id, input.phone],
|
|
);
|
|
await ensureStudentTenantRecords(client, input.tenantId, user.id);
|
|
return { user, isNewUser: false };
|
|
}
|
|
|
|
const userResult = await client.query<PlatformUserSummary>(
|
|
`
|
|
insert into public.platform_users (username, phone, primary_role, raw_profile)
|
|
values ($1, $2, 'student', $3::jsonb)
|
|
returning id, username, phone, name, avatar_url as "avatarUrl",
|
|
primary_role as "primaryRole", created_at as "createdAt"
|
|
`,
|
|
[`u_${input.phone.slice(-4)}_${Date.now().toString(36)}`, input.phone, JSON.stringify({ source: 'sms_login' })],
|
|
);
|
|
|
|
const user = userResult.rows[0];
|
|
|
|
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 user_id = excluded.user_id,
|
|
phone = excluded.phone,
|
|
updated_at = now()
|
|
`,
|
|
[user.id, input.phone],
|
|
);
|
|
|
|
await ensureStudentTenantRecords(client, input.tenantId, user.id);
|
|
return { user, isNewUser: true };
|
|
}
|
|
|
|
export async function ensureStudentTenantRecords(client: pg.PoolClient, tenantId: string, userId: string) {
|
|
await client.query(
|
|
`
|
|
insert into public.tenant_memberships (tenant_id, user_id, role, status)
|
|
values ($1, $2, 'student', 'active')
|
|
on conflict (tenant_id, user_id, role)
|
|
do update set status = 'active', updated_at = now()
|
|
`,
|
|
[tenantId, userId],
|
|
);
|
|
|
|
await client.query(
|
|
`
|
|
insert into public.student_profiles (tenant_id, user_id, stats, progress)
|
|
values ($1, $2, $3::jsonb, '{}'::jsonb)
|
|
on conflict (tenant_id, user_id) do nothing
|
|
`,
|
|
[
|
|
tenantId,
|
|
userId,
|
|
JSON.stringify({
|
|
totalAnswered: 0,
|
|
correctCount: 0,
|
|
wrongCount: 0,
|
|
studyDays: 1,
|
|
}),
|
|
],
|
|
);
|
|
}
|
|
|
|
export async function writeLoginEvent(
|
|
client: pg.PoolClient,
|
|
input: {
|
|
tenantId: string;
|
|
userId?: string | null;
|
|
provider: string;
|
|
identifier?: string;
|
|
result: 'sent' | 'success' | 'failed' | 'blocked';
|
|
failureCode?: string | null;
|
|
ipAddress?: string;
|
|
userAgent?: string;
|
|
metadata?: Record<string, unknown>;
|
|
},
|
|
) {
|
|
await client.query(
|
|
`
|
|
insert into public.auth_login_events (
|
|
tenant_id, user_id, provider, identifier, result, failure_code,
|
|
ip_address, user_agent, metadata
|
|
)
|
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
|
|
`,
|
|
[
|
|
input.tenantId,
|
|
input.userId || null,
|
|
input.provider,
|
|
input.identifier || null,
|
|
input.result,
|
|
input.failureCode || null,
|
|
input.ipAddress || null,
|
|
input.userAgent || null,
|
|
JSON.stringify(input.metadata || {}),
|
|
],
|
|
);
|
|
}
|