Å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
+66
View File
@@ -339,6 +339,72 @@ curl -s -X DELETE "$BAS/api/integrationer/generisk_vin" -H "Authorization: Beare
ANTAL=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" | falt .integrationer.length)
kontroll "kopplingen kan tas bort" "$ANTAL" "0"
# 10e. Kontospärr, återkallelse och takt-begränsning på inloggning
# Skapa en tekniker att stänga av.
SVAR=$(curl -s -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"epost":"karin@a.se","losenord":"hemligt123","namn":"Karin","roll":"tekniker"}')
KARIN_ID=$(echo "$SVAR" | falt .id)
kontroll "ny användare är aktiv" "$(echo "$SVAR" | falt .aktiv)" "true"
TOKEN_K=$(curl -s -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \
-d '{"epost":"karin@a.se","losenord":"hemligt123"}' | falt .token)
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_K")
kontroll "teknikern kommer in" "$KOD" "200"
# Tekniker får inte stänga av någon
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare/$KARIN_ID/avaktivera" \
-H "Authorization: Bearer $TOKEN_K")
kontroll "tekniker kan inte stänga av konton" "$KOD" "403"
# Administratören kan inte stänga av sig själv
ANNA_ID=$(curl -s "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" \
| falt '.anvandare.find(a=>a.epost==="anna@a.se").id')
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare/$ANNA_ID/avaktivera" \
-H "Authorization: Bearer $TOKEN_A")
kontroll "admin kan inte stänga av sig själv" "$KOD" "400"
# Org B kan inte röra org A:s användare
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare/$KARIN_ID/avaktivera" \
-H "Authorization: Bearer $TOKEN_B")
kontroll "org B kan inte stänga av org A:s användare" "$KOD" "404"
# Avstängningen gäller OMEDELBART för redan utfärdad token
curl -s -X POST "$BAS/api/anvandare/$KARIN_ID/avaktivera" -H "Authorization: Bearer $TOKEN_A" >/dev/null
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_K")
kontroll "utfärdad token slutar gälla direkt vid avstängning" "$KOD" "401"
# … och inloggning stängs
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \
-d '{"epost":"karin@a.se","losenord":"hemligt123"}')
kontroll "avstängt konto kan inte logga in" "$KOD" "403"
# Öppnas kontot igen fungerar inloggning, men den gamla token är död för gott
curl -s -X POST "$BAS/api/anvandare/$KARIN_ID/aktivera" -H "Authorization: Bearer $TOKEN_A" >/dev/null
TOKEN_K2=$(curl -s -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \
-d '{"epost":"karin@a.se","losenord":"hemligt123"}' | falt .token)
kontroll "återöppnat konto kan logga in" "$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_K2")" "200"
kontroll "den återkallade token förblir ogiltig" "$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_K")" "401"
# Logga ut på alla enheter återkallar den egna sessionen
curl -s -X POST "$BAS/api/auth/logga-ut-alla" -H "Authorization: Bearer $TOKEN_K2" >/dev/null
kontroll "logga-ut-alla dödar den egna token" "$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_K2")" "401"
# Takt-begränsning: efter tio misslyckade försök spärras kontot en stund
SISTA=""
for i in $(seq 1 11); do
SISTA=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/auth/logga-in" \
-H 'Content-Type: application/json' -d '{"epost":"karin@a.se","losenord":"fel-losenord"}')
done
kontroll "upprepade misslyckade inloggningar spärras" "$SISTA" "429"
# Spärren gäller kontot även med RÄTT lösenord — annars vore den meningslös
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/auth/logga-in" \
-H 'Content-Type: application/json' -d '{"epost":"karin@a.se","losenord":"hemligt123"}')
kontroll "spärren gäller även rätt lösenord" "$KOD" "429"
# Ett annat konto påverkas inte av spärren på det första
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/auth/logga-in" \
-H 'Content-Type: application/json' -d '{"epost":"anna@a.se","losenord":"hemligt123"}')
kontroll "andra konton påverkas inte" "$KOD" "200"
# 11. API-first: OpenAPI-specen serveras live, utan inloggning
SPEC=$(curl -s "$BAS/api/openapi.yaml")
case "$SPEC" in
+81
View File
@@ -88,6 +88,12 @@ paths:
post:
tags: [Auth]
summary: Logga in
description: >
Takt-begränsad i databasen, så spärren håller bakom flera
repliker: 10 misslyckade försök per konto och 30 per källadress
inom 15 minuter ger 429. Spärren gäller kontot även vid rätt
lösenord — annars vore den meningslös. Ett avstängt konto svarar
403.
security: []
requestBody:
required: true
@@ -102,6 +108,16 @@ paths:
responses:
"200": { $ref: "#/components/responses/Inloggad" }
"401": { $ref: "#/components/responses/Fel" }
"403":
description: Kontot är avstängt.
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
"429":
description: För många misslyckade försök.
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
/api/anvandare:
get:
@@ -230,6 +246,71 @@ paths:
"401": { $ref: "#/components/responses/Fel" }
"403": { $ref: "#/components/responses/Fel" }
/api/anvandare/{anvandarId}/avaktivera:
post:
tags: [Användare]
summary: Stäng av ett konto
description: >
Kräver rollen `admin` och att användaren tillhör samma
organisation. Avstängningen höjer kontots token-version, så
**pågående sessioner upphör omedelbart** — annars vore den
verkningslös tills utfärdade tokens gick ut. Ett konto kan inte
stänga av sig självt.
parameters:
- name: anvandarId
in: path
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: Kontot är avstängt.
content:
application/json:
schema:
type: object
properties:
id: { type: string, format: uuid }
namn: { type: string }
aktiv: { type: boolean }
"400":
description: Försök att stänga av sitt eget konto.
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
"401": { $ref: "#/components/responses/Fel" }
"403": { $ref: "#/components/responses/Fel" }
"404": { $ref: "#/components/responses/Fel" }
/api/anvandare/{anvandarId}/aktivera:
post:
tags: [Användare]
summary: Öppna ett avstängt konto
description: >
Kräver rollen `admin`. Kontot kan logga in igen, men tokens som
återkallades vid avstängningen förblir ogiltiga.
parameters:
- name: anvandarId
in: path
required: true
schema: { type: string, format: uuid }
responses:
"200": { description: Kontot är öppnat. }
"401": { $ref: "#/components/responses/Fel" }
"403": { $ref: "#/components/responses/Fel" }
"404": { $ref: "#/components/responses/Fel" }
/api/auth/logga-ut-alla:
post:
tags: [Auth]
summary: Logga ut på alla enheter
description: >
Höjer den egna token-versionen, vilket gör samtliga utfärdade
tokens för kontot ogiltiga direkt — vägen ut när en enhet
tappats bort.
responses:
"200": { description: Alla sessioner är avslutade. }
"401": { $ref: "#/components/responses/Fel" }
/api/integrationer/leverantorer:
get:
tags: [Integrationer]
+127 -8
View File
@@ -87,6 +87,49 @@ function forTataForsok(kod) {
return forsok.length > BESLUT_TAK;
}
// ---- Takt-begränsning på inloggning -----------------------------------
//
// Den i minnet (beslutsvägen ovan) räcker för en pod. Inloggningen skalar
// till flera repliker och behöver därför en gemensam räknare — den ligger
// i databasen. Två spärrar: per konto (skyddar en enskild användare) och
// per källa (stoppar den som betar av många konton från samma håll).
const INLOGG_FONSTER = "15 minutes";
const INLOGG_TAK_KONTO = 10;
const INLOGG_TAK_KALLA = 30;
function kallaFor(req) {
// Bakom ingressen står klientens adress först i X-Forwarded-For.
const vidarebefordrad = req.headers["x-forwarded-for"];
const forsta = typeof vidarebefordrad === "string" ? vidarebefordrad.split(",")[0].trim() : "";
return (forsta || req.socket?.remoteAddress || "").slice(0, 64);
}
async function inloggningSparrad(epost, kalla) {
const rad = await pool.query(
`select
count(*) filter (where epost = $1) as konto,
count(*) filter (where kalla = $2 and $2 <> '') as kalla
from inloggningsforsok
where lyckades = false and tidpunkt > now() - interval '${INLOGG_FONSTER}'`,
[epost, kalla],
);
const { konto, kalla: franKalla } = rad.rows[0];
return Number(konto) >= INLOGG_TAK_KONTO || Number(franKalla) >= INLOGG_TAK_KALLA;
}
async function loggaForsok(epost, kalla, lyckades) {
await pool.query(
`insert into inloggningsforsok (epost, kalla, lyckades) values ($1, $2, $3)`,
[epost, kalla, lyckades],
);
// Städa bort det som inte längre kan påverka någon spärr. Billigt nog
// att göra i skrivvägen och slipper ett schemalagt jobb.
if (Math.random() < 0.02) {
await pool.query(`delete from inloggningsforsok where tidpunkt < now() - interval '1 day'`);
}
}
// ---- JWT (HS256, utan beroenden) --------------------------------------
const b64url = (data) => Buffer.from(data).toString("base64url");
@@ -236,6 +279,20 @@ function kravAuth(req, hemlighet) {
return token ? verifieraJwt(token, hemlighet) : null;
}
// En giltig signatur räcker inte. Kontot måste fortfarande vara aktivt,
// och token-versionen måste stämma med kontots — annars har den
// återkallats. Ett uppslag på primärnyckeln per anrop, vilket gör
// återkallelsen omedelbar i stället för att gälla vid nästa utgång.
async function kontoGiltigt(anspr) {
const rad = await pool.query(
`select aktiv, token_version from anvandare where id = $1 and organisation_id = $2`,
[anspr.sub, anspr.org],
);
if (rad.rowCount === 0) return false;
if (rad.rows[0].aktiv === false) return false;
return (anspr.tv ?? 0) === rad.rows[0].token_version;
}
// Verifierar att ärendet tillhör användarens organisation.
async function arendeIOrg(arendeId, organisationId) {
const rader = await pool.query(
@@ -351,7 +408,16 @@ export function skapaServer() {
function loggaIn(res, rad, hemlighet) {
const nu = Math.floor(Date.now() / 1000);
const token = skapaJwt(
{ sub: rad.id, namn: rad.namn, org: rad.organisation_id, roll: rad.roll, iat: nu, exp: nu + TOKEN_LIVSTID_S },
{
sub: rad.id,
namn: rad.namn,
org: rad.organisation_id,
roll: rad.roll,
// Bärs med så att en återkallelse gör token ogiltig direkt.
tv: rad.token_version ?? 0,
iat: nu,
exp: nu + TOKEN_LIVSTID_S,
},
hemlighet,
);
return svara(res, 200, { token, namn: rad.namn, roll: rad.roll, organisation: rad.org_namn });
@@ -408,7 +474,7 @@ export function skapaServer() {
`insert into anvandare (organisation_id, epost, losen_hash, namn, roll)
values ($1, lower($2), crypt($3, gen_salt('bf')), $4, 'admin')
on conflict (epost) do nothing
returning id, namn, organisation_id, roll`,
returning id, namn, organisation_id, roll, token_version`,
[org.rows[0].id, epost.trim(), losenord, namn.trim()],
);
if (rad.rowCount === 0) {
@@ -427,13 +493,30 @@ export function skapaServer() {
if (req.method === "POST" && vag === "/api/auth/logga-in") {
const { epost, losenord } = await lasKropp(req);
const normaliserad = (epost ?? "").trim().toLowerCase();
const kalla = kallaFor(req);
if (await inloggningSparrad(normaliserad, kalla)) {
return svara(res, 429, { error: "För många misslyckade försök — vänta en stund och försök igen." });
}
const rader = await pool.query(
`select a.id, a.namn, a.organisation_id, a.roll, o.namn as org_namn
`select a.id, a.namn, a.organisation_id, a.roll, a.aktiv, a.token_version, o.namn as org_namn
from anvandare a join organisationer o on o.id = a.organisation_id
where a.epost = lower($1) and a.losen_hash = crypt($2, a.losen_hash)`,
[epost ?? "", losenord ?? ""],
where a.epost = $1 and a.losen_hash = crypt($2, a.losen_hash)`,
[normaliserad, losenord ?? ""],
);
if (rader.rowCount === 0) return svara(res, 401, { error: "Fel e-post eller lösenord." });
if (rader.rowCount === 0) {
await loggaForsok(normaliserad, kalla, false);
return svara(res, 401, { error: "Fel e-post eller lösenord." });
}
// Ett avaktiverat konto räknas som misslyckat försök: annars blir
// svarstiden ett sätt att lista ut vilka konton som finns.
if (rader.rows[0].aktiv === false) {
await loggaForsok(normaliserad, kalla, false);
return svara(res, 403, { error: "Kontot är avstängt — kontakta er administratör." });
}
await loggaForsok(normaliserad, kalla, true);
return loggaIn(res, rader.rows[0], hemlighet);
}
@@ -549,6 +632,9 @@ export function skapaServer() {
// -- Skyddade endpoints (organisationsknutna) --
const anspr = kravAuth(req, hemlighet);
if (!anspr?.org) return svara(res, 401, { error: "Inloggning krävs." });
if (!(await kontoGiltigt(anspr))) {
return svara(res, 401, { error: "Sessionen gäller inte längre — logga in på nytt." });
}
// Användarhantering: endast systemadministratör, endast egen org.
if (vag === "/api/anvandare") {
@@ -557,7 +643,7 @@ export function skapaServer() {
if (anspr.roll === "tekniker") return svara(res, 403, { error: "Kräver arbetsledar- eller administratörsbehörighet." });
if (req.method === "GET") {
const rader = await pool.query(
`select id, epost, namn, roll from anvandare where organisation_id = $1 order by namn`,
`select id, epost, namn, roll, aktiv from anvandare where organisation_id = $1 order by namn`,
[anspr.org],
);
return svara(res, 200, { anvandare: rader.rows });
@@ -572,7 +658,7 @@ export function skapaServer() {
`insert into anvandare (organisation_id, epost, losen_hash, namn, roll)
values ($1, lower($2), crypt($3, gen_salt('bf')), $4, $5)
on conflict (epost) do nothing
returning id, epost, namn, roll`,
returning id, epost, namn, roll, aktiv`,
[anspr.org, epost.trim(), losenord, namn.trim(), roll],
);
if (rad.rowCount === 0) return svara(res, 409, { error: "E-postadressen är redan registrerad." });
@@ -580,6 +666,39 @@ export function skapaServer() {
}
}
// Stäng av eller öppna ett konto. Att stänga av höjer också
// token-versionen, så pågående sessioner upphör direkt — annars
// vore avstängningen verkningslös i upp till tolv timmar.
const kontoVag = vag.match(/^\/api\/anvandare\/([0-9a-fA-F-]{36})\/(avaktivera|aktivera)$/);
if (req.method === "POST" && kontoVag) {
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
const [, id, atgard] = kontoVag;
if (id === anspr.sub) {
return svara(res, 400, { error: "Du kan inte stänga av ditt eget konto." });
}
const aktivera = atgard === "aktivera";
const rad = await pool.query(
`update anvandare
set aktiv = $3,
token_version = token_version + case when $3 then 0 else 1 end
where id = $1 and organisation_id = $2
returning id, namn, aktiv`,
[id, anspr.org, aktivera],
);
if (rad.rowCount === 0) return svara(res, 404, { error: "Användaren finns inte." });
return svara(res, 200, rad.rows[0]);
}
// Logga ut på alla enheter — den egna vägen ut när en telefon
// tappats bort. Höjer den egna token-versionen.
if (req.method === "POST" && vag === "/api/auth/logga-ut-alla") {
await pool.query(
`update anvandare set token_version = token_version + 1 where id = $1 and organisation_id = $2`,
[anspr.sub, anspr.org],
);
return svara(res, 200, { ok: true });
}
// ECM Knowledge Library: aktuellt regelpaket för inloggade klienter.
if (req.method === "GET" && vag === "/api/ecm/regler") {
res.writeHead(200, {