Files
gongxue-base/apps/api/src/features/auth/service.ts
2026-06-21 21:54:43 +08:00

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 || {}),
],
);
}