Åtkomsthärdning: kontospärr, omedelbar återkallelse, takt på inloggning

Två luckor från genomgången, båda i auth.

En utfärdad token gällde sina tolv timmar ut. Det fanns dessutom inget
sätt att stänga av ett konto alls — en person som slutade behöll
åtkomsten till organisationens ärenden. Nu bär token en version, och
varje autentiserat anrop slår upp kontot och kontrollerar att det är
aktivt och att versionen stämmer. Det kostar ett uppslag på
primärnyckeln per anrop och ger i gengäld omedelbar verkan i stället för
en avstängning som börjar gälla någon gång i morgon.

Administratören stänger av och öppnar konton i användarlistan. Att stänga
av höjer token-versionen, så pågående sessioner upphör direkt; öppnas
kontot igen förblir de gamla token döda. Ingen kan stänga av sig själv
och organisationsgränsen gäller. Var och en kan dessutom logga ut på alla
enheter — vägen ut när en telefon tappats bort. Händelseloggen rörs
aldrig: historiken är fortfarande knuten till den som utförde arbetet.

Lösenord kunde gissas i obegränsad takt. Spärren ligger nu i databasen,
inte i minnet, så den håller bakom flera repliker: 10 misslyckade försök
per konto och 30 per källadress inom 15 minuter. Spärren gäller kontot
även vid rätt lösenord — annars kunde den kringgås av den som till slut
gissar rätt. Andra konton påverkas inte. Inget lösenord lagras, bara att
ett försök skedde och om det lyckades, och rader äldre än ett dygn städas
bort i skrivvägen. Ett avstängt konto räknas som misslyckat försök så att
svarstiden inte avslöjar vilka konton som finns.

Verifierat: 87 vitest-tester, typkontroll, eslint, OpenAPI-validering och
integrationstest mot riktig Postgres med 13 nya kontroller — rollstyrning,
självavstängning, organisationsgränsen, att en utfärdad token dör direkt,
att den förblir död efter återöppning, logga-ut-alla samt att spärren
slår till, gäller även rätt lösenord och inte smittar andra konton.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-03 22:38:55 +00:00
parent 19796bec70
commit a7574015b1
9 changed files with 393 additions and 12 deletions
+10
View File
@@ -55,7 +55,14 @@ describe("AI-orkestern", () => {
expect(plattform).toContain("organisation_id");
expect(plattform).toContain("arendeIOrg");
// Kontospärr och återkallelse: en giltig signatur räcker inte.
expect(plattform).toContain("kontoGiltigt");
expect(plattform).toContain("token_version");
expect(plattform).toContain("inloggningSparrad");
const schema = readFileSync("infra/postgres-init.sql", "utf8");
expect(schema).toContain("create table if not exists inloggningsforsok");
expect(schema).toContain("token_version integer not null default 0");
expect(schema).toContain("before update or delete on felsokning_handelser");
expect(schema).toContain("before update or delete on felsokning_arenden");
expect(schema).toContain("create table if not exists organisationer");
@@ -86,6 +93,9 @@ describe("AI-orkestern", () => {
["/api/integrationer/leverantorer", "/api/integrationer/leverantorer"],
["/api/integrationer/{leverantor}", "integrationVag"],
["/api/integrationer/{leverantor}/uppslag", "uppslagVag"],
["/api/anvandare/{anvandarId}/avaktivera", "kontoVag"],
["/api/anvandare/{anvandarId}/aktivera", "aktivera"],
["/api/auth/logga-ut-alla", "/api/auth/logga-ut-alla"],
];
for (const [iSpec, iServer] of vagar) {
expect(spec).toContain(`${iSpec}:`);
+20
View File
@@ -74,6 +74,7 @@ export interface PlattformAnvandare {
epost: string;
namn: string;
roll: PlattformRoll;
aktiv: boolean;
}
export async function hamtaAnvandare(): Promise<PlattformAnvandare[]> {
@@ -98,6 +99,25 @@ export async function skapaAnvandare(
}
}
// Stänga av eller öppna ett konto. En avstängning återkallar samtidigt
// pågående sessioner — annars vore den verkningslös tills token gick ut.
export async function sattKontoAktiv(id: string, aktiv: boolean): Promise<void> {
const res = await plattformFetch(`/api/anvandare/${id}/${aktiv ? "aktivera" : "avaktivera"}`, {
method: "POST",
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Fel ${res.status}`);
}
}
// Logga ut på alla enheter — vägen ut när en telefon tappats bort.
export async function loggaUtAllaEnheter(): Promise<void> {
const res = await plattformFetch("/api/auth/logga-ut-alla", { method: "POST" });
if (!res.ok) throw new Error(`Fel ${res.status}`);
loggaUtPlattform();
}
// Live Share-delningar: återkallbara länkar med behörighetsnivå.
export type DelningsNiva = "kund" | "partner" | "intern";
+37 -4
View File
@@ -10,6 +10,7 @@ import {
plattformAktiv,
plattformKonto,
registreraPlattform,
sattKontoAktiv,
skapaAnvandare,
type PlattformAnvandare,
type PlattformKonto,
@@ -173,11 +174,43 @@ function AnvandarAdmin() {
return (
<Panel rubrik="Användare">
{lista.map((anv) => (
<p key={anv.id} className="border-b border-[#DDDDDD] py-1 text-[14px] last:border-0">
<span className="font-semibold">{anv.namn}</span> · {ROLL_LABEL[anv.roll]}{" "}
<span className="text-[#707070]">{anv.epost}</span>
</p>
<div
key={anv.id}
className="flex items-center justify-between gap-3 border-b border-[#DDDDDD] py-1.5 last:border-0"
>
<p className="min-w-0 text-[14px]">
<span className={`font-semibold ${anv.aktiv === false ? "text-[#8A8A8A] line-through" : ""}`}>
{anv.namn}
</span>{" "}
· {ROLL_LABEL[anv.roll]} <span className="text-[#707070]">{anv.epost}</span>
{anv.aktiv === false && (
<span className="ml-1 text-[12px] font-semibold text-[#8B1A1A]">Avstängd</span>
)}
</p>
<button
type="button"
className={`shrink-0 rounded border px-2 py-1 text-[12px] font-semibold ${
anv.aktiv === false
? "border-[#1E6B34] bg-white text-[#1E6B34]"
: "border-[#6E1414] bg-white text-[#8B1A1A]"
}`}
onClick={async () => {
setFel("");
try {
await sattKontoAktiv(anv.id, anv.aktiv === false);
await uppdatera();
} catch (misslyckande) {
setFel(misslyckande instanceof Error ? misslyckande.message : "Något gick fel.");
}
}}
>
{anv.aktiv === false ? "Öppna igen" : "Stäng av"}
</button>
</div>
))}
<p className="mt-1 text-[12px] text-[#707070]">
En avstängning gäller omedelbart pågående sessioner personens enheter upphör direkt.
</p>
<form
className="mt-3"
onSubmit={async (e) => {