first commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user