Phase 19: Local Auth (No-OIDC Mode) #23
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* localAuthMiddleware.ts — local-session cookie → c.set('user') middleware (AUTH-LOCAL-04).
|
||||||
|
*
|
||||||
|
* Reads the `local-session` cookie, validates the JWT, fetches the users row,
|
||||||
|
* and populates `c.get('user')` with the same shape as devAuthBypass so that all
|
||||||
|
* downstream routes work unchanged.
|
||||||
|
*
|
||||||
|
* Mount in index.ts AFTER devAuthBypass() and BEFORE the OIDC guard:
|
||||||
|
* app.use('/api/*', devAuthBypass());
|
||||||
|
* app.use('/api/*', localAuthMiddleware()); ← HERE
|
||||||
|
* if (!devBypassActive) {
|
||||||
|
* app.use('/api/*', oidcAuthMiddleware()); ← skip if c.get('user') set
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Security contract:
|
||||||
|
* - If c.get('user') is already set (devAuthBypass ran first): no-op passthrough.
|
||||||
|
* This is Test 4 — dev user is not overwritten.
|
||||||
|
* - If no 'local-session' cookie is present: pure passthrough WITHOUT calling
|
||||||
|
* c.set('user', undefined). The OIDC guard fires on falsy c.get('user') only when
|
||||||
|
* the value was never set — calling c.set('user', undefined) would suppress it.
|
||||||
|
* This is Pitfall-1 / Test 2.
|
||||||
|
* - If cookie is valid but the users row is gone: passthrough (no crash).
|
||||||
|
* This is Test 3.
|
||||||
|
*
|
||||||
|
* ContextVariableMap: the `user` shape is declared in devBypass.ts. Import it as a
|
||||||
|
* side-effect so c.set('user', ...) is typed correctly throughout this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Side-effect import: extends ContextVariableMap with the `user` key (Pitfall — shared shape).
|
||||||
|
import './devBypass.js';
|
||||||
|
import type { MiddlewareHandler } from 'hono';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { users } from '../db/schema.js';
|
||||||
|
import { verifyLocalSessionCookie } from './localSession.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Hono MiddlewareHandler that:
|
||||||
|
* 1. Checks if c.get('user') is already set — no-op if so (devAuthBypass-first).
|
||||||
|
* 2. Calls verifyLocalSessionCookie(c) — returns null if no cookie / invalid / expired.
|
||||||
|
* 3. On null: calls next() WITHOUT setting user (pure passthrough — OIDC guard can fire).
|
||||||
|
* 4. On valid userId: SELECTs the users row; if found, c.set('user', {...}); always next().
|
||||||
|
*/
|
||||||
|
export function localAuthMiddleware(): MiddlewareHandler {
|
||||||
|
return async (c, next) => {
|
||||||
|
// If a prior middleware (devAuthBypass) already set the user, do not overwrite.
|
||||||
|
if (c.get('user')) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the local-session JWT cookie — returns userId or null.
|
||||||
|
// verifyLocalSessionCookie returns null (never throws) on any error (Pitfall 9 guard).
|
||||||
|
const userId = await verifyLocalSessionCookie(c);
|
||||||
|
|
||||||
|
if (userId === null) {
|
||||||
|
// No valid local session — pass through WITHOUT setting c.get('user').
|
||||||
|
// CRITICAL: Do NOT call c.set('user', undefined) — that sets the key to undefined
|
||||||
|
// which is falsy but "set", breaking the OIDC guard's c.get('user') check (Pitfall 1).
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the users row to populate the same shape as DEV_USER.
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
oidcIss: users.oidcIss,
|
||||||
|
oidcSub: users.oidcSub,
|
||||||
|
displayName: users.displayName,
|
||||||
|
color: users.color,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
// userId from JWT but no users row (deleted user) — passthrough without setting user.
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate c.get('user') with the same shape as DEV_USER (devBypass.ts ContextVariableMap).
|
||||||
|
// oidcIss/oidcSub: local users have nullable oidcIss/oidcSub — use fallback strings so the
|
||||||
|
// shape matches typeof DEV_USER (all required fields, no undefined in the value object).
|
||||||
|
c.set('user', {
|
||||||
|
id: row.id,
|
||||||
|
oidcIss: row.oidcIss ?? 'local',
|
||||||
|
oidcSub: row.oidcSub ?? String(row.id),
|
||||||
|
displayName: row.displayName ?? null,
|
||||||
|
color: row.color ?? '#4A90D9',
|
||||||
|
});
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
+25
-2
@@ -11,12 +11,15 @@ import { listsRouter, listItemsRouter } from './routes/lists.js';
|
|||||||
import { pushRouter } from './routes/push.js';
|
import { pushRouter } from './routes/push.js';
|
||||||
import { adminRouter } from './routes/admin.js';
|
import { adminRouter } from './routes/admin.js';
|
||||||
import { setupRouter } from './routes/setup.js';
|
import { setupRouter } from './routes/setup.js';
|
||||||
|
import { authModeRouter } from './routes/authMode.js';
|
||||||
|
import { localAuthRouter } from './routes/localAuth.js';
|
||||||
import {
|
import {
|
||||||
oidcAuthMiddleware,
|
oidcAuthMiddleware,
|
||||||
processOAuthCallback,
|
processOAuthCallback,
|
||||||
oidcConfigFallbackMiddleware,
|
oidcConfigFallbackMiddleware,
|
||||||
} from './auth/middleware.js';
|
} from './auth/middleware.js';
|
||||||
import { devAuthBypass } from './auth/devBypass.js';
|
import { devAuthBypass } from './auth/devBypass.js';
|
||||||
|
import { localAuthMiddleware } from './auth/localAuthMiddleware.js';
|
||||||
import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
||||||
import { startBrokerPoller } from './broker/poller.js';
|
import { startBrokerPoller } from './broker/poller.js';
|
||||||
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
|
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
|
||||||
@@ -48,15 +51,26 @@ app.route('/health', healthRouter);
|
|||||||
// 423 lock after setup is complete (SETUP-04 / D-10).
|
// 423 lock after setup is complete (SETUP-04 / D-10).
|
||||||
app.route('/api/setup', setupRouter);
|
app.route('/api/setup', setupRouter);
|
||||||
|
|
||||||
|
// Phase 19 — pre-auth auth routes: GET /api/auth/mode and POST /api/auth/local/login, /logout.
|
||||||
|
// Mounted BEFORE devAuthBypass so they are reachable without a session (D-01 / AUTH-LOCAL-05).
|
||||||
|
app.route('/api/auth', authModeRouter);
|
||||||
|
app.route('/api/auth', localAuthRouter);
|
||||||
|
|
||||||
// Dev-auth bypass — no-op passthrough unless DEV_AUTH_BYPASS=true AND NODE_ENV!='production'.
|
// Dev-auth bypass — no-op passthrough unless DEV_AUTH_BYPASS=true AND NODE_ENV!='production'.
|
||||||
// When active, injects DEV_USER into the Hono context and the OIDC guard is NOT mounted.
|
// When active, injects DEV_USER into the Hono context and the OIDC guard is NOT mounted.
|
||||||
// Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts).
|
// Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts).
|
||||||
app.use('/api/*', devAuthBypass());
|
app.use('/api/*', devAuthBypass());
|
||||||
|
|
||||||
|
// Phase 19 — local-session middleware: sets c.get('user') from 'local-session' JWT cookie.
|
||||||
|
// No-op passthrough when no cookie is present — the OIDC guard fires for unauthenticated.
|
||||||
|
// Runs AFTER devAuthBypass (which may set c.get('user') first) and BEFORE the OIDC guard.
|
||||||
|
// The OIDC guard below is wrapped to skip when c.get('user') is already set (Pitfall 1 guard).
|
||||||
|
app.use('/api/*', localAuthMiddleware());
|
||||||
|
|
||||||
// Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05).
|
// Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05).
|
||||||
// Skipped entirely when devBypassActive so that local dev works without Authelia.
|
// Skipped entirely when devBypassActive so that local dev works without Authelia.
|
||||||
// In production devBypassActive is always false — OIDC is unconditionally mounted.
|
// In production devBypassActive is always false — OIDC is unconditionally mounted.
|
||||||
// Unauthenticated requests receive a 302 redirect to Authelia's authorize endpoint.
|
// Unauthenticated requests receive a 302 redirect to the OIDC authorize endpoint.
|
||||||
// OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct
|
// OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct
|
||||||
// redirect_uri (Pitfall 1). Set it to https://familysync.<domain>.
|
// redirect_uri (Pitfall 1). Set it to https://familysync.<domain>.
|
||||||
if (!devBypassActive) {
|
if (!devBypassActive) {
|
||||||
@@ -67,7 +81,16 @@ if (!devBypassActive) {
|
|||||||
// A2 CONFIRMED: oidcAuthMiddleware() reads process.env per-request (call time), so
|
// A2 CONFIRMED: oidcAuthMiddleware() reads process.env per-request (call time), so
|
||||||
// injecting into process.env here is safe and effective (see auth/middleware.ts A2 note).
|
// injecting into process.env here is safe and effective (see auth/middleware.ts A2 note).
|
||||||
app.use('/api/*', oidcConfigFallbackMiddleware);
|
app.use('/api/*', oidcConfigFallbackMiddleware);
|
||||||
app.use('/api/*', oidcAuthMiddleware());
|
// Phase 19 / D-03: OIDC guard wrapped to skip when c.get('user') is already set.
|
||||||
|
// A valid local-session (or dev-bypass) user must NOT be 302-redirected to the OIDC
|
||||||
|
// provider — the skip-when-set wrapper is the coexistence seam (D-03 / RESEARCH Pitfall 1).
|
||||||
|
app.use('/api/*', async (c, next) => {
|
||||||
|
if (c.get('user')) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await oidcAuthMiddleware()(c, next);
|
||||||
|
});
|
||||||
// Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02).
|
// Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02).
|
||||||
app.use('/api/*', persistSessionCookie());
|
app.use('/api/*', persistSessionCookie());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* authMode.ts — GET /api/auth/mode pre-auth endpoint (AUTH-LOCAL-05).
|
||||||
|
*
|
||||||
|
* Returns { localEnabled: boolean, oidcEnabled: boolean } reflecting the current
|
||||||
|
* authentication configuration. This endpoint is intentionally reachable before
|
||||||
|
* authentication (same pre-auth pattern as GET /api/setup/status).
|
||||||
|
*
|
||||||
|
* Mount in index.ts BEFORE devAuthBypass and OIDC guard:
|
||||||
|
* app.route('/api/auth', authModeRouter); ← pre-auth
|
||||||
|
*
|
||||||
|
* Response contract:
|
||||||
|
* - localEnabled: always true — local auth is the default, always available (D-01).
|
||||||
|
* - oidcEnabled: true when OIDC_ISSUER env var is set, OR when app_config has
|
||||||
|
* an oidc_issuer row (supports wizard-configured OIDC before container restart).
|
||||||
|
*
|
||||||
|
* No auth gate, no isSetupLocked() check — the PWA fetches this on app load before
|
||||||
|
* knowing if the user is authenticated.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { appConfig } from '../db/schema.js';
|
||||||
|
|
||||||
|
export const authModeRouter = new Hono();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET / (mounted as /api/auth, so effective path is GET /api/auth/mode)
|
||||||
|
*
|
||||||
|
* Checks OIDC_ISSUER env var first (process.env is cheapest); falls back to
|
||||||
|
* app_config DB row if env var is absent (wizard-configured OIDC).
|
||||||
|
*/
|
||||||
|
authModeRouter.get('/mode', async (c) => {
|
||||||
|
// localEnabled: always true (D-01)
|
||||||
|
// oidcEnabled: env var first, then app_config fallback
|
||||||
|
const issuerFromEnv = process.env.OIDC_ISSUER;
|
||||||
|
let oidcEnabled = Boolean(issuerFromEnv);
|
||||||
|
|
||||||
|
if (!oidcEnabled) {
|
||||||
|
// Check app_config for oidc_issuer (wizard-written, pre-restart-fallback pattern from middleware.ts)
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'oidc_issuer'))
|
||||||
|
.limit(1);
|
||||||
|
oidcEnabled = Boolean(row?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ localEnabled: true, oidcEnabled });
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* localAuth.ts — local authentication routes (AUTH-LOCAL-03, AUTH-LOCAL-06).
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* POST /api/auth/local/login — rate-limited credential verify + session cookie issue
|
||||||
|
* POST /api/auth/local/logout — clear local-session cookie
|
||||||
|
* GET /api/auth/local/logout — alias for POST /logout (some browsers prefer GET)
|
||||||
|
*
|
||||||
|
* Mounted in index.ts as app.route('/api/auth', localAuthRouter) BEFORE any auth middleware.
|
||||||
|
* This means POST /api/auth/local/login is reachable without a session (pre-auth, per D-01).
|
||||||
|
*
|
||||||
|
* Security (T-19-11, T-19-12, T-19-14):
|
||||||
|
* - noEchoHook on login: Zod errors NEVER return received values (T-19-14 / Pitfall 7).
|
||||||
|
* - Dummy-hash timing defense: verifyPassword is always called, even for unknown usernames,
|
||||||
|
* to prevent timing-oracle username enumeration attacks (T-19-12 / RESEARCH Pitfall 2).
|
||||||
|
* - Same 401 body for wrong password AND unknown username — no field discrimination (T-19-12).
|
||||||
|
* - Per-IP in-memory rate-limiting: 5 failures → 429; 10 failures → 423 (T-19-11).
|
||||||
|
* - Lockout (423) cleared only by admin password reset — no self-service unlock.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { Context } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { localCredentials } from '../db/schema.js';
|
||||||
|
import { verifyPassword, hashPassword } from '../auth/localCredentials.js';
|
||||||
|
import { issueLocalSessionCookie, clearLocalSessionCookie } from '../auth/localSession.js';
|
||||||
|
|
||||||
|
export const localAuthRouter = new Hono();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// noEchoHook — NEVER return Zod validation error details for the login route.
|
||||||
|
// Zod's error object contains issues[].received which may echo the submitted password.
|
||||||
|
// Always return { error: 'Invalid request' } 400, no other fields (T-19-14).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const noEchoHook = (result: { success: boolean }, c: Context) => {
|
||||||
|
if (!result.success) {
|
||||||
|
return c.json({ error: 'Invalid request' }, 400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zod schema for login body
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
username: z.string().min(1).max(128).trim(),
|
||||||
|
password: z.string().min(1).max(1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-IP rate-limiting state (in-memory Map — household scale, no Redis needed).
|
||||||
|
//
|
||||||
|
// State shape per IP: { count, lockedUntil (epoch ms), lockedOut (bool) }
|
||||||
|
// count >= RATE_WINDOW_FAILURES AND Date.now() < lockedUntil → 429
|
||||||
|
// count >= LOCKOUT_FAILURES → 423 (permanent until admin reset)
|
||||||
|
// Success → delete entry (clears counter)
|
||||||
|
//
|
||||||
|
// RATE_WINDOW_FAILURES: 5 failures → 60s cooldown (429)
|
||||||
|
// LOCKOUT_FAILURES: 10 failures → account locked (423)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const loginAttempts = new Map<string, { count: number; lockedUntil: number; lockedOut: boolean }>();
|
||||||
|
|
||||||
|
const RATE_WINDOW_FAILURES = 5;
|
||||||
|
const RATE_WINDOW_SECS = 60;
|
||||||
|
const LOCKOUT_FAILURES = 10;
|
||||||
|
|
||||||
|
// Pre-computed dummy hash used to run verifyPassword on unknown-username paths
|
||||||
|
// (timing defense — prevents timing-oracle username enumeration, RESEARCH Pitfall 2).
|
||||||
|
// Computed once at module load time; the actual value is never used for auth.
|
||||||
|
const DUMMY_HASH = hashPassword('dummy-constant-time-filler-xyzzy');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /local/login → POST /api/auth/local/login
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook), async (c) => {
|
||||||
|
// Derive client IP from Pangolin-set X-Forwarded-For header; fall back to host header.
|
||||||
|
const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim()
|
||||||
|
?? c.req.raw.headers.get('host')
|
||||||
|
?? 'unknown';
|
||||||
|
|
||||||
|
const attempt = loginAttempts.get(ip);
|
||||||
|
|
||||||
|
// 423: account locked (>= LOCKOUT_FAILURES total failures, admin must reset)
|
||||||
|
if (attempt?.lockedOut) {
|
||||||
|
return c.json({ error: 'Account locked' }, 423);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 429: rate window — >= RATE_WINDOW_FAILURES failures within the cooldown window
|
||||||
|
if (attempt && attempt.count >= RATE_WINDOW_FAILURES && Date.now() < attempt.lockedUntil) {
|
||||||
|
return c.json({ error: 'Too many attempts' }, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password } = c.req.valid('json');
|
||||||
|
|
||||||
|
// Look up local_credentials by username
|
||||||
|
let cred: { userId: number; passwordHash: string } | undefined;
|
||||||
|
try {
|
||||||
|
const [found] = await db
|
||||||
|
.select({ userId: localCredentials.userId, passwordHash: localCredentials.passwordHash })
|
||||||
|
.from(localCredentials)
|
||||||
|
.where(eq(localCredentials.username, username))
|
||||||
|
.limit(1);
|
||||||
|
cred = found;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[localAuth/POST /local/login] DB error:', err instanceof Error ? err.message : String(err));
|
||||||
|
return c.json({ error: 'Service unavailable' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ALWAYS run verifyPassword — even for unknown usernames — to prevent timing-oracle
|
||||||
|
// username enumeration (T-19-12 / RESEARCH Pitfall 2). Use a pre-computed dummy hash
|
||||||
|
// so the scrypt work is always performed regardless of whether username was found.
|
||||||
|
const valid = cred
|
||||||
|
? verifyPassword(cred.passwordHash, password)
|
||||||
|
: verifyPassword(DUMMY_HASH, password);
|
||||||
|
|
||||||
|
if (!valid || !cred) {
|
||||||
|
// Increment failure counter
|
||||||
|
const cur = loginAttempts.get(ip) ?? { count: 0, lockedUntil: 0, lockedOut: false };
|
||||||
|
cur.count += 1;
|
||||||
|
cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000;
|
||||||
|
cur.lockedOut = cur.count >= LOCKOUT_FAILURES;
|
||||||
|
loginAttempts.set(ip, cur);
|
||||||
|
// Same 401 body for wrong password AND unknown username — no field discrimination (T-19-12)
|
||||||
|
return c.json({ error: 'Invalid credentials' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: clear failure counter, issue the local-session JWT cookie, return ok
|
||||||
|
loginAttempts.delete(ip);
|
||||||
|
try {
|
||||||
|
await issueLocalSessionCookie(c, cred.userId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[localAuth/POST /local/login] Cookie issue error:', err instanceof Error ? err.message : String(err));
|
||||||
|
return c.json({ error: 'Service unavailable' }, 503);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /local/logout → POST /api/auth/local/logout
|
||||||
|
// GET /local/logout → GET /api/auth/local/logout (browser-redirect alias)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function handleLogout(c: Context) {
|
||||||
|
clearLocalSessionCookie(c);
|
||||||
|
return c.json({ ok: true }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
localAuthRouter.post('/local/logout', handleLogout);
|
||||||
|
localAuthRouter.get('/local/logout', handleLogout);
|
||||||
Reference in New Issue
Block a user