Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 165 additions & 115 deletions backend/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { getAdminEmails } from "#/config/security";
import { Prisma } from "#prisma/generated/prisma";
import { sendMagicLink } from "#utils/email";
import { logger } from "#utils/logger";
import { SpanStatusCode } from "@opentelemetry/api";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { APIError } from "better-auth/api";
import { magicLink } from "better-auth/plugins";
import { tracer } from "./tracing";

const authLogger = logger.child({ component: "auth" });

Expand All @@ -31,154 +33,202 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
magicLink({
expiresIn: 600,
sendMagicLink: async ({ email, token, url }, request?) => {
const normalizedEmail = email.toLowerCase();

let ipAddress: string | null = null;
if (request?.headers) {
const headers = request.headers as unknown as Record<string, string | string[] | undefined>;
const forwardedFor = headers["x-forwarded-for"];
const realIp = headers["x-real-ip"];
const requestWithIp = request as unknown as Record<string, unknown>;
ipAddress =
(typeof forwardedFor === "string" ? forwardedFor.split(",")[0]?.trim() : undefined) ||
(typeof realIp === "string" ? realIp : undefined) ||
(requestWithIp.ip as string | undefined) ||
null;
}
const maskedEmail = email.length > 4 ? `${email.substring(0, 2)}***@${email.split("@")[1] || "***"}` : "***";
const span = tracer.startSpan("auth.send_magic_link", {
attributes: {
"auth.email.masked": maskedEmail,
"auth.token.length": token.length,
"auth.method": "magic_link"
}
});

try {
await prisma.$transaction(
async tx => {
const recentAttempt = await tx.magicLinkAttempt.findFirst({
where: {
email: normalizedEmail,
createdAt: {
gt: new Date(Date.now() - 30000)
}
},
orderBy: {
createdAt: "desc"
}
});

if (recentAttempt) {
throw new APIError("TOO_MANY_REQUESTS", {
message: "請稍後再試,登入信發送間隔需 30 秒"
});
}
const normalizedEmail = email.toLowerCase();

const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
let ipAddress: string | null = null;
if (request?.headers) {
const headers = request.headers as unknown as Record<string, string | string[] | undefined>;
const forwardedFor = headers["x-forwarded-for"];
const realIp = headers["x-real-ip"];
const requestWithIp = request as unknown as Record<string, unknown>;
ipAddress =
(typeof forwardedFor === "string" ? forwardedFor.split(",")[0]?.trim() : undefined) ||
(typeof realIp === "string" ? realIp : undefined) ||
(requestWithIp.ip as string | undefined) ||
null;

const todayEnd = new Date();
todayEnd.setHours(23, 59, 59, 999);
if (ipAddress) {
span.setAttribute("auth.ip.masked", ipAddress.substring(0, 8) + "***");
}
}

const lastSuccessfulLogin = await tx.magicLinkAttempt.findFirst({
where: {
email: normalizedEmail,
success: true
},
orderBy: {
createdAt: "desc"
}
});

const failedAttemptsSinceSuccess = await tx.magicLinkAttempt.count({
where: {
email: normalizedEmail,
success: false,
createdAt: {
gt: lastSuccessfulLogin?.createdAt || new Date(0)
try {
span.addEvent("auth.rate_limit.check_start");
await prisma.$transaction(
async tx => {
const recentAttempt = await tx.magicLinkAttempt.findFirst({
where: {
email: normalizedEmail,
createdAt: {
gt: new Date(Date.now() - 30000)
}
},
orderBy: {
createdAt: "desc"
}
});

if (recentAttempt) {
span.addEvent("auth.rate_limit.throttled", {
reason: "recent_attempt_30s"
});
throw new APIError("TOO_MANY_REQUESTS", {
message: "請稍後再試,登入信發送間隔需 30 秒"
});
}
});

if (failedAttemptsSinceSuccess >= 5) {
throw new APIError("TOO_MANY_REQUESTS", {
message: "登入嘗試次數已達上限(5 次),請稍後再試或聯繫客服"
});
}
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);

const successfulLoginsToday = await tx.magicLinkAttempt.count({
where: {
email: normalizedEmail,
success: true,
createdAt: {
gte: todayStart,
lte: todayEnd
const todayEnd = new Date();
todayEnd.setHours(23, 59, 59, 999);

const lastSuccessfulLogin = await tx.magicLinkAttempt.findFirst({
where: {
email: normalizedEmail,
success: true
},
orderBy: {
createdAt: "desc"
}
}
});
});

if (successfulLoginsToday >= 20) {
throw new APIError("TOO_MANY_REQUESTS", {
message: "今日登入次數已達上限(20 次),請明天再試"
const failedAttemptsSinceSuccess = await tx.magicLinkAttempt.count({
where: {
email: normalizedEmail,
success: false,
createdAt: {
gt: lastSuccessfulLogin?.createdAt || new Date(0)
}
}
});
}

if (ipAddress) {
const ipAttempts = await tx.magicLinkAttempt.count({
if (failedAttemptsSinceSuccess >= 5) {
span.addEvent("auth.rate_limit.throttled", {
reason: "failed_attempts_limit",
count: failedAttemptsSinceSuccess
});
throw new APIError("TOO_MANY_REQUESTS", {
message: "登入嘗試次數已達上限(5 次),請稍後再試或聯繫客服"
});
}

const successfulLoginsToday = await tx.magicLinkAttempt.count({
where: {
ipAddress,
email: normalizedEmail,
success: true,
createdAt: {
gte: todayStart,
lte: todayEnd
}
}
});

if (ipAttempts >= 50) {
if (successfulLoginsToday >= 20) {
span.addEvent("auth.rate_limit.throttled", {
reason: "daily_login_limit",
count: successfulLoginsToday
});
throw new APIError("TOO_MANY_REQUESTS", {
message: "您今日已達到發送登入信的次數上限,請明天再試"
message: "今日登入次數已達上限(20 次),請明天再試"
});
}
}

await tx.magicLinkAttempt.create({
data: {
email: normalizedEmail,
ipAddress,
success: false
if (ipAddress) {
const ipAttempts = await tx.magicLinkAttempt.count({
where: {
ipAddress,
createdAt: {
gte: todayStart,
lte: todayEnd
}
}
});

if (ipAttempts >= 50) {
span.addEvent("auth.rate_limit.throttled", {
reason: "ip_daily_limit",
count: ipAttempts
});
throw new APIError("TOO_MANY_REQUESTS", {
message: "您今日已達到發送登入信的次數上限,請明天再試"
});
}
}
});
},
{ isolationLevel: "Serializable" }
);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
const prismaError = e as Prisma.PrismaClientKnownRequestError;
if (prismaError.code === "P2034") {
authLogger.warn("Magic link transaction conflict detected");
throw new APIError("TOO_MANY_REQUESTS", {
message: "系統繁忙,請稍後再試"
});

const magicLinkAttempt = await tx.magicLinkAttempt.create({
data: {
email: normalizedEmail,
ipAddress,
success: false
}
});
span.setAttribute("magic_link_attempt.id", magicLinkAttempt.id);
span.addEvent("auth.rate_limit.attempt_recorded");
},
{ isolationLevel: "Serializable" }
);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
const prismaError = e as Prisma.PrismaClientKnownRequestError;
if (prismaError.code === "P2034") {
authLogger.warn("Magic link transaction conflict detected");
span.addEvent("auth.rate_limit.transaction_conflict");
throw new APIError("TOO_MANY_REQUESTS", {
message: "系統繁忙,請稍後再試"
});
}
}
span.recordException(e as Error);
throw e;
}
throw e;
}

let locale = "zh-Hant";
let returnUrl: string | null = null;
try {
const constructedUrl = new URL(url);
const callbackURL = constructedUrl.searchParams.get("callbackURL");
if (callbackURL) {
const callbackUrl = new URL(callbackURL);
const callbackPathParts = callbackUrl.pathname.split("/").filter(Boolean);
if (callbackPathParts.length > 0 && ["en", "zh-Hant", "zh-Hans"].includes(callbackPathParts[0])) {
locale = callbackPathParts[0];
let locale = "zh-Hant";
let returnUrl: string | null = null;
try {
const constructedUrl = new URL(url);
const callbackURL = constructedUrl.searchParams.get("callbackURL");
if (callbackURL) {
const callbackUrl = new URL(callbackURL);
const callbackPathParts = callbackUrl.pathname.split("/").filter(Boolean);
if (callbackPathParts.length > 0 && ["en", "zh-Hant", "zh-Hans"].includes(callbackPathParts[0])) {
locale = callbackPathParts[0];
}
returnUrl = callbackUrl.pathname + callbackUrl.search;
}
returnUrl = callbackUrl.pathname + callbackUrl.search;
} catch (e) {
authLogger.error({ error: e }, "Error parsing callback URL");
span.addEvent("auth.url_parse_error");
}

span.setAttribute("auth.locale", locale);

let frontendUrl = `${process.env.FRONTEND_URI || "http://localhost:4321"}/api/auth/magic-link/verify?token=${token}&locale=${locale}`;
if (returnUrl) {
frontendUrl += `&returnUrl=${encodeURIComponent(returnUrl)}`;
}
} catch (e) {
authLogger.error({ error: e }, "Error parsing callback URL");
}

let frontendUrl = `${process.env.FRONTEND_URI || "http://localhost:4321"}/api/auth/magic-link/verify?token=${token}&locale=${locale}`;
if (returnUrl) {
frontendUrl += `&returnUrl=${encodeURIComponent(returnUrl)}`;
span.addEvent("auth.email.send_start");
await sendMagicLink(email, frontendUrl);
span.addEvent("auth.email.send_complete");
span.setStatus({ code: SpanStatusCode.OK });
} catch (error) {
span.recordException(error as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: "Failed to send magic link" });
throw error;
} finally {
span.end();
}
await sendMagicLink(email, frontendUrl);
}
})
],
Expand Down
Loading