Å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:
@@ -108,6 +108,33 @@ upp dess CRD redan vid plan. I `extern` läge kör ni
|
||||
`infra/postgres-init.sql` mot databasen själva; det är samma fil som
|
||||
integrationstestet kör.
|
||||
|
||||
## Åtkomst: spärr och återkallelse
|
||||
|
||||
En giltig JWT-signatur räcker inte. Varje autentiserat anrop slår upp
|
||||
kontot och kontrollerar två saker till: att det fortfarande är aktivt och
|
||||
att token-versionen stämmer. Det kostar ett uppslag på primärnyckeln per
|
||||
anrop och ger i gengäld **omedelbar** återkallelse i stället för att en
|
||||
avstängning börjar gälla först när token går ut om upp till tolv timmar.
|
||||
|
||||
| Situation | Väg | Effekt |
|
||||
| --- | --- | --- |
|
||||
| Någon slutar | `POST /api/anvandare/{id}/avaktivera` (admin) | Inloggning stängs och pågående sessioner upphör direkt |
|
||||
| Kontot ska tillbaka | `POST /api/anvandare/{id}/aktivera` (admin) | Kan logga in igen; tidigare återkallade tokens förblir döda |
|
||||
| Telefon borttappad | `POST /api/auth/logga-ut-alla` (sig själv) | Alla enheter loggas ut |
|
||||
|
||||
En administratör kan inte stänga av sig själv, och gränsen mellan
|
||||
organisationer gäller — org B kan inte röra org A:s användare.
|
||||
Händelseloggen rörs aldrig: historiken är fortfarande knuten till
|
||||
personen som utförde arbetet.
|
||||
|
||||
**Takt-begränsning på inloggning** ligger i databasen, inte i minnet, 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 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; rader äldre än ett dygn städas bort i
|
||||
skrivvägen.
|
||||
|
||||
## Multi-tenant och roller
|
||||
|
||||
Enligt Master Prompt: varje kund är en egen tenant, ingen data blandas mellan kunder.
|
||||
|
||||
@@ -55,6 +55,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
|
||||
| Instrumentavläsning (visual-first) | ✅ Kameran som universellt gränssnitt: `📷 Instrument` i Dokumentera-panelen fotograferar multimetrar, diagnosskärmar, batteritestare m.m. — bildtolkningen identifierar instrumenttyp och extraherar värden/enheter/felkoder med konfidens per värde; teknikern bekräftar innan något loggas. Originalbilden loggas alltid tillsammans med de strukturerade mätvärdena — strukturerad data ersätter aldrig originalevidensen. Ingen integration mot diagnossystem krävs. |
|
||||
| Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. Utskriften går genom ECM-kvalitetsgrinden. |
|
||||
| Märkesspecifika kopplingar | ✅ Verkstaden konfigurerar sina egna OEM-/fordonsdataleverantörer under Inställningar med sina egna credentials ([moduler/markesspecifika-kopplingar.md](moduler/markesspecifika-kopplingar.md)): uppgifterna krypteras med AES-256-GCM i vila, returneras alltid maskerade (`••••3456`) och **alla uppslag görs av servern** — leverantörsnycklar når aldrig webbläsaren. Endast systemadministratören hanterar dem, kopplingarna är organisationsknutna och saknas krypteringsnyckeln sparas ingenting alls (fail closed). Leverantörer är data, inte kod: URL-mall, autentiseringstyp (bearer/header/basic/query) och svarsmappning beskrivs i `integrationer.json` (ConfigMap-utbytbar via `INTEGRATIONER_FIL`) — nya märken läggs till utan ombyggnad. Varje uppslag loggar teststatus, så ett utgånget abonnemang syns i inställningarna i stället för att ge tysta tomma svar. Verifierat i integrationstestet (rollstyrning, maskering, kryptering i databasen, organisationsisolering, fail closed). |
|
||||
| Åtkomstkontroll | ✅ **Återkallelse är omedelbar**: varje autentiserat anrop kontrollerar att kontot är aktivt och att token-versionen stämmer, i stället för att en avstängning börjar gälla när token går ut. Administratören stänger av och öppnar konton i användarlistan (kan inte stänga av sig själv, aldrig över organisationsgränsen), och var och en kan logga ut på alla enheter när en telefon tappats bort. **Takt-begränsning på inloggning** ligger i databasen och håller därför bakom flera repliker: 10 försök per konto och 30 per källadress inom 15 minuter, och spärren gäller kontot även vid rätt lösenord. Samtliga gränser verifierade i integrationstestet. |
|
||||
| Infrastruktur som kod | ✅ `infra/terraform` är systemets definition ([README](../infra/terraform/README.md)): `karta.tf` beskriver hela systemet en gång som data — tjänster, portar, routing, hemligheter per tjänst, dataflöden och gränser — och `terraform output karta` skriver ut samma sak i klartext. Namnrymden är stängd med nätverkspolicyer (bara ingress→tjänster, plattform→postgres, HTTPS ut utom privata nät), Postgres kör med säkerhetskontext, hemligheter kan genereras eller komma från en secrets-hanterare. **Databasen har tre lägen** och `databas_lage` saknar standardvärde med flit — valet avgör om det finns säkerhetskopiering: `extern` (managerad Postgres, leverantörens PITR — rekommenderat i produktion), `cnpg` (CloudNativePG i klustret: basbackup 02:30 + WAL-arkivering + failover) och `inbyggd` (en volym, ingen backup, spärrad av en precondition när miljön är produktion). Driftsättning är ett eget CI-flöde som startas för hand med en bildtagg mot en miljö med godkännandekrav, kör plan/apply, skriver ut kartan och rökkontrollerar. Kustomize-/Argo CD-vägen är borttagen. |
|
||||
| Öppet API | ✅ Plattforms-API:t är dokumenterat med OpenAPI 3.0 (`services/plattform/openapi.yaml`) — auth, användare, ärenden/händelser (append-only), översikt, publik delning och AI-orkestern, med scheman för alla händelsetyper. Specen valideras maskinellt, paritetstestas mot serverns rutter och serveras live på `GET /api/openapi.yaml`. |
|
||||
|
||||
|
||||
@@ -39,8 +39,32 @@ create table if not exists anvandare (
|
||||
namn text not null,
|
||||
roll text not null default 'tekniker'
|
||||
check (roll in ('tekniker', 'arbetsledare', 'admin')),
|
||||
-- En avaktiverad användare kan varken logga in eller använda en token
|
||||
-- som redan är utfärdad.
|
||||
aktiv boolean not null default true,
|
||||
-- Räknare för återkallelse. Utfärdade tokens bär sitt värde; höjs det
|
||||
-- slutar alla tidigare utfärdade att gälla omedelbart. Loggen rörs
|
||||
-- inte — historiken är fortfarande knuten till personen.
|
||||
token_version integer not null default 0,
|
||||
skapad timestamptz not null default now()
|
||||
);
|
||||
alter table anvandare add column if not exists aktiv boolean not null default true;
|
||||
alter table anvandare add column if not exists token_version integer not null default 0;
|
||||
|
||||
-- Inloggningsförsök: underlag för takt-begränsning som fungerar bakom
|
||||
-- flera repliker (den i minnet gör det inte). Inget lösenord lagras —
|
||||
-- bara att ett försök skedde och om det lyckades.
|
||||
create table if not exists inloggningsforsok (
|
||||
id bigserial primary key,
|
||||
epost text not null,
|
||||
kalla text not null default '',
|
||||
lyckades boolean not null,
|
||||
tidpunkt timestamptz not null default now()
|
||||
);
|
||||
create index if not exists inloggningsforsok_epost_idx
|
||||
on inloggningsforsok (epost, tidpunkt desc);
|
||||
create index if not exists inloggningsforsok_kalla_idx
|
||||
on inloggningsforsok (kalla, tidpunkt desc);
|
||||
|
||||
create table if not exists felsokning_arenden (
|
||||
id text primary key,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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}:`);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 på personens enheter upphör direkt.
|
||||
</p>
|
||||
<form
|
||||
className="mt-3"
|
||||
onSubmit={async (e) => {
|
||||
|
||||
Reference in New Issue
Block a user