This commit is contained in:
+322
-42
@@ -347,51 +347,150 @@ describe('administrator user management', () => {
|
||||
expect(auth.getUser(second.id)?.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('revokes every target session on role, enabled-state and password changes while keeping other users signed in', async () => {
|
||||
const admin = seedUser(),
|
||||
target = seedUser('target', 'viewer');
|
||||
const administratorSession = auth.createSession(admin.id);
|
||||
const first = auth.createSession(target.id),
|
||||
second = auth.createSession(target.id);
|
||||
await auth.updateUser(admin.id, target.id, { role: 'super_viewer' });
|
||||
expect(auth.getUser(target.id)?.role).toBe('super_viewer');
|
||||
expect(auth.readSession(first.token)).toBeNull();
|
||||
expect(auth.readSession(second.token)).toBeNull();
|
||||
const third = auth.createSession(target.id);
|
||||
await auth.updateUser(admin.id, target.id, { disabled: true });
|
||||
expect(auth.readSession(third.token)).toBeNull();
|
||||
expect(() => auth.createSession(target.id)).toThrow('unavailable');
|
||||
await auth.updateUser(admin.id, target.id, { disabled: false });
|
||||
expect(auth.readSession(third.token)).toBeNull();
|
||||
const fourth = auth.createSession(target.id);
|
||||
const reset = await auth.updateUser(admin.id, target.id, { password: replacement });
|
||||
expect(reset.mustChangePassword).toBe(true);
|
||||
expect(auth.readSession(fourth.token)).toBeNull();
|
||||
expect(auth.readSession(administratorSession.token)?.user.id).toBe(admin.id);
|
||||
expect(storedUser(target.id).password_hash).not.toBe(passwordHash);
|
||||
await expect(
|
||||
auth.updateUser(admin.id, admin.id, { password: replacement })
|
||||
).rejects.toThrow('current password');
|
||||
});
|
||||
it.each([false, true])(
|
||||
'revokes every target session on role, enabled-state and password changes (remembered=%s)',
|
||||
async (remembered) => {
|
||||
const admin = seedUser(),
|
||||
target = seedUser('target', 'viewer');
|
||||
const administratorSession = auth.createSession(admin.id);
|
||||
const first = auth.createSession(target.id, remembered),
|
||||
second = auth.createSession(target.id);
|
||||
await auth.updateUser(admin.id, target.id, { role: 'super_viewer' });
|
||||
expect(auth.getUser(target.id)?.role).toBe('super_viewer');
|
||||
expect(auth.readSession(first.token)).toBeNull();
|
||||
expect(auth.readSession(second.token)).toBeNull();
|
||||
const third = auth.createSession(target.id, remembered);
|
||||
await auth.updateUser(admin.id, target.id, { disabled: true });
|
||||
expect(auth.readSession(third.token)).toBeNull();
|
||||
expect(() => auth.createSession(target.id)).toThrow('unavailable');
|
||||
await auth.updateUser(admin.id, target.id, { disabled: false });
|
||||
expect(auth.readSession(third.token)).toBeNull();
|
||||
const fourth = auth.createSession(target.id, remembered);
|
||||
const reset = await auth.updateUser(admin.id, target.id, { password: replacement });
|
||||
expect(reset.mustChangePassword).toBe(true);
|
||||
expect(auth.readSession(fourth.token)).toBeNull();
|
||||
expect(auth.readSession(administratorSession.token)?.user.id).toBe(admin.id);
|
||||
expect(storedUser(target.id).password_hash).not.toBe(passwordHash);
|
||||
await expect(
|
||||
auth.updateUser(admin.id, admin.id, { password: replacement })
|
||||
).rejects.toThrow('current password');
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps sessions for display-only edits and supports explicit per-user and individual revocation', async () => {
|
||||
const admin = seedUser(),
|
||||
target = seedUser('target', 'viewer');
|
||||
const one = auth.createSession(target.id),
|
||||
two = auth.createSession(target.id),
|
||||
adminSession = auth.createSession(admin.id);
|
||||
await auth.updateUser(admin.id, target.id, { displayName: 'New display name' });
|
||||
expect(auth.readSession(one.token)?.user.displayName).toBe('New display name');
|
||||
auth.revokeSession(one.token);
|
||||
expect(auth.readSession(one.token)).toBeNull();
|
||||
expect(auth.readSession(two.token)).not.toBeNull();
|
||||
auth.revokeUserSessions(admin.id, target.id);
|
||||
expect(auth.readSession(two.token)).toBeNull();
|
||||
expect(auth.readSession(adminSession.token)).not.toBeNull();
|
||||
});
|
||||
it.each([false, true])(
|
||||
'keeps sessions for display-only edits and supports per-user and individual revocation (remembered=%s)',
|
||||
async (remembered) => {
|
||||
const admin = seedUser(),
|
||||
target = seedUser('target', 'viewer');
|
||||
const one = auth.createSession(target.id, remembered),
|
||||
two = auth.createSession(target.id, remembered),
|
||||
adminSession = auth.createSession(admin.id);
|
||||
await auth.updateUser(admin.id, target.id, { displayName: 'New display name' });
|
||||
expect(auth.readSession(one.token)?.user.displayName).toBe('New display name');
|
||||
auth.revokeSession(one.token);
|
||||
expect(auth.readSession(one.token)).toBeNull();
|
||||
expect(auth.readSession(two.token)).not.toBeNull();
|
||||
auth.revokeUserSessions(admin.id, target.id);
|
||||
expect(auth.readSession(two.token)).toBeNull();
|
||||
expect(auth.readSession(adminSession.token)).not.toBeNull();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('session secrecy and expiration', () => {
|
||||
it('signs out a remembered device by deleting its cookie and server session', async () => {
|
||||
const user = seedUser();
|
||||
const session = auth.createSession(user.id, true);
|
||||
const otherDevice = auth.createSession(user.id, true);
|
||||
const { POST } = await import('../../src/routes/api/auth/logout/+server.js');
|
||||
const url = new URL('https://ec.test/api/auth/logout');
|
||||
const cookies = { get: vi.fn(() => session.token), delete: vi.fn() };
|
||||
const response = await POST({ cookies, url, locals: { user } } as unknown as Parameters<
|
||||
typeof POST
|
||||
>[0]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(cookies.delete).toHaveBeenCalledWith('__Host-ec_session', { path: '/' });
|
||||
expect(auth.readSession(session.token)).toBeNull();
|
||||
expect(auth.readSession(otherDevice.token)).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([undefined, false, true])(
|
||||
'requires explicit opt-in at login and sets the cookie lifetime (rememberMe=%s)',
|
||||
async (rememberMe) => {
|
||||
const now = 2_000_000_000_000;
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
const user = seedUser();
|
||||
const previous = auth.createSession(user.id, true);
|
||||
const { POST } = await import('../../src/routes/api/auth/login/+server.js');
|
||||
const url = new URL('https://ec.test/api/auth/login');
|
||||
const cookies = { get: vi.fn(() => previous.token), set: vi.fn() };
|
||||
const response = await POST({
|
||||
request: new Request(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user.username, password, rememberMe })
|
||||
}),
|
||||
cookies,
|
||||
url,
|
||||
getClientAddress: () => '192.0.2.60'
|
||||
} as unknown as Parameters<typeof POST>[0]);
|
||||
expect(response.status).toBe(200);
|
||||
const [name, token, options] = cookies.set.mock.calls[0];
|
||||
const lifetime = rememberMe ? 30 * 24 * 60 * 60_000 : 12 * 60 * 60_000;
|
||||
expect(name).toBe('__Host-ec_session');
|
||||
expect(options).toEqual({
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
expires: new Date(now + lifetime)
|
||||
});
|
||||
expect(auth.readSession(token)).toMatchObject({
|
||||
remembered: rememberMe === true,
|
||||
expiresAt: now + lifetime
|
||||
});
|
||||
expect(auth.readSession(previous.token)).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['true', 'false', 1, null, {}])(
|
||||
'rejects non-boolean remember-device choices: %j',
|
||||
async (rememberMe) => {
|
||||
const user = seedUser();
|
||||
await expect(
|
||||
auth.authenticatePassword(
|
||||
{ username: user.username, password, rememberMe },
|
||||
'192.0.2.60'
|
||||
)
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(database.db().prepare('SELECT * FROM auth_sessions').all()).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps remembered sessions across inactivity, other logins and restarts, but never beyond 30 days', () => {
|
||||
let now = 2_000_000_000_000;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
const user = seedUser();
|
||||
const remembered = auth.createSession(user.id, true);
|
||||
const short = auth.createSession(user.id);
|
||||
now += 2 * 24 * 60 * 60_000;
|
||||
auth.createSession(user.id); // Expiry cleanup must retain inactive remembered devices.
|
||||
database.closeDb();
|
||||
expect(auth.readSession(short.token)).toBeNull();
|
||||
expect(auth.readSession(remembered.token)).toMatchObject({
|
||||
user: { id: user.id },
|
||||
remembered: true
|
||||
});
|
||||
now = remembered.expiresAt - 1;
|
||||
expect(auth.readSession(remembered.token)?.expiresAt).toBe(remembered.expiresAt);
|
||||
now++;
|
||||
expect(auth.readSession(remembered.token)).toBeNull();
|
||||
auth.createSession(user.id);
|
||||
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({
|
||||
count: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('stores only opaque session hashes, never raw cookies or password hashes in public identities', () => {
|
||||
const user = seedUser();
|
||||
const first = auth.createSession(user.id),
|
||||
@@ -449,6 +548,52 @@ describe('session secrecy and expiration', () => {
|
||||
});
|
||||
|
||||
describe('forced password changes and asynchronous authorization races', () => {
|
||||
it.each([false, true])(
|
||||
'preserves this device choice through a required password change and revokes all old sessions (remembered=%s)',
|
||||
async (remembered) => {
|
||||
let now = 2_000_000_000_000;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
const user = seedUser('newuser', 'viewer', { mustChangePassword: true });
|
||||
const login = await auth.authenticatePassword(
|
||||
{ username: user.username, password, rememberMe: remembered },
|
||||
'192.0.2.61'
|
||||
);
|
||||
const other = auth.createSession(user.id, true);
|
||||
const tokenHash = auth.readSession(login.session.token)!.tokenHash;
|
||||
const { POST } = await import('../../src/routes/api/auth/password/+server.js');
|
||||
const url = new URL('https://ec.test/api/auth/password');
|
||||
const cookies = { set: vi.fn() };
|
||||
const response = await POST({
|
||||
request: new Request(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// The server must preserve the stored policy, ignoring a forged client choice.
|
||||
body: JSON.stringify({
|
||||
currentPassword: password,
|
||||
newPassword: replacement,
|
||||
rememberMe: !remembered
|
||||
})
|
||||
}),
|
||||
cookies,
|
||||
url,
|
||||
locals: { user, sessionHash: tokenHash },
|
||||
getClientAddress: () => '192.0.2.61'
|
||||
} as unknown as Parameters<typeof POST>[0]);
|
||||
expect(response.status).toBe(200);
|
||||
const [, token, options] = cookies.set.mock.calls[0];
|
||||
const lifetime = remembered ? 30 * 24 * 60 * 60_000 : 12 * 60 * 60_000;
|
||||
expect(options.expires).toEqual(new Date(now + lifetime));
|
||||
expect(auth.readSession(login.session.token)).toBeNull();
|
||||
expect(auth.readSession(other.token)).toBeNull();
|
||||
expect(auth.readSession(token)).toMatchObject({
|
||||
remembered,
|
||||
user: { mustChangePassword: false }
|
||||
});
|
||||
now += 24 * 60 * 60_000;
|
||||
expect(!!auth.readSession(token)).toBe(remembered);
|
||||
}
|
||||
);
|
||||
|
||||
it('requires the current password, replaces old sessions atomically and clears the forced-change state', async () => {
|
||||
const user = seedUser('newuser', 'viewer', { mustChangePassword: true });
|
||||
const login = await auth.authenticatePassword(
|
||||
@@ -577,6 +722,138 @@ describe('forced password changes and asynchronous authorization races', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('remembered administrator account changes', () => {
|
||||
it.each([false, true])(
|
||||
'enforces password confirmation in account creation and update endpoints (remembered=%s)',
|
||||
async (remembered) => {
|
||||
const admin = seedUser();
|
||||
const login = auth.createSession(admin.id, remembered);
|
||||
const sessionHash = auth.readSession(login.token)!.tokenHash;
|
||||
const { POST } = await import('../../src/routes/api/users/+server.js');
|
||||
const { PATCH } = await import('../../src/routes/api/users/[id]/+server.js');
|
||||
function event(method: string, body: Record<string, unknown>, id = '') {
|
||||
const url = new URL(`https://ec.test/api/users/${id}`);
|
||||
return {
|
||||
url,
|
||||
request: new Request(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}),
|
||||
locals: { user: admin, sessionHash },
|
||||
params: { id },
|
||||
getClientAddress: () => '192.0.2.62'
|
||||
};
|
||||
}
|
||||
const input = { username: 'newviewer', role: 'viewer', password: replacement };
|
||||
if (remembered) {
|
||||
for (const currentPassword of [undefined, replacement]) {
|
||||
const response = await POST(
|
||||
event('POST', { ...input, currentPassword }) as unknown as Parameters<
|
||||
typeof POST
|
||||
>[0]
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
expect(auth.listUsers()).toHaveLength(1);
|
||||
}
|
||||
}
|
||||
const response = await POST(
|
||||
event('POST', {
|
||||
...input,
|
||||
...(remembered ? { currentPassword: password } : {})
|
||||
}) as unknown as Parameters<typeof POST>[0]
|
||||
);
|
||||
expect(response.status).toBe(201);
|
||||
const { user } = await response.json();
|
||||
const targetSession = auth.createSession(user.id, true);
|
||||
const changes = { role: 'operator', password };
|
||||
if (remembered) {
|
||||
for (const currentPassword of [undefined, replacement]) {
|
||||
const response = await PATCH(
|
||||
event(
|
||||
'PATCH',
|
||||
{ ...changes, currentPassword },
|
||||
user.id
|
||||
) as unknown as Parameters<typeof PATCH>[0]
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
expect(auth.getUser(user.id)?.role).toBe('viewer');
|
||||
expect(auth.readSession(targetSession.token)).not.toBeNull();
|
||||
}
|
||||
}
|
||||
const updated = await PATCH(
|
||||
event(
|
||||
'PATCH',
|
||||
{
|
||||
...changes,
|
||||
...(remembered ? { currentPassword: password } : {})
|
||||
},
|
||||
user.id
|
||||
) as unknown as Parameters<typeof PATCH>[0]
|
||||
);
|
||||
expect(updated.status).toBe(200);
|
||||
expect(auth.getUser(user.id)?.role).toBe('operator');
|
||||
expect(auth.readSession(targetSession.token)).toBeNull();
|
||||
const audit = JSON.stringify(auth.listAuthAudit());
|
||||
expect(audit).not.toContain(replacement);
|
||||
expect(audit).not.toContain('currentPassword');
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects an account change if the remembered session is revoked while confirming the password', async () => {
|
||||
const admin = seedUser(),
|
||||
target = seedUser('target', 'viewer');
|
||||
const session = auth.createSession(admin.id, true);
|
||||
const tokenHash = auth.readSession(session.token)!.tokenHash;
|
||||
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
||||
const verifying = gate<boolean>();
|
||||
vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise);
|
||||
const pending = auth.updateUser(
|
||||
admin.id,
|
||||
target.id,
|
||||
{ role: 'admin', currentPassword: password },
|
||||
{
|
||||
tokenHash,
|
||||
clientAddress: '192.0.2.62'
|
||||
}
|
||||
);
|
||||
auth.revokeSession(session.token);
|
||||
verifying.release(true);
|
||||
await expect(pending).rejects.toMatchObject({ status: 401 });
|
||||
expect(auth.getUser(target.id)?.role).toBe('viewer');
|
||||
});
|
||||
|
||||
it('rechecks the session after hashing a new user password', async () => {
|
||||
const admin = seedUser();
|
||||
const session = auth.createSession(admin.id, true);
|
||||
const tokenHash = auth.readSession(session.token)!.tokenHash;
|
||||
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
||||
vi.spyOn(passwords, 'verifyPassword').mockResolvedValueOnce(true);
|
||||
const hashing = gate<string>();
|
||||
const hash = vi
|
||||
.spyOn(passwords, 'hashPassword')
|
||||
.mockImplementationOnce(() => hashing.promise);
|
||||
const pending = auth.createUser(
|
||||
admin.id,
|
||||
{
|
||||
username: 'newviewer',
|
||||
role: 'viewer',
|
||||
password: replacement,
|
||||
currentPassword: password
|
||||
},
|
||||
{
|
||||
tokenHash,
|
||||
clientAddress: '192.0.2.62'
|
||||
}
|
||||
);
|
||||
await vi.waitFor(() => expect(hash).toHaveBeenCalled());
|
||||
auth.revokeSession(session.token);
|
||||
hashing.release(replacementHash);
|
||||
await expect(pending).rejects.toMatchObject({ status: 401 });
|
||||
expect(auth.listUsers()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistent authentication throttling', () => {
|
||||
it.each(['admin', 'super_viewer'] as const)(
|
||||
'does not consume login or password-change limits for an enabled %s',
|
||||
@@ -638,7 +915,10 @@ describe('persistent authentication throttling', () => {
|
||||
body: JSON.stringify({ currentPassword: password, newPassword: replacement })
|
||||
}),
|
||||
cookies: { set: vi.fn() },
|
||||
locals: { user: login.user },
|
||||
locals: {
|
||||
user: login.user,
|
||||
sessionHash: auth.readSession(login.session.token)!.tokenHash
|
||||
},
|
||||
url,
|
||||
getClientAddress: () => address
|
||||
} as unknown as Parameters<typeof POST>[0]);
|
||||
|
||||
Reference in New Issue
Block a user