Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Failing after 1m45s
CI / tests-ui (push) Successful in 2m29s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Closes the four HIGH/MEDIUM items left open after the previous pass. Login lockout - The only login limits were keyed on the client IP, so a distributed attempt could grind on one account indefinitely. Added a per-account lockout with a budget of 8 failures per 15 minutes. - The bucket is keyed on the RESOLVED account id, not on the submitted string: users may sign in with either username or e-mail and neither the lookup nor the input normaliser folds case, so an input-keyed bucket would hand out a fresh budget per spelling of the same account. - precheckLogin and NextAuth's authorize share the bucket, so the pre-check cannot be used to buy extra attempts and a client that skips it entirely is still bounded. Both check the lockout BEFORE verifying the password: the success path clears the counter, which would otherwise walk a locked account straight back in on the right password. - A successful login clears the failures, which needs two new primitives in rate-limit.ts: peekRateLimit (read-only, does not consume a unit) and clearRateLimit. - Fixed a latent inconsistency while doing so: the in-process bucket capped its counter at the limit while Redis' INCR kept climbing, so the two backends disagreed about how far over the limit a key was. Both now track the true count. Mail lookup index - Added an index on users.mail (0035). Password reset, e-mail verification and the resend cooldown all resolve a single account from a submitted address and were full table scans of `users`. Deliberately non-unique: legacy rows can hold the same address more than once, so a unique index would fail to apply. Resend captcha - /verify's resend form triggers real outbound mail and was reachable with only a cooldown. It now runs the configured captcha before the account lookup and before any send. Client message payload - The root layout serialised the whole catalogue into every page. pages.admin and admin are ~177 KB of the ~235 KB and are unreachable from the public route group, so that layout now installs its own provider with the staff namespaces removed. Nested providers replace rather than merge, which is why this has to live in the segment layout. /admin, /mod, /client and /admin-next keep the full set; a guard test fails if a public page ever references a staff namespace.
154 lines
5.4 KiB
TypeScript
154 lines
5.4 KiB
TypeScript
// @ts-nocheck
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
|
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
|
import { precheckLogin } from "./auth-precheck";
|
|
|
|
const core = vi.hoisted(() => ({
|
|
getLoginUser: vi.fn(),
|
|
verifyLoginPassword: vi.fn(),
|
|
isEmailUnverified: vi.fn(),
|
|
runDummyHashCheck: vi.fn(),
|
|
normalizeLoginInput: (username: unknown, password: unknown) => ({
|
|
username: String(username ?? "")
|
|
.normalize("NFC")
|
|
.trim(),
|
|
password: String(password ?? "").normalize("NFC"),
|
|
}),
|
|
isLoginLocked: vi.fn(async () => false),
|
|
recordLoginFailure: vi.fn(async () => false),
|
|
clearLoginLockout: vi.fn(async () => undefined),
|
|
}));
|
|
|
|
vi.mock("@/env", () => ({ env: {} }));
|
|
vi.mock("@/lib/auth/login-core", () => core);
|
|
vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() }));
|
|
vi.mock("@/lib/services/captcha", () => ({
|
|
captchaConfig: vi.fn(),
|
|
verifyCaptcha: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/services/site-settings", () => ({
|
|
siteSettings: { getBool: vi.fn() },
|
|
}));
|
|
vi.mock("@/lib/auth/login-lockout", () => ({
|
|
isLoginLocked: core.isLoginLocked,
|
|
recordLoginFailure: core.recordLoginFailure,
|
|
clearLoginLockout: core.clearLoginLockout,
|
|
}));
|
|
|
|
const user = (overrides = {}) => ({
|
|
id: 42,
|
|
password: "hash",
|
|
twoFactorConfirmedAt: null,
|
|
mail: null,
|
|
mailVerified: "0",
|
|
...overrides,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(clientIp).mockResolvedValue("1.2.3.4");
|
|
vi.mocked(rateLimit).mockResolvedValue({ ok: true });
|
|
vi.mocked(captchaConfig).mockResolvedValue({ provider: "none" } as never);
|
|
core.getLoginUser.mockResolvedValue(null);
|
|
core.verifyLoginPassword.mockResolvedValue({ valid: true });
|
|
core.isEmailUnverified.mockResolvedValue(false);
|
|
core.runDummyHashCheck.mockResolvedValue(undefined);
|
|
core.isLoginLocked.mockResolvedValue(false);
|
|
core.recordLoginFailure.mockResolvedValue(false);
|
|
core.clearLoginLockout.mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe("precheckLogin", () => {
|
|
it("returns ok for valid login without 2FA", async () => {
|
|
core.getLoginUser.mockResolvedValue(user());
|
|
expect(await precheckLogin("user", "pass")).toBe("ok");
|
|
});
|
|
|
|
it("returns twofactor when 2FA is set up", async () => {
|
|
core.getLoginUser.mockResolvedValue(
|
|
user({ twoFactorConfirmedAt: new Date() }),
|
|
);
|
|
expect(await precheckLogin("user", "pass")).toBe("twofactor");
|
|
});
|
|
|
|
it("returns invalid for empty inputs", async () => {
|
|
expect(await precheckLogin("", "")).toBe("invalid");
|
|
});
|
|
|
|
it("returns captcha when captcha required", async () => {
|
|
vi.mocked(captchaConfig).mockResolvedValue({
|
|
provider: "hcaptcha",
|
|
} as never);
|
|
vi.mocked(verifyCaptcha).mockResolvedValue(false);
|
|
core.getLoginUser.mockResolvedValue(user());
|
|
expect(await precheckLogin("user", "pass", "bad-token")).toBe("captcha");
|
|
});
|
|
|
|
it("returns invalid when user not found (dummy hash check)", async () => {
|
|
core.getLoginUser.mockResolvedValue(null);
|
|
const result = await precheckLogin("nonexistent", "pass");
|
|
expect(result).toBe("invalid");
|
|
expect(core.runDummyHashCheck).toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns unverified when email verification required", async () => {
|
|
core.getLoginUser.mockResolvedValue(user({ mail: "[email protected]" }));
|
|
core.isEmailUnverified.mockResolvedValue(true);
|
|
expect(await precheckLogin("user", "pass")).toBe("unverified");
|
|
});
|
|
|
|
it("returns locked for an account that is already locked out", async () => {
|
|
core.getLoginUser.mockResolvedValue(user());
|
|
core.isLoginLocked.mockResolvedValue(true);
|
|
expect(await precheckLogin("user", "pass")).toBe("locked");
|
|
// The password is never verified while locked, so a correct password
|
|
// cannot walk a locked account back in.
|
|
expect(core.verifyLoginPassword).not.toHaveBeenCalled();
|
|
expect(core.clearLoginLockout).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("checks the lockout before verifying the password", async () => {
|
|
core.getLoginUser.mockResolvedValue(user());
|
|
const order: string[] = [];
|
|
core.getLoginUser.mockImplementation(async () => {
|
|
order.push("lookup");
|
|
return user();
|
|
});
|
|
core.isLoginLocked.mockImplementation(async () => {
|
|
order.push("lock");
|
|
return false;
|
|
});
|
|
core.verifyLoginPassword.mockImplementation(async () => {
|
|
order.push("verify");
|
|
return { valid: true };
|
|
});
|
|
expect(await precheckLogin("user", "pass")).toBe("ok");
|
|
expect(order).toEqual(["lookup", "lock", "verify"]);
|
|
});
|
|
|
|
it("records a failure and skips the clear when the password is wrong", async () => {
|
|
core.getLoginUser.mockResolvedValue(user());
|
|
core.verifyLoginPassword.mockResolvedValue({ valid: false });
|
|
core.recordLoginFailure.mockResolvedValue(false);
|
|
expect(await precheckLogin("user", "pass")).toBe("invalid");
|
|
expect(core.recordLoginFailure).toHaveBeenCalledWith(42);
|
|
expect(core.clearLoginLockout).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("clears the lockout after a successful authentication", async () => {
|
|
core.getLoginUser.mockResolvedValue(user());
|
|
expect(await precheckLogin("user", "pass")).toBe("ok");
|
|
expect(core.clearLoginLockout).toHaveBeenCalledWith(42);
|
|
expect(core.recordLoginFailure).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not lock or clear a bucket for an unknown account", async () => {
|
|
core.getLoginUser.mockResolvedValue(null);
|
|
expect(await precheckLogin("nonexistent", "pass")).toBe("invalid");
|
|
expect(core.isLoginLocked).not.toHaveBeenCalled();
|
|
expect(core.recordLoginFailure).not.toHaveBeenCalled();
|
|
expect(core.clearLoginLockout).not.toHaveBeenCalled();
|
|
});
|
|
});
|