feat(auth): use password-only admin login
This commit is contained in:
@@ -14,6 +14,7 @@ export default async function LoginPage() {
|
||||
const config = readIdentityConfig(process.env);
|
||||
let provider = config.provider;
|
||||
let passwordEnabled = false;
|
||||
let passwordOnly = false;
|
||||
if (isSelfHostedIdentityEnabled(process.env)) {
|
||||
const selfHosted = readSelfHostedIdentityConfig(process.env);
|
||||
const surface = resolveIdentitySurface(
|
||||
@@ -21,9 +22,14 @@ export default async function LoginPage() {
|
||||
selfHosted,
|
||||
);
|
||||
if (surface === "admin") provider = "self-hosted";
|
||||
passwordEnabled = provider === "self-hosted" && surface === "user";
|
||||
passwordEnabled = provider === "self-hosted";
|
||||
passwordOnly = surface === "admin";
|
||||
}
|
||||
return (
|
||||
<EmailOtpLogin provider={provider} passwordEnabled={passwordEnabled} />
|
||||
<EmailOtpLogin
|
||||
provider={provider}
|
||||
passwordEnabled={passwordEnabled}
|
||||
passwordOnly={passwordOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,11 +36,13 @@ function passwordError(password: string, confirmation: string): string {
|
||||
export function EmailOtpLogin({
|
||||
provider,
|
||||
passwordEnabled = false,
|
||||
passwordOnly = false,
|
||||
}: {
|
||||
provider: AuthProvider;
|
||||
passwordEnabled?: boolean;
|
||||
passwordOnly?: boolean;
|
||||
}) {
|
||||
const [mode, setMode] = useState<AuthMode>("otp");
|
||||
const [mode, setMode] = useState<AuthMode>(passwordOnly ? "password" : "otp");
|
||||
const [step, setStep] = useState<AuthStep>("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
@@ -52,9 +54,9 @@ export function EmailOtpLogin({
|
||||
|
||||
const canUsePassword = provider === "self-hosted" && passwordEnabled;
|
||||
const showLoginNavigation =
|
||||
canUsePassword && (mode === "otp" || mode === "password");
|
||||
canUsePassword && !passwordOnly && (mode === "otp" || mode === "password");
|
||||
const showBackToLogin =
|
||||
canUsePassword && (mode === "register" || mode === "forgot");
|
||||
canUsePassword && !passwordOnly && (mode === "register" || mode === "forgot");
|
||||
|
||||
function chooseMode(nextMode: AuthMode, nextNotice = "") {
|
||||
setMode(nextMode);
|
||||
|
||||
@@ -100,19 +100,17 @@ export function buildAuthOptions({
|
||||
path: "/",
|
||||
},
|
||||
},
|
||||
...(surface === "user"
|
||||
? {
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
disableSignUp: true,
|
||||
minPasswordLength: 8,
|
||||
maxPasswordLength: 128,
|
||||
revokeSessionsOnPasswordReset: true,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
disableSignUp: true,
|
||||
minPasswordLength: 8,
|
||||
maxPasswordLength: 128,
|
||||
revokeSessionsOnPasswordReset: true,
|
||||
},
|
||||
plugins: [
|
||||
emailOTP(createEmailOtpOptions(emailSender, secret, surface === "admin")),
|
||||
...(surface === "user"
|
||||
? [emailOTP(createEmailOtpOptions(emailSender, secret, false))]
|
||||
: []),
|
||||
admin({
|
||||
defaultRole: "user",
|
||||
adminRoles: ["admin"],
|
||||
|
||||
@@ -114,7 +114,10 @@ test("user and admin auth surfaces have host-only isolated cookies", () => {
|
||||
maxPasswordLength: 128,
|
||||
revokeSessionsOnPasswordReset: true,
|
||||
});
|
||||
assert.equal(adminOptions.emailAndPassword, undefined);
|
||||
assert.deepEqual(
|
||||
adminOptions.emailAndPassword,
|
||||
userOptions.emailAndPassword,
|
||||
);
|
||||
for (const options of [userOptions, adminOptions]) {
|
||||
const attributes = options.advanced?.defaultCookieAttributes;
|
||||
assert.equal(attributes?.secure, true);
|
||||
@@ -126,7 +129,7 @@ test("user and admin auth surfaces have host-only isolated cookies", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("admin surface disables sign-up and rejects non-admin session creation", async () => {
|
||||
test("admin surface is password-only and rejects non-admin session creation", async () => {
|
||||
const checkedUserIds: string[] = [];
|
||||
const options = buildAuthOptions({
|
||||
surface: "admin",
|
||||
@@ -141,7 +144,9 @@ test("admin surface disables sign-up and rejects non-admin session creation", as
|
||||
const emailPlugin = options.plugins?.find(
|
||||
(plugin) => plugin.id === "email-otp",
|
||||
);
|
||||
assert.ok(emailPlugin);
|
||||
assert.equal(emailPlugin, undefined);
|
||||
assert.equal(options.emailAndPassword?.enabled, true);
|
||||
assert.equal(options.emailAndPassword?.disableSignUp, true);
|
||||
|
||||
const before = options.databaseHooks?.session?.create?.before;
|
||||
assert.ok(before);
|
||||
@@ -163,12 +168,6 @@ test("admin surface disables sign-up and rejects non-admin session creation", as
|
||||
);
|
||||
assert.deepEqual(checkedUserIds, ["ordinary-user-id", "admin-user-id"]);
|
||||
|
||||
const otpOptions = createEmailOtpOptions(
|
||||
new FakeEmailOtpSender(),
|
||||
config.adminSecret,
|
||||
true,
|
||||
);
|
||||
assert.equal(otpOptions.disableSignUp, true);
|
||||
});
|
||||
|
||||
test("admin surface requires a server-side persisted-role authorizer", () => {
|
||||
|
||||
@@ -61,7 +61,7 @@ const envKeys = [
|
||||
"RESEND_FROM_EMAIL",
|
||||
] as const;
|
||||
|
||||
test("Better Auth supports OTP registration/login, first password, password login, and OTP reset while admin stays OTP-only", async () => {
|
||||
test("Better Auth supports user OTP/password flows and password-only admin login", async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const migration = spawnSync(process.execPath, [runnerPath], {
|
||||
encoding: "utf8",
|
||||
@@ -300,6 +300,15 @@ test("Better Auth supports OTP registration/login, first password, password logi
|
||||
/^(?:__Secure-)?jyotisha-user\.session_token=/,
|
||||
);
|
||||
|
||||
const nonAdminPasswordLogin = await handlers.POST(
|
||||
request(adminHost, "/api/auth/sign-in/email", {
|
||||
email: newEmail,
|
||||
password: resetPassword,
|
||||
}),
|
||||
);
|
||||
assert.notEqual(nonAdminPasswordLogin.status, 200);
|
||||
assert.equal(nonAdminPasswordLogin.headers.has("set-cookie"), false);
|
||||
|
||||
fixture.psqlAs(
|
||||
"identity_runtime",
|
||||
"identity-runtime-test-password",
|
||||
@@ -311,33 +320,28 @@ test("Better Auth supports OTP registration/login, first password, password logi
|
||||
password: resetPassword,
|
||||
}),
|
||||
);
|
||||
assert.notEqual(adminPasswordLogin.status, 200);
|
||||
assert.equal(adminPasswordLogin.headers.has("set-cookie"), false);
|
||||
assert.equal(adminPasswordLogin.status, 200);
|
||||
assert.match(
|
||||
sessionCookie(adminPasswordLogin),
|
||||
/^(?:__Secure-)?jyotisha-admin\.session_token=/,
|
||||
);
|
||||
|
||||
const sentMessageCount = sender.messages.length;
|
||||
const adminSend = await handlers.POST(
|
||||
request(adminHost, "/api/auth/email-otp/send-verification-otp", {
|
||||
email: newEmail,
|
||||
type: "sign-in",
|
||||
}),
|
||||
);
|
||||
assert.equal(adminSend.status, 200);
|
||||
const adminMessage = sender.messages.at(-1);
|
||||
assert.equal(adminMessage?.type, "sign-in");
|
||||
const adminSignIn = await handlers.POST(
|
||||
request(adminHost, "/api/auth/sign-in/email-otp", {
|
||||
email: newEmail,
|
||||
otp: adminMessage?.otp,
|
||||
}),
|
||||
);
|
||||
assert.equal(adminSignIn.status, 200);
|
||||
assert.match(sessionCookie(adminSignIn), /^(?:__Secure-)?jyotisha-admin\.session_token=/);
|
||||
assert.notEqual(adminSend.status, 200);
|
||||
assert.equal(sender.messages.length, sentMessageCount);
|
||||
|
||||
const adminPasswordRoute = await setAccountPassword(
|
||||
request(
|
||||
adminHost,
|
||||
"/api/account/password",
|
||||
{ newPassword: "admin-must-not-set-password" },
|
||||
sessionCookie(adminSignIn),
|
||||
sessionCookie(adminPasswordLogin),
|
||||
),
|
||||
);
|
||||
assert.equal(adminPasswordRoute.status, 401);
|
||||
|
||||
@@ -4,7 +4,7 @@ import test from "node:test";
|
||||
|
||||
import { createSelfHostedAuthActions } from "../src/modules/identity/client.ts";
|
||||
|
||||
test("login page selects the auth provider and limits passwords to the user surface", () => {
|
||||
test("login page uses password-only mode on the self-hosted admin surface", () => {
|
||||
const page = readFileSync(
|
||||
new URL("../src/app/login/page.tsx", import.meta.url),
|
||||
"utf8",
|
||||
@@ -18,9 +18,11 @@ test("login page selects the auth provider and limits passwords to the user surf
|
||||
assert.match(page, /surface === "admin"/);
|
||||
assert.match(
|
||||
page,
|
||||
/passwordEnabled = provider === "self-hosted" && surface === "user"/,
|
||||
/passwordEnabled = provider === "self-hosted"/,
|
||||
);
|
||||
assert.match(page, /passwordOnly = surface === "admin"/);
|
||||
assert.match(page, /passwordEnabled=\{passwordEnabled\}/);
|
||||
assert.match(page, /passwordOnly=\{passwordOnly\}/);
|
||||
assert.doesNotMatch(page, /NEXT_PUBLIC_AUTH_PROVIDER/);
|
||||
});
|
||||
|
||||
@@ -176,12 +178,13 @@ test("login UI preserves accessible OTP, password, registration, and reset input
|
||||
}
|
||||
assert.match(
|
||||
component,
|
||||
/canUsePassword && \(mode === "otp" \|\| mode === "password"\)/,
|
||||
/canUsePassword && !passwordOnly && \(mode === "otp" \|\| mode === "password"\)/,
|
||||
);
|
||||
assert.match(
|
||||
component,
|
||||
/canUsePassword && \(mode === "register" \|\| mode === "forgot"\)/,
|
||||
/canUsePassword && !passwordOnly && \(mode === "register" \|\| mode === "forgot"\)/,
|
||||
);
|
||||
assert.match(component, /useState<AuthMode>\(passwordOnly \? "password" : "otp"\)/);
|
||||
assert.match(component, /\{showLoginNavigation && \(/);
|
||||
assert.match(component, />\s*返回登录\s*</);
|
||||
for (const autocomplete of [
|
||||
|
||||
@@ -989,3 +989,4 @@
|
||||
- 阻塞:执行环境无受控 staging 测试邮箱/收件箱,真实收信验收写入 BLOCKED.md;不使用他人邮箱,代码与部署继续。
|
||||
- 2026-07-27 UI 跟进:注册与忘记密码子流程不再显示“验证码登录 / 密码登录 / 注册账号 / 忘记密码”顶层导航,仅保留“返回登录”;聚焦测试 4/4、ESLint、diff check 通过。
|
||||
- 2026-07-27 staging 测试账户:确认 luna@copse.life 原为 0 用户/0 credential/0 会话;仅在 staging 通过 Better Auth createUser 创建,复核 user=1、credential=1、password_hashed=true,密码登录 HTTP 200 且 Set-Cookie=true;未直接写密码 SQL、未触发 production、临时文件已清理。
|
||||
- 2026-07-27 admin 跟进:按最新要求将 self-hosted admin 改为仅邮箱密码登录,OTP 插件不挂载 admin;普通账号仍由服务端持久化 admin 角色门禁拒绝。聚焦单测 11/11、identity 36/36、集成 1/1、staging 契约 20/20、ESLint、build、diff check 全绿,skipped/todo=0;本地 admin Host `/login`=200、密码表单存在、OTP/注册/忘记密码入口不存在。反向关闭 admin password 后集成测试按预期在 admin 登录 400!=200 变红,恢复后 1/1 全绿。
|
||||
|
||||
Reference in New Issue
Block a user