diff --git a/docs/DRIFT.md b/docs/DRIFT.md index 01a6753..55f4a46 100644 --- a/docs/DRIFT.md +++ b/docs/DRIFT.md @@ -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. diff --git a/docs/MVP.md b/docs/MVP.md index 9f704ce..f1109dd 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -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`. | diff --git a/infra/postgres-init.sql b/infra/postgres-init.sql index 62d8dee..99b3c8f 100644 --- a/infra/postgres-init.sql +++ b/infra/postgres-init.sql @@ -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, diff --git a/services/plattform/integrationstest.sh b/services/plattform/integrationstest.sh index bed9d91..908582f 100755 --- a/services/plattform/integrationstest.sh +++ b/services/plattform/integrationstest.sh @@ -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 diff --git a/services/plattform/openapi.yaml b/services/plattform/openapi.yaml index af8af79..c674b97 100644 --- a/services/plattform/openapi.yaml +++ b/services/plattform/openapi.yaml @@ -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] diff --git a/services/plattform/server.mjs b/services/plattform/server.mjs index acc1bc5..ccc5392 100644 --- a/services/plattform/server.mjs +++ b/services/plattform/server.mjs @@ -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, { diff --git a/src/felsokning/__tests__/ai.test.ts b/src/felsokning/__tests__/ai.test.ts index 12351bc..8487f9a 100644 --- a/src/felsokning/__tests__/ai.test.ts +++ b/src/felsokning/__tests__/ai.test.ts @@ -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}:`); diff --git a/src/felsokning/plattform.ts b/src/felsokning/plattform.ts index 65ddc35..94348f8 100644 --- a/src/felsokning/plattform.ts +++ b/src/felsokning/plattform.ts @@ -74,6 +74,7 @@ export interface PlattformAnvandare { epost: string; namn: string; roll: PlattformRoll; + aktiv: boolean; } export async function hamtaAnvandare(): Promise { @@ -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 { + 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 { + 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"; diff --git a/src/pages/felsokning/Arendelista.tsx b/src/pages/felsokning/Arendelista.tsx index 9c72cd7..64af93e 100644 --- a/src/pages/felsokning/Arendelista.tsx +++ b/src/pages/felsokning/Arendelista.tsx @@ -10,6 +10,7 @@ import { plattformAktiv, plattformKonto, registreraPlattform, + sattKontoAktiv, skapaAnvandare, type PlattformAnvandare, type PlattformKonto, @@ -173,11 +174,43 @@ function AnvandarAdmin() { return ( {lista.map((anv) => ( -

- {anv.namn} · {ROLL_LABEL[anv.roll]}{" "} - {anv.epost} -

+
+

+ + {anv.namn} + {" "} + · {ROLL_LABEL[anv.roll]} {anv.epost} + {anv.aktiv === false && ( + Avstängd + )} +

+ +
))} +

+ En avstängning gäller omedelbart — pågående sessioner på personens enheter upphör direkt. +

{