Require uppercase and numeric setup passwords
This commit is contained in:
+20
-9
@@ -20,14 +20,25 @@ import { collectSystemStatus } from "./status.js";
|
|||||||
import { readUpdateStatus, triggerSystemUpdate } from "./update.js";
|
import { readUpdateStatus, triggerSystemUpdate } from "./update.js";
|
||||||
import "./types.js";
|
import "./types.js";
|
||||||
|
|
||||||
const credentialsSchema = z.object({
|
const usernameSchema = z
|
||||||
username: z
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(3, "Username must contain at least 3 characters")
|
||||||
|
.max(48, "Username must contain at most 48 characters")
|
||||||
|
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/, "Use letters, numbers, dots, hyphens, or underscores");
|
||||||
|
|
||||||
|
const loginCredentialsSchema = z.object({
|
||||||
|
username: usernameSchema,
|
||||||
|
password: z.string().min(1).max(256)
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupCredentialsSchema = loginCredentialsSchema.extend({
|
||||||
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.min(8, "Password must contain at least 8 characters")
|
||||||
.min(3, "Username must contain at least 3 characters")
|
.max(256)
|
||||||
.max(48, "Username must contain at most 48 characters")
|
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/, "Use letters, numbers, dots, hyphens, or underscores"),
|
.regex(/[0-9]/, "Password must contain at least one number")
|
||||||
password: z.string().min(12, "Password must contain at least 12 characters").max(256)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type UserRow = { id: number; username: string; role: "admin"; password_hash: string };
|
type UserRow = { id: number; username: string; role: "admin"; password_hash: string };
|
||||||
@@ -162,7 +173,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
|||||||
"/api/auth/setup",
|
"/api/auth/setup",
|
||||||
{ config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
|
{ config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const parsed = credentialsSchema.safeParse(request.body);
|
const parsed = setupCredentialsSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: { code: "INVALID_CREDENTIALS", message: parsed.error.issues[0]?.message ?? "Invalid account details" }
|
error: { code: "INVALID_CREDENTIALS", message: parsed.error.issues[0]?.message ?? "Invalid account details" }
|
||||||
@@ -203,7 +214,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
|||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
{ config: { rateLimit: { max: 8, timeWindow: "1 minute" } } },
|
{ config: { rateLimit: { max: 8, timeWindow: "1 minute" } } },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const parsed = credentialsSchema.safeParse(request.body);
|
const parsed = loginCredentialsSchema.safeParse(request.body);
|
||||||
const user = parsed.success
|
const user = parsed.success
|
||||||
? (database
|
? (database
|
||||||
.prepare("SELECT id, username, role, password_hash FROM users WHERE username = ? COLLATE NOCASE")
|
.prepare("SELECT id, username, role, password_hash FROM users WHERE username = ? COLLATE NOCASE")
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ async function setup(app: FastifyInstance, token: string) {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/setup",
|
url: "/api/auth/setup",
|
||||||
headers: mutationHeaders(token),
|
headers: mutationHeaders(token),
|
||||||
payload: { username: "owner", password: "correct-horse-battery-staple" }
|
payload: { username: "owner", password: "Correct-horse1" }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,19 +63,40 @@ describe("authentication boundary", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/setup",
|
url: "/api/auth/setup",
|
||||||
headers: mutationHeaders(token),
|
headers: mutationHeaders(token),
|
||||||
payload: { username: "second", password: "another-secure-password" }
|
payload: { username: "second", password: "Another-pass2" }
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(second.statusCode).toBe(409);
|
expect(second.statusCode).toBe(409);
|
||||||
expect(second.json()).toMatchObject({ error: { code: "SETUP_COMPLETE" } });
|
expect(second.json()).toMatchObject({ error: { code: "SETUP_COMPLETE" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires 8 characters, an uppercase letter, and a number for setup", async () => {
|
||||||
|
const app = await createApp();
|
||||||
|
const token = await csrf(app);
|
||||||
|
const cases = [
|
||||||
|
{ password: "Short1", expectedMessage: "at least 8 characters" },
|
||||||
|
{ password: "lowercase1", expectedMessage: "uppercase letter" },
|
||||||
|
{ password: "NoNumberHere", expectedMessage: "one number" }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/setup",
|
||||||
|
headers: mutationHeaders(token),
|
||||||
|
payload: { username: "owner", password: testCase.password }
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json<{ error: { message: string } }>().error.message).toContain(testCase.expectedMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects state changes without a matching CSRF token", async () => {
|
it("rejects state changes without a matching CSRF token", async () => {
|
||||||
const app = await createApp();
|
const app = await createApp();
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/setup",
|
url: "/api/auth/setup",
|
||||||
payload: { username: "owner", password: "correct-horse-battery-staple" }
|
payload: { username: "owner", password: "Correct-horse1" }
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(403);
|
expect(response.statusCode).toBe(403);
|
||||||
@@ -89,7 +110,7 @@ describe("authentication boundary", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/setup",
|
url: "/api/auth/setup",
|
||||||
headers: { ...mutationHeaders(token), origin: "https://attacker.example" },
|
headers: { ...mutationHeaders(token), origin: "https://attacker.example" },
|
||||||
payload: { username: "owner", password: "correct-horse-battery-staple" }
|
payload: { username: "owner", password: "Correct-horse1" }
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(403);
|
expect(response.statusCode).toBe(403);
|
||||||
@@ -107,7 +128,7 @@ describe("authentication boundary", () => {
|
|||||||
host: "192.168.4.20:8787",
|
host: "192.168.4.20:8787",
|
||||||
origin: "http://192.168.4.20:8787"
|
origin: "http://192.168.4.20:8787"
|
||||||
},
|
},
|
||||||
payload: { username: "owner", password: "correct-horse-battery-staple" }
|
payload: { username: "owner", password: "Correct-horse1" }
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(201);
|
expect(response.statusCode).toBe(201);
|
||||||
@@ -123,7 +144,7 @@ describe("authentication boundary", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
headers: mutationHeaders(token),
|
headers: mutationHeaders(token),
|
||||||
payload: { username: "owner", password: "correct-horse-battery-staple" }
|
payload: { username: "owner", password: "Correct-horse1" }
|
||||||
});
|
});
|
||||||
expect(login.statusCode).toBe(200);
|
expect(login.statusCode).toBe(200);
|
||||||
const sessionCookie = login.cookies.find((cookie) => cookie.name === SESSION_COOKIE);
|
const sessionCookie = login.cookies.find((cookie) => cookie.name === SESSION_COOKIE);
|
||||||
|
|||||||
+12
-2
@@ -126,8 +126,18 @@ function AccountScreen({
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Password</span>
|
<span>Password</span>
|
||||||
<input type="password" autoComplete={setup ? "new-password" : "current-password"} value={password} onChange={(event) => setPassword(event.target.value)} minLength={12} maxLength={256} required />
|
<input
|
||||||
{setup && <small>Use at least 12 characters.</small>}
|
type="password"
|
||||||
|
autoComplete={setup ? "new-password" : "current-password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
minLength={setup ? 8 : 1}
|
||||||
|
maxLength={256}
|
||||||
|
pattern={setup ? "(?=.*[A-Z])(?=.*[0-9]).{8,}" : undefined}
|
||||||
|
title={setup ? "Use at least 8 characters, one uppercase letter, and one number." : undefined}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{setup && <small>Use at least 8 characters, one uppercase letter, and one number.</small>}
|
||||||
</label>
|
</label>
|
||||||
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
|
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
|
||||||
<button className="primary-button" type="submit" disabled={submitting}>
|
<button className="primary-button" type="submit" disabled={submitting}>
|
||||||
|
|||||||
Reference in New Issue
Block a user