feat(opaque): improve password change
- rebuild the opaque envoloppe - revoke all other useer's sessions - send a security email to user
This commit is contained in:
@@ -68,12 +68,19 @@ export async function changePassword(currentPw: string, newPw: string): Promise<
|
||||
body: JSON.stringify({ current_password: currentPw, new_password: newPw })
|
||||
});
|
||||
if (!res.ok) throw new Error(`password change failed: ${res.status}`);
|
||||
// Re-mint the OPAQUE envelope under the new passphrase — session
|
||||
// stays valid across change-password (backend doesn't invalidate),
|
||||
// so the session-authenticated register endpoints are reachable
|
||||
// straight away. Non-fatal on failure: silent migration on next
|
||||
// legacy login recovers the envelope. See
|
||||
// `$lib/api/endpoints/opaque.ts::syncOpaqueEnvelope`.
|
||||
// Re-mint the OPAQUE envelope under the new passphrase — SAME
|
||||
// session is still valid after change_password (the backend now
|
||||
// preserves the caller's session via `revoke_other_user_sessions`;
|
||||
// only OTHER devices are logged out). That means the session-
|
||||
// authenticated register endpoints are reachable straight away,
|
||||
// no 401 race like the earlier `revoke_all_user_sessions` shape.
|
||||
//
|
||||
// This is the PRIMARY migration path: the envelope transitions
|
||||
// straight from OLD-password bound to NEW-password bound with no
|
||||
// null intermediate. `opaque_migrated_at` stays intact, admin
|
||||
// dashboards don't see a spurious "unmigrated" blip. Non-fatal
|
||||
// on failure — silent-migration on next legacy login (post
|
||||
// `oxicloud-cli opaque reset` recovery) is the fallback.
|
||||
//
|
||||
// Dynamic import keeps the ~200 KiB `@serenity-kit/opaque` WASM
|
||||
// bundle out of the profile route's initial chunk — the module
|
||||
|
||||
@@ -227,6 +227,17 @@ export interface User {
|
||||
* missing → `false`.
|
||||
*/
|
||||
force_password_change?: boolean;
|
||||
/**
|
||||
* TRUE when the account has a local Argon2id `password_hash` on
|
||||
* file. Distinct from `auth_provider`: an SSO-linked account can
|
||||
* ALSO carry a local password (hybrid posture — SSO for daily
|
||||
* login, local password as fallback). The profile page's
|
||||
* change-password card gates on this flag rather than on
|
||||
* `auth_provider === 'local'` so hybrid users can rotate their
|
||||
* local credential. Optional on the wire for older-backend
|
||||
* compatibility; missing → `false` (safe default: hide the card).
|
||||
*/
|
||||
has_password?: boolean;
|
||||
}
|
||||
|
||||
/** Fields rendered by the paginated admin table. Full account details remain
|
||||
|
||||
@@ -71,7 +71,17 @@
|
||||
const usernameClaimed = $derived(!!session.user?.username);
|
||||
const isAdmin = $derived(session.user?.role === 'admin');
|
||||
const canEditImage = $derived(session.user?.can_edit_image === true && isLocal);
|
||||
const showPasswordCard = $derived(isLocal && passwordLoginEnabled);
|
||||
// Show the change-password card when the user CAN change their
|
||||
// local password: they have `password_hash` on file AND the
|
||||
// deployment offers password login (backend `change_password`
|
||||
// refuses on either count — see `AuthApplicationService::change_password`).
|
||||
// Distinct from the OLD `isLocal && passwordLoginEnabled` gate,
|
||||
// which refused any SSO-linked account regardless of whether they
|
||||
// carried a local password. Hybrid accounts (OIDC + local
|
||||
// password) are a legitimate posture and MUST be able to rotate
|
||||
// their local credential; the new gate lets them, and the backend
|
||||
// refusal covers the pure-SSO case where has_password is false.
|
||||
const showPasswordCard = $derived((session.user?.has_password ?? false) && passwordLoginEnabled);
|
||||
|
||||
/**
|
||||
* Mandatory change-password mode. TRUE when the backend has
|
||||
|
||||
@@ -14,7 +14,8 @@ const { session, ui } = vi.hoisted(() => ({
|
||||
role: 'admin',
|
||||
storage_used_bytes: 100,
|
||||
storage_quota_bytes: 1000,
|
||||
is_external: false
|
||||
is_external: false,
|
||||
has_password: true
|
||||
}
|
||||
},
|
||||
ui: { notify: vi.fn() }
|
||||
@@ -54,7 +55,8 @@ beforeEach(() => {
|
||||
role: 'admin',
|
||||
storage_used_bytes: 100,
|
||||
storage_quota_bytes: 1000,
|
||||
is_external: false
|
||||
is_external: false,
|
||||
has_password: true
|
||||
};
|
||||
m(profile.listAppPasswords).mockResolvedValue([]);
|
||||
m(profile.updateProfile).mockResolvedValue(undefined);
|
||||
|
||||
Reference in New Issue
Block a user