feat(19-02): self-change password and hasLocalCredential on GET /api/me
- POST /api/me/password: verifyPassword(current) gate before hashPassword(new) update - 401 on wrong current password, 404 if no local_credentials row, 200 on success - meNoEchoHook on /password route (T-19-06, never echo submitted password) - resolveAdminAndSetupStatus extended with hasLocalCredential (AUTH-LOCAL-17) - GET /api/me response includes hasLocalCredential alongside isAdmin/needsProviderSetup
This commit is contained in:
@@ -7,15 +7,15 @@
|
|||||||
* 2. Derives displayName from OIDC claims (name → preferred_username → email → sub fallback)
|
* 2. Derives displayName from OIDC claims (name → preferred_username → email → sub fallback)
|
||||||
* then calls upsertUser(iss, sub, displayName) — writes on first visit, corrects a
|
* then calls upsertUser(iss, sub, displayName) — writes on first visit, corrects a
|
||||||
* previously blank displayName on subsequent visits (idempotent, keyed on iss+sub, D-10)
|
* previously blank displayName on subsequent visits (idempotent, keyed on iss+sub, D-10)
|
||||||
* 3. Queries users.isAdmin and member_credentials existence for the resolved user
|
* 3. Queries users.isAdmin, member_credentials existence, and local_credentials existence
|
||||||
* 4. Returns { user: { id, displayName, color, isAdmin, needsProviderSetup } }
|
* 4. Returns { user: { id, displayName, color, isAdmin, needsProviderSetup, hasLocalCredential } }
|
||||||
*
|
*
|
||||||
* Flow (dev bypass — DEV_AUTH_BYPASS=true, non-production):
|
* Flow (dev bypass — DEV_AUTH_BYPASS=true, non-production):
|
||||||
* devAuthBypass() injects DEV_USER via c.set('user', DEV_USER). oidcAuthMiddleware
|
* devAuthBypass() injects DEV_USER via c.set('user', DEV_USER). oidcAuthMiddleware
|
||||||
* is NOT mounted in index.ts when the bypass is active, so getAuth(c) is never called.
|
* is NOT mounted in index.ts when the bypass is active, so getAuth(c) is never called.
|
||||||
* This handler reads c.get('user') first and short-circuits using the dev user's id,
|
* This handler reads c.get('user') first and short-circuits using the dev user's id,
|
||||||
* but STILL queries the DB for isAdmin (T-10-05: bypass skips OIDC, not the DB check)
|
* but STILL queries the DB for isAdmin (T-10-05: bypass skips OIDC, not the DB check)
|
||||||
* and member_credentials existence.
|
* and member_credentials/local_credentials existence.
|
||||||
*
|
*
|
||||||
* Security (D-03, T-10-06):
|
* Security (D-03, T-10-06):
|
||||||
* isAdmin is exposed for UX-only PWA nav gating — it is NOT the security boundary.
|
* isAdmin is exposed for UX-only PWA nav gating — it is NOT the security boundary.
|
||||||
@@ -33,19 +33,21 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { getAuth } from '../auth/middleware.js';
|
import { getAuth } from '../auth/middleware.js';
|
||||||
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
||||||
import { db } from '../db/client.js';
|
import { db } from '../db/client.js';
|
||||||
import { users, memberCredentials } from '../db/schema.js';
|
import { users, memberCredentials, localCredentials } from '../db/schema.js';
|
||||||
import {
|
import {
|
||||||
validateEncryptAndStoreCredential,
|
validateEncryptAndStoreCredential,
|
||||||
CredentialValidationError,
|
CredentialValidationError,
|
||||||
} from '../broker/credentialSync.js';
|
} from '../broker/credentialSync.js';
|
||||||
|
import { hashPassword, verifyPassword } from '../auth/localCredentials.js';
|
||||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||||
import '../auth/devBypass.js';
|
import '../auth/devBypass.js';
|
||||||
|
|
||||||
export const meRouter = new Hono();
|
export const meRouter = new Hono();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Looks up isAdmin and needsProviderSetup for a given userId.
|
* Looks up isAdmin, needsProviderSetup, and hasLocalCredential for a given userId.
|
||||||
* Always reads from the DB — bypass only skips OIDC, not this check (T-10-05).
|
* Always reads from the DB — bypass only skips OIDC, not this check (T-10-05).
|
||||||
|
* hasLocalCredential (AUTH-LOCAL-17): true when a local_credentials row exists for userId.
|
||||||
*/
|
*/
|
||||||
async function resolveAdminAndSetupStatus(userId: number) {
|
async function resolveAdminAndSetupStatus(userId: number) {
|
||||||
const [userRow] = await db
|
const [userRow] = await db
|
||||||
@@ -60,9 +62,17 @@ async function resolveAdminAndSetupStatus(userId: number) {
|
|||||||
.where(eq(memberCredentials.userId, userId))
|
.where(eq(memberCredentials.userId, userId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
// AUTH-LOCAL-17: expose whether the user has a local username/password credential
|
||||||
|
const [localCred] = await db
|
||||||
|
.select({ id: localCredentials.id })
|
||||||
|
.from(localCredentials)
|
||||||
|
.where(eq(localCredentials.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAdmin: userRow?.isAdmin ?? false,
|
isAdmin: userRow?.isAdmin ?? false,
|
||||||
needsProviderSetup: !cred,
|
needsProviderSetup: !cred,
|
||||||
|
hasLocalCredential: Boolean(localCred), // AUTH-LOCAL-17
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,10 +98,10 @@ async function resolveUserId(c: Context): Promise<number | null> {
|
|||||||
meRouter.get('/', async (c) => {
|
meRouter.get('/', async (c) => {
|
||||||
// Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active.
|
// Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active.
|
||||||
// Use the injected dev identity's id for DB lookups — no OIDC session needed,
|
// Use the injected dev identity's id for DB lookups — no OIDC session needed,
|
||||||
// but isAdmin and needsProviderSetup are still resolved from the DB (T-10-05).
|
// but isAdmin, needsProviderSetup, and hasLocalCredential are still resolved from the DB (T-10-05).
|
||||||
const devUser = c.get('user');
|
const devUser = c.get('user');
|
||||||
if (devUser) {
|
if (devUser) {
|
||||||
const { isAdmin, needsProviderSetup } = await resolveAdminAndSetupStatus(devUser.id);
|
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(devUser.id);
|
||||||
return c.json({
|
return c.json({
|
||||||
user: {
|
user: {
|
||||||
id: devUser.id,
|
id: devUser.id,
|
||||||
@@ -99,6 +109,7 @@ meRouter.get('/', async (c) => {
|
|||||||
color: devUser.color,
|
color: devUser.color,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
needsProviderSetup,
|
needsProviderSetup,
|
||||||
|
hasLocalCredential, // AUTH-LOCAL-17
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -126,7 +137,7 @@ meRouter.get('/', async (c) => {
|
|||||||
return c.json({ error: 'Could not resolve user' }, 500);
|
return c.json({ error: 'Could not resolve user' }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isAdmin, needsProviderSetup } = await resolveAdminAndSetupStatus(user.id);
|
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(user.id);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
user: {
|
user: {
|
||||||
@@ -135,6 +146,7 @@ meRouter.get('/', async (c) => {
|
|||||||
color: user.color,
|
color: user.color,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
needsProviderSetup,
|
needsProviderSetup,
|
||||||
|
hasLocalCredential, // AUTH-LOCAL-17
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -200,3 +212,65 @@ meRouter.post('/credential', zValidator('json', meCredentialSchema, meNoEchoHook
|
|||||||
|
|
||||||
return c.json({ ok: true }, 200);
|
return c.json({ ok: true }, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/me/password — self-change password (AUTH-LOCAL-09, T-19-07)
|
||||||
|
//
|
||||||
|
// Security contract:
|
||||||
|
// - T-19-07: verifyPassword(current) required before any update; resolveUserId from
|
||||||
|
// session (not body). User can only change their OWN password.
|
||||||
|
// - meNoEchoHook: NEVER return Zod error details (contains submitted passwords, T-19-06).
|
||||||
|
// - Never log currentPassword, newPassword, or c.req.valid('json') (T-19-06).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
newPassword: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
meRouter.post(
|
||||||
|
'/password',
|
||||||
|
zValidator('json', mePasswordSchema, meNoEchoHook),
|
||||||
|
async (c) => {
|
||||||
|
// T-19-07: ALWAYS resolve userId from session — never from body
|
||||||
|
const currentUserId = await resolveUserId(c);
|
||||||
|
if (!currentUserId) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentPassword, newPassword } = c.req.valid('json');
|
||||||
|
// T-19-06: NEVER log currentPassword, newPassword, or the request body
|
||||||
|
|
||||||
|
// Look up the user's local_credentials row (404 if none — no local credential to change)
|
||||||
|
const [credRow] = await db
|
||||||
|
.select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId })
|
||||||
|
.from(localCredentials)
|
||||||
|
.where(eq(localCredentials.userId, currentUserId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!credRow) {
|
||||||
|
return c.json({ error: 'No local credential found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// T-19-07: verify current password before any update
|
||||||
|
const isCorrect = verifyPassword(credRow.passwordHash, currentPassword);
|
||||||
|
if (!isCorrect) {
|
||||||
|
return c.json({ error: 'Current password incorrect' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(localCredentials)
|
||||||
|
.set({ passwordHash: hashPassword(newPassword) })
|
||||||
|
.where(eq(localCredentials.userId, currentUserId));
|
||||||
|
|
||||||
|
return c.json({ ok: true }, 200);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
'[me/POST /password] Unexpected error:',
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
return c.json({ error: 'Service unavailable' }, 503);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user