first commit

This commit is contained in:
luoyangwei
2026-06-06 00:49:07 +08:00
commit 2b860d201e
134 changed files with 22773 additions and 0 deletions
+440
View File
@@ -0,0 +1,440 @@
import {
createHash,
createHmac,
randomBytes,
scryptSync,
timingSafeEqual,
} from "node:crypto";
import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role";
import type { AppUser, PublicAppUser } from "@/types/app/user";
type JwtPayload = {
sub: string;
email: string | null;
iat: number;
exp: number;
typ: "app_user";
};
export type AuthenticatedAppUser = {
id: string;
email: string | null;
displayName: string | null;
avatarUrl: string | null;
authProvider: string;
};
export type AppUserLoginContext = {
ipAddress: string | null;
userAgent: string | null;
acceptLanguage: string | null;
referer: string | null;
origin: string | null;
deviceInfo: Record<string, string | null>;
};
type AppUserLoginLogStatus = "success" | "failed" | "blocked";
type SupabaseServiceRoleClient = ReturnType<
typeof createSupabaseServiceRoleClient
>;
const userSelect =
"id,email,display_name,avatar_url,auth_provider,status,created_at,updated_at";
function getJwtSecret() {
const secret = process.env.APP_JWT_SECRET ?? process.env.AUTH_SECRET;
if (!secret) {
throw new Error("APP_JWT_SECRET or AUTH_SECRET is not configured.");
}
return secret;
}
function base64UrlEncode(value: Buffer | string) {
return Buffer.from(value)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
function base64UrlDecode(value: string) {
const padded = value.padEnd(
value.length + ((4 - (value.length % 4)) % 4),
"=",
);
return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}
function sign(input: string) {
return base64UrlEncode(
createHmac("sha256", getJwtSecret()).update(input).digest(),
);
}
function normalizeEmail(email: string) {
return email.trim().toLowerCase();
}
function firstHeaderValue(value: string | null) {
return value?.split(",")[0]?.trim() || null;
}
function getHeader(headers: Headers, name: string) {
return headers.get(name)?.trim() || null;
}
function getRequestIpAddress(headers: Headers) {
return (
getHeader(headers, "cf-connecting-ip") ??
getHeader(headers, "x-real-ip") ??
firstHeaderValue(getHeader(headers, "x-forwarded-for")) ??
getHeader(headers, "x-client-ip")
);
}
function getTokenHash(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export function getAppUserLoginContextFromRequest(
request: Request,
): AppUserLoginContext {
const headers = request.headers;
const forwardedFor = getHeader(headers, "x-forwarded-for");
const userAgent = getHeader(headers, "user-agent");
const acceptLanguage = getHeader(headers, "accept-language");
const referer = getHeader(headers, "referer");
const origin = getHeader(headers, "origin");
return {
ipAddress: getRequestIpAddress(headers),
userAgent,
acceptLanguage,
referer,
origin,
deviceInfo: {
method: request.method,
url: request.url,
host: getHeader(headers, "host"),
userAgent,
acceptLanguage,
referer,
origin,
forwardedFor,
realIp: getHeader(headers, "x-real-ip"),
clientIp: getHeader(headers, "x-client-ip"),
cfConnectingIp: getHeader(headers, "cf-connecting-ip"),
cfIpcountry: getHeader(headers, "cf-ipcountry"),
},
};
}
function mapPublicUser(user: AppUser): PublicAppUser {
return {
id: user.id,
email: user.email,
displayName: user.display_name,
avatarUrl: user.avatar_url,
authProvider: user.auth_provider,
};
}
async function recordAppUserLoginLog({
context,
email,
failureReason,
jwtToken,
status,
supabase,
user,
}: {
context?: AppUserLoginContext;
email: string;
failureReason?: string | null;
jwtToken?: string | null;
status: AppUserLoginLogStatus;
supabase: SupabaseServiceRoleClient;
user?: Pick<AppUser, "auth_provider" | "id"> | null;
}) {
try {
const jwtExpiresAt = jwtToken
? new Date(verifyAppUserToken(jwtToken).exp * 1000).toISOString()
: null;
const { error } = await supabase.from("wg_app_user_login_logs").insert({
user_id: user?.id ?? null,
email,
auth_provider: user?.auth_provider ?? "email",
login_status: status,
failure_reason: failureReason ?? null,
ip_address: context?.ipAddress ?? null,
user_agent: context?.userAgent ?? null,
accept_language: context?.acceptLanguage ?? null,
referer: context?.referer ?? null,
origin: context?.origin ?? null,
device_info: context?.deviceInfo ?? {},
jwt_token: jwtToken ?? null,
jwt_token_hash: jwtToken ? getTokenHash(jwtToken) : null,
jwt_expires_at: jwtExpiresAt,
});
if (error) {
console.error("Failed to record app user login log:", error);
}
} catch (error) {
console.error("Failed to record app user login log:", error);
}
}
export function hashPassword(password: string) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(password, salt, 64).toString("hex");
return `scrypt:v1:${salt}:${hash}`;
}
export function verifyPassword(password: string, storedHash: string | null) {
if (!storedHash) {
return false;
}
const [algorithm, version, salt, hash] = storedHash.split(":");
if (algorithm !== "scrypt" || version !== "v1" || !salt || !hash) {
return false;
}
const expected = Buffer.from(hash, "hex");
const actual = scryptSync(password, salt, expected.length);
return (
actual.length === expected.length && timingSafeEqual(actual, expected)
);
}
export function createAppUserToken(user: Pick<AppUser, "email" | "id">) {
const now = Math.floor(Date.now() / 1000);
const payload: JwtPayload = {
sub: user.id,
email: user.email,
iat: now,
exp: now + 60 * 60 * 24 * 30,
typ: "app_user",
};
const header = {
alg: "HS256",
typ: "JWT",
};
const input = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(
JSON.stringify(payload),
)}`;
return `${input}.${sign(input)}`;
}
export function verifyAppUserToken(token: string): JwtPayload {
const [header, payload, signature] = token.split(".");
if (!header || !payload || !signature) {
throw new Error("Invalid token.");
}
const input = `${header}.${payload}`;
const expected = sign(input);
const expectedBuffer = Buffer.from(expected);
const signatureBuffer = Buffer.from(signature);
if (
expectedBuffer.length !== signatureBuffer.length ||
!timingSafeEqual(expectedBuffer, signatureBuffer)
) {
throw new Error("Invalid token signature.");
}
const decoded = JSON.parse(
base64UrlDecode(payload).toString("utf8"),
) as JwtPayload;
if (decoded.typ !== "app_user" || !decoded.sub) {
throw new Error("Invalid token payload.");
}
if (decoded.exp < Math.floor(Date.now() / 1000)) {
throw new Error("Token expired.");
}
return decoded;
}
export async function registerAppUser({
displayName,
email,
password,
}: {
displayName?: string | null;
email: string;
password: string;
}) {
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail.includes("@")) {
throw new Error("A valid email is required.");
}
if (password.length < 8) {
throw new Error("Password must be at least 8 characters.");
}
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_app_users")
.insert({
email: normalizedEmail,
password_hash: hashPassword(password),
display_name: displayName?.trim() || null,
auth_provider: "email",
status: "active",
})
.select(userSelect)
.single<AppUser>();
if (error) {
if (error.code === "23505") {
throw new Error("Email is already registered.");
}
throw error;
}
await supabase.from("wg_user_settings").upsert(
{
user_id: data.id,
preferences: {},
download_preferences: {},
},
{ onConflict: "user_id" },
);
return {
user: mapPublicUser(data),
token: createAppUserToken(data),
};
}
export async function loginAppUser({
context,
email,
password,
}: {
context?: AppUserLoginContext;
email: string;
password: string;
}) {
const supabase = createSupabaseServiceRoleClient();
const normalizedEmail = normalizeEmail(email);
const { data, error } = await supabase
.from("wg_app_users")
.select(`${userSelect},password_hash`)
.eq("email", normalizedEmail)
.eq("auth_provider", "email")
.maybeSingle<AppUser & { password_hash: string | null }>();
if (error) {
throw error;
}
if (!data) {
await recordAppUserLoginLog({
context,
email: normalizedEmail,
failureReason: "user_not_found",
status: "failed",
supabase,
});
throw new Error("Invalid email or password.");
}
if (data.status !== "active") {
await recordAppUserLoginLog({
context,
email: normalizedEmail,
failureReason: `user_status:${data.status}`,
status: "blocked",
supabase,
user: data,
});
throw new Error("Invalid email or password.");
}
if (!verifyPassword(password, data.password_hash)) {
await recordAppUserLoginLog({
context,
email: normalizedEmail,
failureReason: "invalid_password",
status: "failed",
supabase,
user: data,
});
throw new Error("Invalid email or password.");
}
const token = createAppUserToken(data);
await supabase
.from("wg_app_users")
.update({ last_login_at: new Date().toISOString() })
.eq("id", data.id);
await recordAppUserLoginLog({
context,
email: normalizedEmail,
jwtToken: token,
status: "success",
supabase,
user: data,
});
return {
user: mapPublicUser(data),
token,
};
}
export async function getAppUserFromRequest(request: Request) {
const authorization = request.headers.get("authorization") ?? "";
const token = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
if (!token) {
return null;
}
const payload = verifyAppUserToken(token);
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_app_users")
.select(userSelect)
.eq("id", payload.sub)
.maybeSingle<AppUser>();
if (error) {
throw error;
}
if (!data || data.status !== "active") {
return null;
}
return mapPublicUser(data) satisfies AuthenticatedAppUser;
}
export async function getOptionalAppUserFromRequest(request: Request) {
const authorization = request.headers.get("authorization") ?? "";
if (!authorization) {
return null;
}
return getAppUserFromRequest(request);
}