forked from wangziqi/gongxue-base
feat: scaffold supabase multi-tenant backend
This commit is contained in:
281
apps/api/src/features/auth/service.ts
Normal file
281
apps/api/src/features/auth/service.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
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 || {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user