Add change-password: users update their own password from Settings
POST /api/auth/change-password requires the current password; admins still reset others via manage.py set-password. UI on the Settings page (signed-in users only) with match/length validation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>main
parent
d823a45cb2
commit
11bceedaca
|
|
@ -236,3 +236,33 @@ def me(request: Request) -> dict:
|
||||||
return {"authenticated": False, "auth_required": False}
|
return {"authenticated": False, "auth_required": False}
|
||||||
return {"authenticated": True, "auth_required": True,
|
return {"authenticated": True, "auth_required": True,
|
||||||
"username": user.username, "display_name": user.display_name}
|
"username": user.username, "display_name": user.display_name}
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordIn(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
def change_password(body: ChangePasswordIn, request: Request,
|
||||||
|
db: OrmSession = Depends(db_dep)) -> dict:
|
||||||
|
"""Signed-in users change their own password (admins reset others via manage.py).
|
||||||
|
|
||||||
|
Requires the current password so a walked-away-from session can't be hijacked into a
|
||||||
|
permanent account takeover. Existing tokens stay valid until their normal expiry."""
|
||||||
|
user = current_user(request)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(401, "Sign in to change your password.")
|
||||||
|
row = db.get(models.User, user.id)
|
||||||
|
if row is None or not row.is_active:
|
||||||
|
raise HTTPException(401, "Account not found or deactivated.")
|
||||||
|
if not verify_password(body.current_password, row.password_hash):
|
||||||
|
raise HTTPException(400, "The current password is wrong.")
|
||||||
|
if len(body.new_password) < 8:
|
||||||
|
raise HTTPException(400, "The new password must be at least 8 characters.")
|
||||||
|
if body.new_password == body.current_password:
|
||||||
|
raise HTTPException(400, "The new password must be different from the current one.")
|
||||||
|
row.password_hash = hash_password(body.new_password)
|
||||||
|
db.commit()
|
||||||
|
logger.info("password changed: %s", row.username)
|
||||||
|
return {"changed": True}
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,38 @@ def test_signed_in_identity_overrides_body_name(clean_users):
|
||||||
c.delete(f"/api/sessions/{sid}", headers=hdr)
|
c.delete(f"/api/sessions/{sid}", headers=hdr)
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password_flow(clean_users):
|
||||||
|
_add_user("changer", "Change Person", "old-password-1")
|
||||||
|
with TestClient(app) as c:
|
||||||
|
token = c.post("/api/auth/login", json={
|
||||||
|
"username": "changer", "password": "old-password-1"}).json()["token"]
|
||||||
|
hdr = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
# Wrong current password / too short / unchanged are all refused.
|
||||||
|
assert c.post("/api/auth/change-password", headers=hdr, json={
|
||||||
|
"current_password": "nope", "new_password": "new-password-2"}).status_code == 400
|
||||||
|
assert c.post("/api/auth/change-password", headers=hdr, json={
|
||||||
|
"current_password": "old-password-1", "new_password": "short"}).status_code == 400
|
||||||
|
assert c.post("/api/auth/change-password", headers=hdr, json={
|
||||||
|
"current_password": "old-password-1",
|
||||||
|
"new_password": "old-password-1"}).status_code == 400
|
||||||
|
# Not signed in -> refused.
|
||||||
|
assert c.post("/api/auth/change-password", json={
|
||||||
|
"current_password": "old-password-1",
|
||||||
|
"new_password": "new-password-2"}).status_code == 401
|
||||||
|
|
||||||
|
r = c.post("/api/auth/change-password", headers=hdr, json={
|
||||||
|
"current_password": "old-password-1", "new_password": "new-password-2"})
|
||||||
|
assert r.status_code == 200 and r.json()["changed"] is True
|
||||||
|
|
||||||
|
# Old password dead, new one works, existing token still valid until expiry.
|
||||||
|
assert c.post("/api/auth/login", json={
|
||||||
|
"username": "changer", "password": "old-password-1"}).status_code == 401
|
||||||
|
assert c.post("/api/auth/login", json={
|
||||||
|
"username": "changer", "password": "new-password-2"}).status_code == 200
|
||||||
|
assert c.get("/api/auth/me", headers=hdr).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_inactive_user_cannot_login(clean_users):
|
def test_inactive_user_cannot_login(clean_users):
|
||||||
_add_user("gone", "Gone Person", "some-pw-12345")
|
_add_user("gone", "Gone Person", "some-pw-12345")
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|
|
||||||
|
|
@ -495,6 +495,10 @@ export const api = {
|
||||||
method: "POST", body: JSON.stringify({ username, password }),
|
method: "POST", body: JSON.stringify({ username, password }),
|
||||||
}),
|
}),
|
||||||
me: () => req<{ authenticated: boolean; username?: string; display_name?: string }>("/auth/me"),
|
me: () => req<{ authenticated: boolean; username?: string; display_name?: string }>("/auth/me"),
|
||||||
|
changePassword: (current_password: string, new_password: string) =>
|
||||||
|
req<{ changed: boolean }>("/auth/change-password", {
|
||||||
|
method: "POST", body: JSON.stringify({ current_password, new_password }),
|
||||||
|
}),
|
||||||
|
|
||||||
listSessions: () => req<SessionT[]>("/sessions"),
|
listSessions: () => req<SessionT[]>("/sessions"),
|
||||||
createSession: (body: Partial<SessionT> & { allow_duplicate?: boolean }) =>
|
createSession: (body: Partial<SessionT> & { allow_duplicate?: boolean }) =>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { FormEvent, useState } from "react";
|
||||||
import { ShieldCheck } from "lucide-react";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { KeyRound, ShieldCheck } from "lucide-react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { Section } from "../components/ui";
|
import { Section, Spinner } from "../components/ui";
|
||||||
|
import { useAuth } from "../auth";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { data: health } = useQuery({ queryKey: ["health"], queryFn: api.health });
|
const { data: health } = useQuery({ queryKey: ["health"], queryFn: api.health });
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-3xl mx-auto space-y-6">
|
<div className="p-6 max-w-3xl mx-auto space-y-6">
|
||||||
|
|
@ -13,6 +16,8 @@ export default function Settings() {
|
||||||
<p className="text-sm text-subink">Application defaults and security posture.</p>
|
<p className="text-sm text-subink">Application defaults and security posture.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{user && <ChangePassword username={user.username} />}
|
||||||
|
|
||||||
<Section title="Processing defaults">
|
<Section title="Processing defaults">
|
||||||
<dl className="divide-y divide-line">
|
<dl className="divide-y divide-line">
|
||||||
{[
|
{[
|
||||||
|
|
@ -55,3 +60,54 @@ export default function Settings() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChangePassword({ username }: { username: string }) {
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [next, setNext] = useState("");
|
||||||
|
const [repeat, setRepeat] = useState("");
|
||||||
|
const change = useMutation({
|
||||||
|
mutationFn: () => api.changePassword(current, next),
|
||||||
|
onSuccess: () => { setCurrent(""); setNext(""); setRepeat(""); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mismatch = repeat.length > 0 && next !== repeat;
|
||||||
|
const tooShort = next.length > 0 && next.length < 8;
|
||||||
|
const ready = current && next.length >= 8 && next === repeat;
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (ready) change.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Change my password"
|
||||||
|
subtitle={`Signed in as ${username}. Forgot the current one? An administrator can reset it on the server (manage.py set-password).`}>
|
||||||
|
<form onSubmit={submit} className="p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-xs font-medium text-subink mb-1">Current password</span>
|
||||||
|
<input className="input" type="password" autoComplete="current-password"
|
||||||
|
value={current} onChange={(e) => setCurrent(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-xs font-medium text-subink mb-1">New password (min 8)</span>
|
||||||
|
<input className="input" type="password" autoComplete="new-password"
|
||||||
|
value={next} onChange={(e) => setNext(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-xs font-medium text-subink mb-1">Repeat new password</span>
|
||||||
|
<input className="input" type="password" autoComplete="new-password"
|
||||||
|
value={repeat} onChange={(e) => setRepeat(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="sm:col-span-3 flex items-center gap-3 flex-wrap">
|
||||||
|
<button className="btn-primary" type="submit" disabled={!ready || change.isPending}>
|
||||||
|
{change.isPending ? <Spinner /> : <KeyRound size={15} />} Update password
|
||||||
|
</button>
|
||||||
|
{tooShort && <span className="text-xs text-warn">At least 8 characters.</span>}
|
||||||
|
{mismatch && <span className="text-xs text-bad">Passwords don't match.</span>}
|
||||||
|
{change.isSuccess && <span className="text-xs text-ok">Password updated — use it from your next sign-in.</span>}
|
||||||
|
{change.isError && <span className="text-xs text-bad">{(change.error as Error).message}</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue