Allow public leaderboard API reads
This commit is contained in:
@@ -4,6 +4,12 @@ Electric Cherry requires a user session for operator pages and management APIs.
|
|||||||
HTTPS protects the connection; server-side role checks protect each operation.
|
HTTPS protects the connection; server-side role checks protect each operation.
|
||||||
There is no anonymous management mode or public registration.
|
There is no anonymous management mode or public registration.
|
||||||
|
|
||||||
|
Leaderboard API reads (`GET` and `HEAD /api/leaderboard`) are public and need no
|
||||||
|
session or credentials. This exception covers only that exact API route;
|
||||||
|
leaderboard/profile pages, match-history routes and management operations retain
|
||||||
|
their access checks. HTTPS requirements still apply. See the
|
||||||
|
[leaderboard API reference](leaderboard.md#read-api) for requests and response fields.
|
||||||
|
|
||||||
## First administrator and upgrades
|
## First administrator and upgrades
|
||||||
|
|
||||||
1. Generate a setup secret: `openssl rand -hex 32`.
|
1. Generate a setup secret: `openssl rand -hex 32`.
|
||||||
|
|||||||
+17
-34
@@ -157,35 +157,18 @@ sources are the [HTTP handler](../src/routes/api/leaderboard/+server.ts),
|
|||||||
|
|
||||||
### Authentication and requests
|
### Authentication and requests
|
||||||
|
|
||||||
`GET /api/leaderboard` is a session-authenticated JSON endpoint. A **viewer**,
|
`GET /api/leaderboard` is a public JSON endpoint; `HEAD` is public too. A tool
|
||||||
operator or administrator account can read it after any required temporary
|
does not need an account, session cookie, API key or authorization header to
|
||||||
password change. There is no API-key or bearer-token authentication for this
|
read it. Reads also work before administrator setup and with an expired session
|
||||||
endpoint; telemetry and host-agent credentials do not grant access.
|
or a session awaiting a password change. This exception applies only to reads
|
||||||
|
of this exact API route. Leaderboard/profile HTML pages, match-history APIs and
|
||||||
|
management routes still require a session; see [Users and access](authentication.md).
|
||||||
|
|
||||||
A tool can sign in with `POST /api/auth/login`, sending a JSON object with
|
For example:
|
||||||
`username` and `password`, `Content-Type: application/json`, and an `Origin`
|
|
||||||
header exactly matching the instance's origin (scheme, hostname and port, with
|
|
||||||
no trailing slash). Preserve the returned `__Host-ec_session` cookie and send it
|
|
||||||
on subsequent requests. Login returns `{ "user": ... }`; the session token is
|
|
||||||
in `Set-Cookie`, not the JSON body. Sessions expire after 12 hours or 30 minutes
|
|
||||||
of inactivity, and can be revoked earlier. Handle `401` by signing in again.
|
|
||||||
See [Users and access](authentication.md) for setup and session rules.
|
|
||||||
|
|
||||||
For example, with an existing account's credentials in a private `login.json`
|
|
||||||
file containing `{"username":"reader","password":"..."}`:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
EC_BASE_URL='https://ec.example.com'
|
EC_BASE_URL='https://ec.example.com'
|
||||||
umask 077
|
|
||||||
curl --fail-with-body --silent --show-error \
|
curl --fail-with-body --silent --show-error \
|
||||||
--cookie-jar ec-cookies.txt \
|
|
||||||
--header "Origin: $EC_BASE_URL" \
|
|
||||||
--header 'Content-Type: application/json' \
|
|
||||||
--data-binary @login.json \
|
|
||||||
"$EC_BASE_URL/api/auth/login"
|
|
||||||
|
|
||||||
curl --fail-with-body --silent --show-error \
|
|
||||||
--cookie ec-cookies.txt \
|
|
||||||
--get "$EC_BASE_URL/api/leaderboard" \
|
--get "$EC_BASE_URL/api/leaderboard" \
|
||||||
--data-urlencode 'game=t6' \
|
--data-urlencode 'game=t6' \
|
||||||
--data-urlencode 'metric=kills' \
|
--data-urlencode 'metric=kills' \
|
||||||
@@ -194,8 +177,9 @@ curl --fail-with-body --silent --show-error \
|
|||||||
```
|
```
|
||||||
|
|
||||||
HTTPS is required. Local HTTP development requires `EC_AUTH_ALLOW_HTTP=true`
|
HTTPS is required. Local HTTP development requires `EC_AUTH_ALLOW_HTTP=true`
|
||||||
and a loopback hostname, and uses the separate `ec_session_dev` cookie. The
|
and a loopback hostname. The read request does not require an `Origin` header.
|
||||||
read request does not require an `Origin` header. Successful responses use
|
This endpoint does not add cross-origin browser access (CORS) headers; the
|
||||||
|
example above works for command-line or server-side clients. Successful responses use
|
||||||
`Content-Type: application/json`, `Cache-Control: no-store` and `Vary: Cookie`.
|
`Content-Type: application/json`, `Cache-Control: no-store` and `Vary: Cookie`.
|
||||||
|
|
||||||
### Query parameters
|
### Query parameters
|
||||||
@@ -308,14 +292,13 @@ strings such as `2026-10-05T12:00:00.000Z`, not reporter gameplay-clock times.
|
|||||||
### Errors, pagination and freshness
|
### Errors, pagination and freshness
|
||||||
|
|
||||||
Invalid query values return HTTP `400` with
|
Invalid query values return HTTP `400` with
|
||||||
`{ "ok": false, "kind": "bad-request", "error": "..." }`. Authentication errors
|
`{ "ok": false, "kind": "bad-request", "error": "..." }`. Public reads do not
|
||||||
have a different shape: HTTP `401` returns `{ "error": "...", "setupRequired":
|
return `401` for missing/expired sessions or `403` for a required password change.
|
||||||
false }` (`true` before initial administrator setup); a required password change
|
Sending host/enrollment bearer credentials still returns `403` because those
|
||||||
returns HTTP `403` with `{ "error": "...", "mustChangePassword": true }`.
|
credentials are restricted to the host-agent API; omit them. Disallowed HTTP transport
|
||||||
Host/enrollment bearer credentials return `403`. Disallowed HTTP transport
|
|
||||||
returns `426` with an `error` string. Report-service failures can produce `5xx`;
|
returns `426` with an `error` string. Report-service failures can produce `5xx`;
|
||||||
check status and content type before assuming the success schema or a uniform
|
check status and content type before assuming the success schema or a uniform
|
||||||
error body. Failed/rate-limited login requests use their own auth error responses.
|
error body.
|
||||||
|
|
||||||
For successive pages, retain the same filters and increase `offset` by `limit`
|
For successive pages, retain the same filters and increase `offset` by `limit`
|
||||||
until `offset + entries.length >= total` or the page is empty. There is no cursor,
|
until `offset + entries.length >= total` or the page is empty. There is no cursor,
|
||||||
@@ -335,8 +318,8 @@ an identity rename can change results without changing those timestamps.
|
|||||||
### Related routes and current limits
|
### Related routes and current limits
|
||||||
|
|
||||||
`GET /api/matches` and `GET /api/servers/{name}/matches` return JSON match
|
`GET /api/matches` and `GET /api/servers/{name}/matches` return JSON match
|
||||||
summaries as `{ "matches": [...], "total": number }`, under the same session
|
summaries as `{ "matches": [...], "total": number }`. These routes still require
|
||||||
authentication. Both accept only `limit` (default 25, range 1–10,000); there is
|
session authentication. Both accept only `limit` (default 25, range 1–10,000); there is
|
||||||
no offset pagination. Results are newest first by first observation, then match
|
no offset pagination. Results are newest first by first observation, then match
|
||||||
ID, and include active/interrupted matches that reached round 2. Their
|
ID, and include active/interrupted matches that reached round 2. Their
|
||||||
[ServerMatch fields](../src/lib/match-history.ts) differ from leaderboard entries
|
[ServerMatch fields](../src/lib/match-history.ts) differ from leaderboard entries
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
|||||||
|
|
||||||
if (within(routeId, '/api/agent')) return 'machine';
|
if (within(routeId, '/api/agent')) return 'machine';
|
||||||
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
|
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
|
||||||
|
if (routeId === '/api/leaderboard' && reading) return 'public';
|
||||||
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
|
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
|
||||||
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST') return 'public';
|
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST') return 'public';
|
||||||
if (routeId === '/account' || ['/api/auth/me', '/api/auth/logout', '/api/auth/password'].includes(routeId)) return 'authenticated';
|
if (routeId === '/account' || ['/api/auth/me', '/api/auth/logout', '/api/auth/password'].includes(routeId)) return 'authenticated';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const allows = (role: UserRole, route: string | null, method = 'GET') => roleAll
|
|||||||
describe('management route permissions', () => {
|
describe('management route permissions', () => {
|
||||||
it.each([
|
it.each([
|
||||||
'/', '/api/fleet', '/capacity', '/api/capacity', '/api/servers', '/servers/[name]', '/api/servers/[name]/scripts',
|
'/', '/api/fleet', '/capacity', '/api/capacity', '/api/servers', '/servers/[name]', '/api/servers/[name]/scripts',
|
||||||
'/api/metrics', '/metrics', '/api/matches', '/matches/[id]', '/api/leaderboard',
|
'/api/metrics', '/metrics', '/api/matches', '/matches/[id]',
|
||||||
'/leaderboard/players/[game]/[guid]', '/api/campaigns/[id]', '/campaigns/[id]',
|
'/leaderboard/players/[game]/[guid]', '/api/campaigns/[id]', '/campaigns/[id]',
|
||||||
'/api/provisioning/[id]', '/provisioning/[id]', '/api/provisioning/[id]/events',
|
'/api/provisioning/[id]', '/provisioning/[id]', '/api/provisioning/[id]/events',
|
||||||
'/api/betas/[id]', '/api/scripts/[id]', '/api/configs', '/api/plugins', '/api/hosts/[id]',
|
'/api/betas/[id]', '/api/scripts/[id]', '/api/configs', '/api/plugins', '/api/hosts/[id]',
|
||||||
@@ -72,6 +72,19 @@ describe('management route permissions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('authentication and machine boundaries', () => {
|
describe('authentication and machine boundaries', () => {
|
||||||
|
it('makes only leaderboard API reads public', () => {
|
||||||
|
for (const method of ['GET', 'HEAD']) {
|
||||||
|
expect(routePermission('/api/leaderboard', method)).toBe('public');
|
||||||
|
for (const route of ['/leaderboard', '/leaderboard/players/[guid]',
|
||||||
|
'/leaderboard/players/[game]/[guid]', '/api/leaderboard/players/[guid]',
|
||||||
|
'/api/leaderboard-extra', '/api/matches', '/api/servers/[name]/matches', '/matches/[id]']) {
|
||||||
|
expect(routePermission(route, method)).toBe('viewer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
|
||||||
|
expect(routePermission('/api/leaderboard', method)).toBe('operator');
|
||||||
|
}
|
||||||
|
});
|
||||||
it('makes only the intended login/bootstrap methods public', () => {
|
it('makes only the intended login/bootstrap methods public', () => {
|
||||||
for (const route of ['/login', '/setup']) {
|
for (const route of ['/login', '/setup']) {
|
||||||
expect(routePermission(route, 'GET')).toBe('public');
|
expect(routePermission(route, 'GET')).toBe('public');
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function signIn(role: UserRole = 'viewer', mustChangePassword = false): Session
|
|||||||
}
|
}
|
||||||
type Resolve = Parameters<Handle>[0]['resolve'];
|
type Resolve = Parameters<Handle>[0]['resolve'];
|
||||||
function requestFor(routeId: string | null, options: {
|
function requestFor(routeId: string | null, options: {
|
||||||
method?: string; path?: string; base?: string; origin?: string | null; headers?: HeadersInit;
|
method?: string; path?: string; base?: string; origin?: string | null; headers?: HeadersInit; cookie?: string | null;
|
||||||
body?: unknown; rawBody?: BodyInit; signal?: AbortSignal; resolve?: Resolve;
|
body?: unknown; rawBody?: BodyInit; signal?: AbortSignal; resolve?: Resolve;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const url = new URL(options.path ?? routeId ?? '/not-found', options.base ?? 'https://ec.test');
|
const url = new URL(options.path ?? routeId ?? '/not-found', options.base ?? 'https://ec.test');
|
||||||
@@ -32,7 +32,7 @@ function requestFor(routeId: string | null, options: {
|
|||||||
if (!['GET', 'HEAD'].includes(method) && options.origin !== null) headers.set('Origin', options.origin ?? url.origin);
|
if (!['GET', 'HEAD'].includes(method) && options.origin !== null) headers.set('Origin', options.origin ?? url.origin);
|
||||||
if (options.body !== undefined && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
if (options.body !== undefined && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
||||||
const request = new Request(url, { method, headers, body: options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)), signal: options.signal });
|
const request = new Request(url, { method, headers, body: options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)), signal: options.signal });
|
||||||
const cookies = { get: vi.fn(() => token) };
|
const cookies = { get: vi.fn(() => options.cookie === null ? undefined : options.cookie ?? token) };
|
||||||
const event = { request, url, route: { id: routeId }, locals: { user: null, sessionHash: null }, cookies } as unknown as RequestEvent;
|
const event = { request, url, route: { id: routeId }, locals: { user: null, sessionHash: null }, cookies } as unknown as RequestEvent;
|
||||||
const resolve = vi.fn(options.resolve ?? (async () => new Response('resolved', { headers: { 'Content-Type': 'text/plain' } })));
|
const resolve = vi.fn(options.resolve ?? (async () => new Response('resolved', { headers: { 'Content-Type': 'text/plain' } })));
|
||||||
return { event, resolve, cookies, invoke: () => authenticationHandle({ event, resolve } as Parameters<Handle>[0]) };
|
return { event, resolve, cookies, invoke: () => authenticationHandle({ event, resolve } as Parameters<Handle>[0]) };
|
||||||
@@ -47,6 +47,49 @@ beforeEach(() => {
|
|||||||
afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); });
|
afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); });
|
||||||
|
|
||||||
describe('browser authentication guard', () => {
|
describe('browser authentication guard', () => {
|
||||||
|
it.each(['GET', 'HEAD'])('allows anonymous leaderboard %s requests even before account setup', async method => {
|
||||||
|
for (const setup of [false, true]) {
|
||||||
|
auth.setup = setup;
|
||||||
|
const call = requestFor('/api/leaderboard', { method, path: '/api/leaderboard?game=t6&metric=kills', origin: null, cookie: null });
|
||||||
|
const response = await call.invoke();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(call.resolve).toHaveBeenCalledOnce();
|
||||||
|
expect(call.event.locals.user).toBeNull();
|
||||||
|
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||||
|
expect(response.headers.get('vary')).toContain('Cookie');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('allows leaderboard reads when a supplied session has expired', async () => {
|
||||||
|
const call = requestFor('/api/leaderboard', { cookie: token });
|
||||||
|
expect((await call.invoke()).status).toBe(200);
|
||||||
|
expect(auth.readSession).toHaveBeenCalledWith(token);
|
||||||
|
expect(call.event.locals.user).toBeNull();
|
||||||
|
expect(call.resolve).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
it('allows public leaderboard reads with a session that requires a password change', async () => {
|
||||||
|
signIn('viewer', true);
|
||||||
|
const call = requestFor('/api/leaderboard');
|
||||||
|
expect((await call.invoke()).status).toBe(200);
|
||||||
|
expect(call.resolve).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
it('keeps anonymous leaderboard writes and related API reads protected', async () => {
|
||||||
|
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
|
||||||
|
const call = requestFor('/api/leaderboard', { method });
|
||||||
|
expect((await call.invoke()).status).toBe(401);
|
||||||
|
expect(call.resolve).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
for (const route of ['/api/leaderboard/players/[guid]', '/api/leaderboard-extra',
|
||||||
|
'/api/matches', '/api/servers/[name]/matches', '/api/fleet']) {
|
||||||
|
const call = requestFor(route);
|
||||||
|
expect((await call.invoke()).status).toBe(401);
|
||||||
|
expect(call.resolve).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
for (const route of ['/leaderboard', '/leaderboard/players/[guid]', '/matches/[id]']) {
|
||||||
|
const call = requestFor(route);
|
||||||
|
await expect(call.invoke()).rejects.toMatchObject({ status: 303 });
|
||||||
|
expect(call.resolve).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
it('redirects anonymous pages to login while APIs return JSON 401 without resolving', async () => {
|
it('redirects anonymous pages to login while APIs return JSON 401 without resolving', async () => {
|
||||||
const page = requestFor('/metrics', { path: '/metrics?days=7' });
|
const page = requestFor('/metrics', { path: '/metrics?days=7' });
|
||||||
await expect(page.invoke()).rejects.toMatchObject({ status: 303, location: '/login?next=%2Fmetrics%3Fdays%3D7' });
|
await expect(page.invoke()).rejects.toMatchObject({ status: 303, location: '/login?next=%2Fmetrics%3Fdays%3D7' });
|
||||||
|
|||||||
Reference in New Issue
Block a user