All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
459 lines
16 KiB
TypeScript
459 lines
16 KiB
TypeScript
// Tokens, passwords and the checks that turn a token into a user.
|
|
import { count, createUser, db, get, PASSWORD, post, test } from "./helpers.ts";
|
|
import {
|
|
assert,
|
|
assertEquals,
|
|
assertFalse,
|
|
assertNotEquals,
|
|
} from "@std/assert";
|
|
import { SignJWT } from "@panva/jose";
|
|
import {
|
|
createJWT,
|
|
createMediaToken,
|
|
createPasswordResetToken,
|
|
hashPassword,
|
|
verifyJWT,
|
|
verifyMediaToken,
|
|
verifyPassword,
|
|
verifyPasswordResetToken,
|
|
} from "../lib/jwt.ts";
|
|
import { authenticate, authenticateMedia } from "../lib/auth.ts";
|
|
|
|
const KEY = new TextEncoder().encode("test-secret-test-secret-test-secret");
|
|
|
|
Deno.test("hashPassword/verifyPassword round-trip with a random salt", async () => {
|
|
const a = await hashPassword("hunter22");
|
|
const b = await hashPassword("hunter22");
|
|
assertNotEquals(a, b);
|
|
assert(await verifyPassword("hunter22", a));
|
|
assertFalse(await verifyPassword("hunter23", a));
|
|
// A malformed stored hash never verifies.
|
|
assertFalse(await verifyPassword("hunter22", "not-a-hash"));
|
|
assertFalse(await verifyPassword("hunter22", ""));
|
|
});
|
|
|
|
Deno.test("session, media and reset tokens don't stand in for each other", async () => {
|
|
const userId = crypto.randomUUID();
|
|
const session = await createJWT({ userId, username: "u", role: "user" });
|
|
const media = await createMediaToken(userId);
|
|
const reset = await createPasswordResetToken(userId);
|
|
|
|
assertEquals((await verifyJWT(session))?.userId, userId);
|
|
assertEquals(await verifyJWT(media), null);
|
|
assertEquals(await verifyJWT(reset), null);
|
|
|
|
assertEquals((await verifyMediaToken(media))?.userId, userId);
|
|
assertEquals(await verifyMediaToken(session), null);
|
|
assertEquals(await verifyMediaToken(reset), null);
|
|
|
|
assertEquals((await verifyPasswordResetToken(reset))?.userId, userId);
|
|
assertEquals(await verifyPasswordResetToken(session), null);
|
|
assertEquals(await verifyPasswordResetToken(media), null);
|
|
});
|
|
|
|
Deno.test("verifyJWT: refuses a bad signature, an expired token, garbage", async () => {
|
|
const forged = await new SignJWT({
|
|
userId: "x",
|
|
username: "x",
|
|
role: "admin",
|
|
})
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
.setExpirationTime("1h")
|
|
.sign(new TextEncoder().encode("some-other-secret"));
|
|
assertEquals(await verifyJWT(forged), null);
|
|
|
|
const expired = await new SignJWT({
|
|
userId: "x",
|
|
username: "x",
|
|
role: "user",
|
|
})
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
.setExpirationTime(Math.floor(Date.now() / 1000) - 10)
|
|
.sign(KEY);
|
|
assertEquals(await verifyJWT(expired), null);
|
|
|
|
const badRole = await new SignJWT({ userId: "x", username: "x", role: "god" })
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
.setExpirationTime("1h")
|
|
.sign(KEY);
|
|
assertEquals(await verifyJWT(badRole), null);
|
|
|
|
assertEquals(await verifyJWT("not.a.jwt"), null);
|
|
});
|
|
|
|
test("authenticate: the role comes from the database, not the token", async () => {
|
|
const user = await createUser({ role: "user" });
|
|
db.prepare(`UPDATE users SET role = 'admin' WHERE id = ?;`).run(user.id);
|
|
assertEquals((await authenticate(user.token))?.role, "admin");
|
|
});
|
|
|
|
test("authenticate: a deleted user's token is refused", async () => {
|
|
const user = await createUser();
|
|
db.prepare(`DELETE FROM users WHERE id = ?;`).run(user.id);
|
|
assertEquals(await authenticate(user.token), null);
|
|
assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401);
|
|
});
|
|
|
|
test("authenticate: tokens issued before tokens_valid_after are revoked", async () => {
|
|
const user = await createUser();
|
|
const future = new Date(Date.now() + 60_000).toISOString();
|
|
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
|
|
future,
|
|
user.id,
|
|
);
|
|
assertEquals(await authenticate(user.token), null);
|
|
assertEquals(await authenticateMedia(user.mediaToken), null);
|
|
// A token issued at the cutoff second or later passes.
|
|
const past = new Date(Date.now() - 60_000).toISOString();
|
|
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
|
|
past,
|
|
user.id,
|
|
);
|
|
assertEquals((await authenticate(user.token))?.userId, user.id);
|
|
});
|
|
|
|
test("authenticateMedia: takes a media token or a legacy session token", async () => {
|
|
const user = await createUser();
|
|
assertEquals(await authenticateMedia(user.mediaToken), user.id);
|
|
assertEquals(await authenticateMedia(user.token), user.id);
|
|
assertEquals(await authenticateMedia("garbage"), null);
|
|
});
|
|
|
|
test("optional auth: a missing or placeholder token is anonymous, a bad one is 401", async () => {
|
|
const owner = await createUser();
|
|
// A public listing that reads optional auth.
|
|
const path = `/api/users/${owner.username}/dumps`;
|
|
assertEquals((await get(path)).status, 200);
|
|
for (const placeholder of ["Bearer undefined", "Bearer null", "Bearer "]) {
|
|
assertEquals(
|
|
(await get(path, { headers: { Authorization: placeholder } })).status,
|
|
200,
|
|
placeholder,
|
|
);
|
|
}
|
|
assertEquals(
|
|
(await get(path, { headers: { Authorization: "Basic abc" } })).status,
|
|
200,
|
|
);
|
|
const bad = await get(path, { headers: { Authorization: "Bearer nope" } });
|
|
assertEquals(bad.status, 401);
|
|
assertEquals(bad.body.error.code, "UNAUTHORIZED");
|
|
});
|
|
|
|
test("required auth: missing, malformed and forged tokens are 401", async () => {
|
|
assertEquals((await get("/api/users/me")).status, 401);
|
|
assertEquals(
|
|
(await get("/api/users/me", { headers: { Authorization: "Token x" } }))
|
|
.status,
|
|
401,
|
|
);
|
|
const media = (await createUser()).mediaToken;
|
|
assertEquals(
|
|
(await get("/api/users/me", {
|
|
headers: { Authorization: `Bearer ${media}` },
|
|
})).status,
|
|
401,
|
|
);
|
|
});
|
|
|
|
// ── Login and register ───────────────────────────────────────────────────────
|
|
|
|
test("POST /api/users/login: right password logs in, wrong one doesn't say which", async () => {
|
|
const user = await createUser();
|
|
const ok = await post("/api/users/login", {
|
|
json: { username: user.username, password: PASSWORD },
|
|
});
|
|
assertEquals(ok.status, 200);
|
|
assertEquals(ok.body.data.user.id, user.id);
|
|
assertEquals(ok.body.data.user.passwordHash, undefined);
|
|
assertEquals((await verifyJWT(ok.body.data.token))?.userId, user.id);
|
|
assertEquals(
|
|
(await verifyMediaToken(ok.body.data.mediaToken))?.userId,
|
|
user.id,
|
|
);
|
|
|
|
const wrong = await post("/api/users/login", {
|
|
json: { username: user.username, password: "nope" },
|
|
});
|
|
const unknown = await post("/api/users/login", {
|
|
json: { username: "no_such_user_here", password: "nope" },
|
|
});
|
|
assertEquals(wrong.status, 401);
|
|
assertEquals(unknown.status, 401);
|
|
assertEquals(wrong.body.error.message, unknown.body.error.message);
|
|
|
|
assertEquals(
|
|
(await post("/api/users/login", { json: { username: user.username } }))
|
|
.status,
|
|
400,
|
|
);
|
|
});
|
|
|
|
test("POST /api/users/login: rate limited per account, case-insensitively", async () => {
|
|
const user = await createUser();
|
|
const statuses: number[] = [];
|
|
for (let i = 0; i < 11; i++) {
|
|
const username = i % 2 ? user.username.toUpperCase() : user.username;
|
|
statuses.push(
|
|
(await post("/api/users/login", {
|
|
json: { username, password: "wrong" },
|
|
})).status,
|
|
);
|
|
}
|
|
assertEquals(statuses.slice(0, 10), Array(10).fill(401));
|
|
assertEquals(statuses[10], 429);
|
|
// Even the right password is held back now.
|
|
assertEquals(
|
|
(await post("/api/users/login", {
|
|
json: { username: user.username, password: PASSWORD },
|
|
})).status,
|
|
429,
|
|
);
|
|
});
|
|
|
|
function invite(inviterId: string, createdAt = new Date()): string {
|
|
const token = crypto.randomUUID();
|
|
db.prepare(
|
|
`INSERT INTO invites (token, inviter_id, created_at) VALUES (?, ?, ?);`,
|
|
).run(token, inviterId, createdAt.toISOString());
|
|
return token;
|
|
}
|
|
|
|
test("POST /api/users/register: an invite opens one account", async () => {
|
|
const inviter = await createUser();
|
|
const inviteToken = invite(inviter.id);
|
|
const res = await post("/api/users/register", {
|
|
json: {
|
|
username: "newbie_reg",
|
|
password: "longenough",
|
|
email: "newbie@example.test",
|
|
inviteToken,
|
|
},
|
|
});
|
|
assertEquals(res.status, 201);
|
|
assertEquals(res.body.data.user.username, "newbie_reg");
|
|
assertEquals(res.body.data.user.role, "user");
|
|
assertEquals(res.body.data.user.passwordHash, undefined);
|
|
assert(res.body.data.token);
|
|
const row = db.prepare(
|
|
`SELECT invited_by FROM users WHERE username = 'newbie_reg';`,
|
|
).get() as { invited_by: string };
|
|
assertEquals(row.invited_by, inviter.id);
|
|
|
|
const again = await post("/api/users/register", {
|
|
json: {
|
|
username: "second_reg",
|
|
password: "longenough",
|
|
email: "second@example.test",
|
|
inviteToken,
|
|
},
|
|
});
|
|
assertEquals(again.status, 409);
|
|
});
|
|
|
|
test("POST /api/users/register: validation and unknown invites", async () => {
|
|
const inviter = await createUser();
|
|
const inviteToken = invite(inviter.id);
|
|
const base = {
|
|
username: "valid_name",
|
|
password: "longenough",
|
|
email: "v@example.test",
|
|
inviteToken,
|
|
};
|
|
const cases: [Record<string, unknown>, RegExp][] = [
|
|
[{ ...base, email: "nope" }, /email/],
|
|
[{ ...base, username: "has space" }, /Username/],
|
|
[{ ...base, username: "x".repeat(33) }, /Username/],
|
|
[{ ...base, password: "short" }, /at least 8/],
|
|
[{ ...base, password: "x".repeat(129) }, /at most 128/],
|
|
[{ ...base, inviteToken: 42 }, /Invalid request/],
|
|
];
|
|
for (const [json, message] of cases) {
|
|
const res = await post("/api/users/register", { json });
|
|
assertEquals(res.status, 400, JSON.stringify(json));
|
|
assert(message.test(res.body.error.message), res.body.error.message);
|
|
}
|
|
const unknown = await post("/api/users/register", {
|
|
json: { ...base, inviteToken: "made-up" },
|
|
});
|
|
assertEquals(unknown.status, 404);
|
|
// None of the failures used the invite up.
|
|
assertEquals(
|
|
count(`SELECT COUNT(*) as n FROM invites WHERE used_at IS NULL;`),
|
|
1,
|
|
);
|
|
});
|
|
|
|
test("POST /api/users/register: a taken name or email hands the invite back", async () => {
|
|
const inviter = await createUser();
|
|
const taken = await createUser();
|
|
const inviteToken = invite(inviter.id);
|
|
const byName = await post("/api/users/register", {
|
|
json: {
|
|
username: taken.username,
|
|
password: "longenough",
|
|
email: "fresh@example.test",
|
|
inviteToken,
|
|
},
|
|
});
|
|
assertEquals(byName.status, 400);
|
|
const byEmail = await post("/api/users/register", {
|
|
json: {
|
|
username: "fresh_name",
|
|
password: "longenough",
|
|
email: taken.email.toUpperCase(),
|
|
inviteToken,
|
|
},
|
|
});
|
|
assertEquals(byEmail.status, 409);
|
|
assertEquals(
|
|
count(
|
|
`SELECT COUNT(*) as n FROM invites WHERE token = ? AND used_at IS NULL;`,
|
|
inviteToken,
|
|
),
|
|
1,
|
|
);
|
|
});
|
|
|
|
test("POST /api/users/register: an invite older than 7 days is refused", async () => {
|
|
const inviter = await createUser();
|
|
const inviteToken = invite(
|
|
inviter.id,
|
|
new Date(Date.now() - 8 * 24 * 60 * 60 * 1000),
|
|
);
|
|
const res = await post("/api/users/register", {
|
|
json: {
|
|
username: "late_comer",
|
|
password: "longenough",
|
|
email: "late@example.test",
|
|
inviteToken,
|
|
},
|
|
});
|
|
assertEquals(res.status, 404);
|
|
});
|
|
|
|
test("POST /api/users/register: parallel registrations can't share an invite", async () => {
|
|
const inviter = await createUser();
|
|
const inviteToken = invite(inviter.id);
|
|
const results = await Promise.all(
|
|
[1, 2, 3].map((i) =>
|
|
post("/api/users/register", {
|
|
json: {
|
|
username: `racer_${i}`,
|
|
password: "longenough",
|
|
email: `racer${i}@example.test`,
|
|
inviteToken,
|
|
},
|
|
})
|
|
),
|
|
);
|
|
assertEquals(results.filter((r) => r.status === 201).length, 1);
|
|
assertEquals(
|
|
count(`SELECT COUNT(*) as n FROM users WHERE invited_by = ?;`, inviter.id),
|
|
1,
|
|
);
|
|
});
|
|
|
|
// ── Password changes ─────────────────────────────────────────────────────────
|
|
|
|
test("change-password: needs the current one, revokes old tokens, returns new ones", async () => {
|
|
const user = await createUser();
|
|
const wrong = await post("/api/users/me/change-password", {
|
|
auth: user.auth,
|
|
json: { currentPassword: "nope", newPassword: "brand-new-pass" },
|
|
});
|
|
assertEquals(wrong.status, 401);
|
|
const short = await post("/api/users/me/change-password", {
|
|
auth: user.auth,
|
|
json: { currentPassword: PASSWORD, newPassword: "short" },
|
|
});
|
|
assertEquals(short.status, 400);
|
|
|
|
// iat has one-second resolution: make sure the old token is from an earlier
|
|
// second than the change.
|
|
await new Promise((r) => setTimeout(r, 1100));
|
|
const ok = await post("/api/users/me/change-password", {
|
|
auth: user.auth,
|
|
json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" },
|
|
});
|
|
assertEquals(ok.status, 200);
|
|
assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401);
|
|
assertEquals(await authenticateMedia(user.mediaToken), null);
|
|
const fresh = { Authorization: `Bearer ${ok.body.data.token}` };
|
|
assertEquals((await get("/api/users/me", { auth: fresh })).status, 200);
|
|
assertEquals(await authenticateMedia(ok.body.data.mediaToken), user.id);
|
|
|
|
const login = await post("/api/users/login", {
|
|
json: { username: user.username, password: "brand-new-pass" },
|
|
});
|
|
assertEquals(login.status, 200);
|
|
});
|
|
|
|
test("request-password-reset: always 200, stores nothing without SMTP", async () => {
|
|
const user = await createUser();
|
|
for (const email of [user.email, "nobody@example.test", "", undefined]) {
|
|
const res = await post("/api/users/request-password-reset", {
|
|
json: { email },
|
|
});
|
|
assertEquals(res.status, 200);
|
|
}
|
|
assertEquals(count(`SELECT COUNT(*) as n FROM password_reset_tokens;`), 0);
|
|
});
|
|
|
|
test("reset-password: a stored token works once", async () => {
|
|
const user = await createUser();
|
|
const token = await createPasswordResetToken(user.id);
|
|
db.prepare(
|
|
`INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`,
|
|
).run(token, user.id, new Date(Date.now() + 3600_000).toISOString());
|
|
|
|
const short = await post("/api/users/reset-password", {
|
|
json: { token, newPassword: "short" },
|
|
});
|
|
assertEquals(short.status, 400);
|
|
|
|
const ok = await post("/api/users/reset-password", {
|
|
json: { token, newPassword: "reset-password-1" },
|
|
});
|
|
assertEquals(ok.status, 200);
|
|
const login = await post("/api/users/login", {
|
|
json: { username: user.username, password: "reset-password-1" },
|
|
});
|
|
assertEquals(login.status, 200);
|
|
|
|
const reuse = await post("/api/users/reset-password", {
|
|
json: { token, newPassword: "reset-password-2" },
|
|
});
|
|
assertEquals(reuse.status, 409);
|
|
});
|
|
|
|
test("reset-password: an unstored, forged or wrong-purpose token fails", async () => {
|
|
const user = await createUser();
|
|
const unstored = await createPasswordResetToken(user.id);
|
|
assertEquals(
|
|
(await post("/api/users/reset-password", {
|
|
json: { token: unstored, newPassword: "whatever-123" },
|
|
})).status,
|
|
404,
|
|
);
|
|
for (const token of ["garbage", user.token, user.mediaToken]) {
|
|
assertEquals(
|
|
(await post("/api/users/reset-password", {
|
|
json: { token, newPassword: "whatever-123" },
|
|
})).status,
|
|
400,
|
|
);
|
|
}
|
|
assertEquals(
|
|
(await post("/api/users/reset-password", { json: { token: 1 } })).status,
|
|
400,
|
|
);
|
|
});
|
|
|
|
test("GET /api/users/me/media-token issues a media token for the caller", async () => {
|
|
const user = await createUser();
|
|
const res = await get("/api/users/me/media-token", { auth: user.auth });
|
|
assertEquals(res.status, 200);
|
|
assertEquals(await authenticateMedia(res.body.data.mediaToken), user.id);
|
|
});
|