diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 371f06459..038b983a6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ on: push: env: - go-version: '1.20' + go-version: '1.25' node-version: 8 artifacts-retention-days: 5 diff --git a/deploy/ansible/roles/icarodb/files/icaro.sql b/deploy/ansible/roles/icarodb/files/icaro.sql index e02dbf371..dd7052b08 100644 --- a/deploy/ansible/roles/icarodb/files/icaro.sql +++ b/deploy/ansible/roles/icarodb/files/icaro.sql @@ -3,6 +3,7 @@ CREATE TABLE `accounts` ( `id` serial, `creator_id` bigint unsigned NOT NULL, `uuid` varchar(200), + `logto_org_id` varchar(64) DEFAULT NULL, `type` varchar(200) NOT NULL, `name` varchar(200) NOT NULL, `username` varchar (200) NOT NULL, @@ -17,6 +18,7 @@ CREATE TABLE `accounts` ( `created` datetime, UNIQUE KEY (`username`), UNIQUE KEY (`uuid`), + UNIQUE KEY (`logto_org_id`), KEY(`username`), KEY(`uuid`), PRIMARY KEY(`id`) diff --git a/deploy/ansible/roles/sun/templates/sun-api-conf.j2 b/deploy/ansible/roles/sun/templates/sun-api-conf.j2 index 4c486517d..d2419ab2a 100644 --- a/deploy/ansible/roles/sun/templates/sun-api-conf.j2 +++ b/deploy/ansible/roles/sun/templates/sun-api-conf.j2 @@ -9,7 +9,7 @@ "token_expires_days": 1, "cors": { "origins": ["https://{{ icaro.hostname }}"], - "headers": ["Token", "Content-Type"], + "headers": ["Token", "Content-Type", "Authorization"], "methods": ["GET", "PUT", "POST", "DELETE"] }, "captive_portal": { @@ -103,5 +103,22 @@ icaro.sun_api.email_smtp_password is defined %} "verb": "POST", "endpoint": "/api/users" }] + }, + "oidc": { + "issuer": "{{ icaro.sun_api.oidc_issuer | default('') }}", + "client_id": "{{ icaro.sun_api.oidc_client_id | default('') }}", + "client_secret": "{{ icaro.sun_api.oidc_client_secret | default('') }}", + "redirect_uri": "{{ icaro.sun_api.oidc_redirect_uri | default('http://localhost:8080/api/auth/oidc/callback') }}", + "frontend_url": "{{ icaro.sun_api.oidc_frontend_url | default('http://localhost:8081') }}", + "scopes": {{ icaro.sun_api.oidc_scopes | default(['openid', 'profile', 'email', 'roles', 'urn:logto:scope:organizations', 'urn:logto:scope:organization_roles']) | to_json }}, + "role_mapping": {{ icaro.sun_api.oidc_role_mapping | default(['super admin:admin']) | to_json }}, + "org_admin_role": "{{ icaro.sun_api.oidc_org_admin_role | default('Admin') }}", + "org_reseller_role": "{{ icaro.sun_api.oidc_org_reseller_role | default('Reseller') }}", + "my_api_url": "{{ icaro.sun_api.oidc_my_api_url | default('') }}", + "my_api_key": "{{ icaro.sun_api.oidc_my_api_key | default('') }}", + "default_subscription_plan_id": {{ icaro.sun_api.oidc_default_subscription_plan_id | default(4) }}, + "userinfo_audience": "{{ icaro.sun_api.oidc_userinfo_audience | default('') }}", + "userinfo_origins": {{ icaro.sun_api.oidc_userinfo_origins | default(['https://my.nethesis.it', 'https://beta.my.nethesis.it']) | to_json }}, + "show_login_button": {{ icaro.sun_api.oidc_show_login_button | default(true) | lower }} } } diff --git a/go.mod b/go.mod index 8488c04ea..a3c6e0358 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,43 @@ module github.com/nethesis/icaro -go 1.15 +go 1.25.0 require ( github.com/appleboy/gofight/v2 v2.1.2 github.com/avct/uasurfer v0.0.0-20180817072212-dc0ec4fd1e87 + github.com/coreos/go-oidc/v3 v3.19.0 github.com/fatih/structs v1.1.0 github.com/gin-contrib/cors v1.3.0 github.com/gin-gonic/gin v1.7.7 github.com/jinzhu/gorm v1.9.8 github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 github.com/satori/go.uuid v1.2.1-0.20180103174451-36e9d2ebbde5 - github.com/stretchr/testify v1.4.0 - gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + github.com/stretchr/testify v1.7.0 + golang.org/x/oauth2 v0.36.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df ) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-playground/locales v0.13.0 // indirect + github.com/go-playground/universal-translator v0.17.0 // indirect + github.com/go-playground/validator/v10 v10.4.1 // indirect + github.com/go-sql-driver/mysql v1.4.1 // indirect + github.com/golang/protobuf v1.3.3 // indirect + github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a // indirect + github.com/json-iterator/go v1.1.9 // indirect + github.com/leodido/go-urn v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/ugorji/go/codec v1.1.7 // indirect + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect + golang.org/x/sys v0.0.0-20200116001909-b77594299b42 // indirect + google.golang.org/appengine v1.4.0 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/yaml.v2 v2.2.8 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) diff --git a/go.sum b/go.sum index 679fd1c69..50a138834 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/avct/uasurfer v0.0.0-20180817072212-dc0ec4fd1e87 h1:TUuuMI5Sxsw5IVVu3 github.com/avct/uasurfer v0.0.0-20180817072212-dc0ec4fd1e87/go.mod h1:noBAuukeYOXa0aXGqxr24tADqkwDO2KRD15FsuaZ5a8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I= +github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -35,6 +37,8 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs= github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= @@ -59,7 +63,6 @@ github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -130,10 +133,10 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= -github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= @@ -159,6 +162,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -208,6 +213,8 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 000000000..c60d3b3a1 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,144 @@ +# OIDC organization bootstrap + +`oidc_org_bootstrap.py` links existing Icaro **reseller** accounts to their +My (Logto) organization by generating the `UPDATE` statements that populate +`accounts.logto_org_id`. That column is the **only** link the OIDC login +uses at runtime: no email/VAT matching ever happens during login. This +script is where the (reviewed, one-time) matching happens instead. + +Identity chain used for matching: + +``` +icaro accounts.uuid == old-my (noc) reseller.uuid +noc reseller --(uuid / piva / normalized company name)--> new-my reseller org +``` + +The script is **offline** (CSV in, SQL + reports out — it never touches a +database) and **idempotent**: every generated `UPDATE` carries +`AND logto_org_id IS NULL`, so re-running and re-applying only adds new +links and never rewrites existing ones. + +## Prerequisites + +1. The `accounts.logto_org_id` column must exist (new installs get it from + `deploy/ansible/roles/icarodb/files/icaro.sql`; existing databases need): + + ```sql + ALTER TABLE accounts + ADD COLUMN logto_org_id varchar(64) DEFAULT NULL AFTER uuid, + ADD UNIQUE KEY logto_org_id (logto_org_id); + ``` + +2. Read access to the three data sources: the Icaro MariaDB, the old My + (noc) MariaDB, and the new My PostgreSQL. + +## Step 1 — extract the input files + +Both TSV (`mysql -B` output) and CSV are accepted; the delimiter is +auto-detected. Keep the headers exactly as below. + +**Icaro accounts** (only `type='reseller'`; desk/customer accounts are +internal to Icaro and must never be linked): + +```bash +mysql -B icaro -e "SELECT id, IFNULL(uuid,'') AS uuid, username, IFNULL(email,'') AS email + FROM accounts WHERE type='reseller';" > icaro_accounts.tsv +``` + +**Old My resellers** (noc): + +```bash +mysql -B noc -e "SELECT r.uuid, IFNULL(r.piva,'') AS piva, IFNULL(r.company,'') AS company, + u.username, u.email + FROM reseller r JOIN User u ON u.id = r.user_id + WHERE r.deleted IS NULL;" > noc_resellers.tsv +``` + +**New My reseller organizations** (PostgreSQL; use `psql` so the JSON +`custom_data` is quoted correctly): + +```bash +psql "$DATABASE_URL" -c "\copy (SELECT COALESCE(logto_id,'') AS logto_id, name, + COALESCE(custom_data::text,'{}') AS custom_data + FROM resellers WHERE deleted_at IS NULL) + TO 'my_orgs.csv' WITH CSV HEADER" +``` + +## Step 2 — dry run + +```bash +python3 scripts/oidc_org_bootstrap.py \ + --icaro-accounts icaro_accounts.tsv \ + --noc-resellers noc_resellers.tsv \ + --my-orgs my_orgs.csv \ + --output-dir bootstrap-out +``` + +Nothing is written to any database. Output files: + +| File | Content | Action | +|---|---|---| +| `matched.sql` | one `UPDATE` per linked account, with account → org and match method (`uuid`/`piva`/`name`) as a comment | review, then apply | +| `report_matched.csv` | same matches in tabular form | review | +| `report_icaro_only.csv` | accounts with no old-My reseller (self-registered / Icaro-only) | none: they keep the classic password login | +| `report_no_my_org.csv` | old-My resellers whose organization is not on the new My yet | re-run after the next org import batch | +| `report_ambiguous.csv` | multiple candidate orgs, or one org claimed by multiple accounts | resolve by hand | + +## Step 3 — review and apply + +Read `matched.sql` (especially any `name`-method matches, which are the +loosest) and the ambiguous report, then: + +```bash +mysql icaro < bootstrap-out/matched.sql +``` + +## Re-running (important) + +Run steps 1–3 again **after every batch of organization imports on the new +My**. If a reseller's organization exists on My but the bootstrap has not +linked it yet, their first OIDC login triggers JIT provisioning and creates +a *new empty* account instead of linking the historical one (runtime +deliberately performs no heuristic matching). Keeping import and bootstrap +close together minimizes that window; the section below covers the cleanup +when a duplicate slips through. + +Apply with `--force` so one conflict does not stop the rest, and read the +errors — they are the duplicate detector: + +```bash +mysql --force icaro < bootstrap-out/matched.sql +``` + +A `Duplicate entry ... for key 'logto_org_id'` error means that org was +already claimed by a JIT-created duplicate: reconcile it as below. + +## Reconciling a JIT duplicate + +Symptom: a reseller logged in via My before the bootstrap linked their +historical account. JIT created a fresh empty account (recognizable: +`username = logto_org_id`, journal line `OIDC provisioning: created company +account ...`), while the historical account still has the hotspots. + +```sql +-- 0. find the two accounts +SELECT id, username, name, logto_org_id, created FROM accounts + WHERE logto_org_id = '' OR (name LIKE '%%' AND type = 'reseller'); + +-- 1. make sure the duplicate is empty (otherwise it needs a manual merge) +SELECT COUNT(*) FROM hotspots WHERE account_id = ; + +-- 2. move the link to the historical account +UPDATE accounts SET logto_org_id = NULL WHERE id = ; +UPDATE accounts SET logto_org_id = '' WHERE id = AND logto_org_id IS NULL; + +-- 3. drop the empty duplicate +DELETE FROM access_tokens WHERE account_id = ; +DELETE FROM subscriptions WHERE account_id = ; +DELETE FROM account_sms_counts WHERE account_id = ; +DELETE FROM accounts WHERE id = ; +``` + +The reseller's next login lands on the historical account. If step 1 shows +hotspots in the duplicate, do **not** delete it: move its hotspots to the +historical account first (or escalate — that is a merge, not a cleanup). diff --git a/scripts/oidc_org_bootstrap.py b/scripts/oidc_org_bootstrap.py new file mode 100644 index 000000000..2e2b674b5 --- /dev/null +++ b/scripts/oidc_org_bootstrap.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +One-shot bootstrap: link existing Icaro company accounts to My (Logto) +organizations by writing accounts.logto_org_id. + +Identity chain (no email heuristics at runtime): + + icaro accounts.uuid == old-my (noc) reseller.uuid + noc reseller --(uuid / piva / company name)--> new-my reseller org + +Inputs (CSV with header): + + --icaro-accounts id,uuid,username,email + mysql icaro: SELECT id, uuid, username, email FROM accounts + WHERE type='reseller' AND logto_org_id IS NULL; + + --noc-resellers uuid,piva,company,username,email + mysql noc: SELECT r.uuid, r.piva, r.company, u.username, u.email + FROM reseller r JOIN User u ON u.id = r.user_id + WHERE r.deleted IS NULL; + + --my-orgs logto_id,name,custom_data + psql my: SELECT logto_id, name, custom_data FROM resellers + WHERE deleted_at IS NULL; + (custom_data as JSON text) + +Outputs (in --output-dir, default ./bootstrap-out): + + matched.sql UPDATE statements to apply (review first!) + report_matched.csv account -> org with match method + report_icaro_only.csv nethspot-only accounts (no noc reseller): keep classic login + report_no_my_org.csv noc reseller found, but no org on new my + report_ambiguous.csv multiple candidate orgs, resolve by hand + +Nothing is written to any database: apply matched.sql manually. +""" + +import argparse +import csv +import json +import os +import re +import sys +from collections import defaultdict + + +def norm_name(s): + """Normalize a company name for loose comparison.""" + s = (s or "").lower() + s = re.sub(r"\b(s\.?r\.?l\.?s?|s\.?p\.?a\.?|s\.?n\.?c\.?|s\.?a\.?s\.?|srls|unipersonale)\b", "", s) + return re.sub(r"[^a-z0-9]", "", s) + + +def load_csv(path): + """Load a CSV or TSV file (mysql -B output works as-is).""" + with open(path, newline="", encoding="utf-8", errors="replace") as f: + header = f.readline() + delimiter = "\t" if "\t" in header else "," + f.seek(0) + return list(csv.DictReader(f, delimiter=delimiter)) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--icaro-accounts", required=True) + ap.add_argument("--noc-resellers", required=True) + ap.add_argument("--my-orgs", required=True) + ap.add_argument("--output-dir", default="bootstrap-out") + args = ap.parse_args() + + accounts = load_csv(args.icaro_accounts) + noc = load_csv(args.noc_resellers) + orgs = load_csv(args.my_orgs) + + noc_by_uuid = {r["uuid"].strip(): r for r in noc if r.get("uuid", "").strip()} + + # Index my orgs by the legacy keys found in custom_data (raw scan: the + # exact key names vary between migration batches) and by normalized name + orgs_by_token = defaultdict(list) # token (uuid/piva found in custom_data) -> orgs + orgs_by_name = defaultdict(list) + for org in orgs: + raw = (org.get("custom_data") or "").lower() + try: + cd = json.loads(org.get("custom_data") or "{}") + except ValueError: + cd = {} + tokens = set(re.findall(r"[0-9a-f]{8}-[0-9a-f-]{10,}[0-9a-f]|\b\d{9,11}\b", raw)) + for v in cd.values(): + if isinstance(v, str) and v.strip(): + tokens.add(v.strip().lower()) + for t in tokens: + orgs_by_token[t].append(org) + orgs_by_name[norm_name(org.get("name"))].append(org) + + matched, icaro_only, no_my_org, ambiguous = [], [], [], [] + used_orgs = defaultdict(list) # logto_id -> accounts (detect N:1 collisions) + + for acc in accounts: + uuid = (acc.get("uuid") or "").strip() + noc_r = noc_by_uuid.get(uuid) + if not noc_r: + icaro_only.append(acc) + continue + + candidates, method = [], None + for key, m in ((uuid.lower(), "uuid"), ((noc_r.get("piva") or "").strip().lower(), "piva")): + if key and orgs_by_token.get(key): + candidates, method = orgs_by_token[key], m + break + if not candidates: + nname = norm_name(noc_r.get("company") or acc.get("username")) + if nname and orgs_by_name.get(nname): + candidates, method = orgs_by_name[nname], "name" + + uniq = {o["logto_id"]: o for o in candidates} + if len(uniq) == 1: + org = next(iter(uniq.values())) + matched.append((acc, org, method)) + used_orgs[org["logto_id"]].append(acc) + elif len(uniq) > 1: + ambiguous.append((acc, list(uniq.values()), method)) + else: + no_my_org.append((acc, noc_r)) + + # An org linked to more than one account is a conflict: move to ambiguous + conflicts = {oid for oid, accs in used_orgs.items() if len(accs) > 1} + matched, dropped = [m for m in matched if m[1]["logto_id"] not in conflicts], [m for m in matched if m[1]["logto_id"] in conflicts] + for acc, org, method in dropped: + ambiguous.append((acc, [org], method + " (org linked by multiple accounts)")) + + os.makedirs(args.output_dir, exist_ok=True) + + def out(name): + return open(os.path.join(args.output_dir, name), "w", newline="", encoding="utf-8") + + with out("matched.sql") as f: + f.write("-- Review before applying: mysql icaro < matched.sql\n") + for acc, org, method in matched: + f.write( + "UPDATE accounts SET logto_org_id = '%s' WHERE id = %s AND logto_org_id IS NULL; -- %s -> %s [%s]\n" + % (org["logto_id"], acc["id"], acc.get("username"), org.get("name"), method) + ) + + with out("report_matched.csv") as f: + w = csv.writer(f) + w.writerow(["account_id", "username", "email", "logto_org_id", "org_name", "method"]) + for acc, org, method in matched: + w.writerow([acc["id"], acc.get("username"), acc.get("email"), org["logto_id"], org.get("name"), method]) + + with out("report_icaro_only.csv") as f: + w = csv.writer(f) + w.writerow(["account_id", "username", "email"]) + for acc in icaro_only: + w.writerow([acc["id"], acc.get("username"), acc.get("email")]) + + with out("report_no_my_org.csv") as f: + w = csv.writer(f) + w.writerow(["account_id", "username", "noc_company", "noc_piva", "noc_email"]) + for acc, noc_r in no_my_org: + w.writerow([acc["id"], acc.get("username"), noc_r.get("company"), noc_r.get("piva"), noc_r.get("email")]) + + with out("report_ambiguous.csv") as f: + w = csv.writer(f) + w.writerow(["account_id", "username", "method", "candidate_orgs"]) + for acc, cands, method in ambiguous: + w.writerow([acc["id"], acc.get("username"), method, "; ".join("%s (%s)" % (o["logto_id"], o.get("name")) for o in cands)]) + + print("accounts: %d" % len(accounts)) + print("matched: %d -> matched.sql" % len(matched)) + print("icaro-only: %d (classic login, untouched)" % len(icaro_only)) + print("no my org: %d (on noc but not migrated to new my)" % len(no_my_org)) + print("ambiguous: %d (resolve by hand)" % len(ambiguous)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sun/sun-api/configuration/configuration.go b/sun/sun-api/configuration/configuration.go index 633d464e3..542428c42 100644 --- a/sun/sun-api/configuration/configuration.go +++ b/sun/sun-api/configuration/configuration.go @@ -53,6 +53,39 @@ type Configuration struct { Origins []string `json:"origins"` Methods []string `json:"methods"` } `json:"cors"` + OIDC struct { + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + RedirectURI string `json:"redirect_uri"` + FrontendURL string `json:"frontend_url"` + Scopes []string `json:"scopes"` + RoleMapping []string `json:"role_mapping"` + // Company (organization) login: users that hold the OrgAdminRole + // global role on My and belong to an organization whose + // organization-role is OrgResellerRole log in as the Icaro account + // linked to that organization via accounts.logto_org_id + OrgAdminRole string `json:"org_admin_role"` + OrgResellerRole string `json:"org_reseller_role"` + // My API access for JIT provisioning of company accounts: + // if unset, only pre-linked organizations can log in + MyAPIURL string `json:"my_api_url"` + MyAPIKey string `json:"my_api_key"` + DefaultSubscriptionPlanID int `json:"default_subscription_plan_id"` + // Audience expected on the ID token presented to /api/userinfo + // (My's own Logto app id); empty = audience check disabled + UserinfoAudience string `json:"userinfo_audience"` + // Browser origins allowed to call /api/userinfo cross-origin (the + // My dashboard, per environment). Separate from the Sun frontend + // CORS above: only /api/userinfo honors these. + UserinfoOrigins []string `json:"userinfo_origins"` + // Whether the "Login with My Nethesis" button is shown on the + // Sun login page. When false the OIDC endpoints stay fully + // functional (so the entry from the My dashboard keeps working), + // only the button is hidden — used for a soft launch where the + // flow is reachable from My but not advertised on Sun itself. + ShowLoginButton *bool `json:"show_login_button"` + } `json:"oidc"` Disclaimers struct { TermsOfUse string `json:"terms_of_use"` MarketingUse string `json:"marketing_use"` @@ -223,6 +256,75 @@ func Init(ConfigFilePtr *string) { Config.Survey.Url = os.Getenv("SURVEY_URL") } + if os.Getenv("OIDC_ISSUER") != "" { + Config.OIDC.Issuer = os.Getenv("OIDC_ISSUER") + } + if os.Getenv("OIDC_CLIENT_ID") != "" { + Config.OIDC.ClientID = os.Getenv("OIDC_CLIENT_ID") + } + if os.Getenv("OIDC_CLIENT_SECRET") != "" { + Config.OIDC.ClientSecret = os.Getenv("OIDC_CLIENT_SECRET") + } + if os.Getenv("OIDC_REDIRECT_URI") != "" { + Config.OIDC.RedirectURI = os.Getenv("OIDC_REDIRECT_URI") + } + if os.Getenv("OIDC_FRONTEND_URL") != "" { + Config.OIDC.FrontendURL = os.Getenv("OIDC_FRONTEND_URL") + } + if os.Getenv("OIDC_SCOPES") != "" { + // scope names never contain spaces or commas: accept both separators + Config.OIDC.Scopes = strings.FieldsFunc(os.Getenv("OIDC_SCOPES"), func(r rune) bool { + return r == ' ' || r == ',' + }) + } + if os.Getenv("OIDC_ROLE_MAPPING") != "" { + // comma-separated only: role names may contain spaces ("super admin:admin") + var roleMappings []string + for _, mapping := range strings.Split(os.Getenv("OIDC_ROLE_MAPPING"), ",") { + if trimmed := strings.TrimSpace(mapping); trimmed != "" { + roleMappings = append(roleMappings, trimmed) + } + } + Config.OIDC.RoleMapping = roleMappings + } + if os.Getenv("OIDC_ORG_ADMIN_ROLE") != "" { + Config.OIDC.OrgAdminRole = os.Getenv("OIDC_ORG_ADMIN_ROLE") + } + if os.Getenv("OIDC_ORG_RESELLER_ROLE") != "" { + Config.OIDC.OrgResellerRole = os.Getenv("OIDC_ORG_RESELLER_ROLE") + } + if os.Getenv("OIDC_USERINFO_AUDIENCE") != "" { + Config.OIDC.UserinfoAudience = os.Getenv("OIDC_USERINFO_AUDIENCE") + } + if os.Getenv("OIDC_USERINFO_ORIGINS") != "" { + // space or comma separated list of allowed My dashboard origins + Config.OIDC.UserinfoOrigins = strings.FieldsFunc(os.Getenv("OIDC_USERINFO_ORIGINS"), func(r rune) bool { + return r == ' ' || r == ',' + }) + } + if os.Getenv("OIDC_SHOW_LOGIN_BUTTON") != "" { + showButton, _ := strconv.ParseBool(os.Getenv("OIDC_SHOW_LOGIN_BUTTON")) + Config.OIDC.ShowLoginButton = &showButton + } + if os.Getenv("OIDC_MY_API_URL") != "" { + Config.OIDC.MyAPIURL = os.Getenv("OIDC_MY_API_URL") + } + if os.Getenv("OIDC_MY_API_KEY") != "" { + Config.OIDC.MyAPIKey = os.Getenv("OIDC_MY_API_KEY") + } + if os.Getenv("OIDC_DEFAULT_SUBSCRIPTION_PLAN_ID") != "" { + Config.OIDC.DefaultSubscriptionPlanID, _ = strconv.Atoi(os.Getenv("OIDC_DEFAULT_SUBSCRIPTION_PLAN_ID")) + } + + // Defaults for company OIDC login: on My "Admin" is the global user + // role and the organization-role encodes the organization type + if Config.OIDC.OrgAdminRole == "" { + Config.OIDC.OrgAdminRole = "Admin" + } + if Config.OIDC.OrgResellerRole == "" { + Config.OIDC.OrgResellerRole = "Reseller" + } + Config.CaptivePortal.LogoContents = "" if _, err := os.Stat(Config.CaptivePortal.Logo); err == nil { if data, errRead := ioutil.ReadFile(Config.CaptivePortal.Logo); errRead == nil { diff --git a/sun/sun-api/main.go b/sun/sun-api/main.go index 9033491e8..43fc2ab4d 100644 --- a/sun/sun-api/main.go +++ b/sun/sun-api/main.go @@ -36,12 +36,41 @@ import ( ) func DefineAPI(router *gin.Engine) { - // cors + // Global CORS for the Sun frontend talking to its own API corsConf := cors.DefaultConfig() corsConf.AllowOrigins = configuration.Config.Cors.Origins corsConf.AllowHeaders = configuration.Config.Cors.Headers corsConf.AllowMethods = configuration.Config.Cors.Methods - router.Use(cors.New(corsConf)) + globalCORS := cors.New(corsConf) + + // /api/userinfo is called cross-origin by the My dashboard widget with + // its own allowlist (the My origins per environment), distinct from the + // Sun frontend CORS above. Handle it here — reflecting the matched + // origin, answering the OPTIONS preflight with 204 — so the global + // middleware never rejects those origins. The Bearer token check in the + // handler is unchanged: CORS is additive, not a substitute for auth. + userinfoOrigins := configuration.Config.OIDC.UserinfoOrigins + router.Use(func(c *gin.Context) { + if c.Request.URL.Path != "/api/userinfo" { + globalCORS(c) + return + } + origin := c.GetHeader("Origin") + for _, allowed := range userinfoOrigins { + if origin != "" && origin == allowed { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Vary", "Origin") + c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type") + c.Header("Access-Control-Allow-Methods", "GET, OPTIONS") + break + } + } + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + }) health := router.Group("/health") health.GET("/check", methods.HealthCheck) @@ -50,6 +79,13 @@ func DefineAPI(router *gin.Engine) { api.POST("/login", methods.Login) api.POST("/logout", methods.Logout) + api.GET("/userinfo", methods.UserInfo) + api.GET("/auth/oidc/config", methods.GetOIDCConfig) + api.GET("/auth/oidc/login", methods.OIDCLogin) + api.GET("/auth/oidc/callback", methods.OIDCCallback) + api.POST("/auth/oidc/exchange", methods.OIDCExchange) + api.POST("/auth/oidc/device/start", methods.OIDCDeviceStart) + api.POST("/auth/oidc/device/poll", methods.OIDCDevicePoll) api.Use(middleware.AAWall) { diff --git a/sun/sun-api/methods/authentication.go b/sun/sun-api/methods/authentication.go index 3606ab3f8..068472461 100644 --- a/sun/sun-api/methods/authentication.go +++ b/sun/sun-api/methods/authentication.go @@ -23,14 +23,24 @@ package methods import ( + "context" "crypto/md5" + "crypto/rand" "crypto/sha256" + "encoding/base64" + "encoding/json" "fmt" + "log" "net/http" + "net/url" + "strings" + "sync" "time" + "github.com/coreos/go-oidc/v3/oidc" "github.com/gin-gonic/gin" _ "github.com/jinzhu/gorm/dialects/mysql" + "golang.org/x/oauth2" "github.com/nethesis/icaro/sun/sun-api/configuration" "github.com/nethesis/icaro/sun/sun-api/database" @@ -38,6 +48,39 @@ import ( "github.com/nethesis/icaro/sun/sun-api/utils" ) +// Global cleanup context and cancel function +var ( + cleanupCtx context.Context + cleanupCancel context.CancelFunc +) + +// Cached OIDC provider: discovery and JWKS are fetched once and reused +// across logins instead of hitting the identity provider on every request +var ( + oidcProviderMu sync.Mutex + oidcProvider *oidc.Provider +) + +// getOIDCProvider returns the cached OIDC provider, initializing it on +// first use. The provider is created with a background context because it +// outlives the request that triggered its initialization. +func getOIDCProvider(issuer string) (*oidc.Provider, error) { + oidcProviderMu.Lock() + defer oidcProviderMu.Unlock() + + if oidcProvider != nil { + return oidcProvider, nil + } + + provider, err := oidc.NewProvider(context.Background(), issuer) + if err != nil { + return nil, err + } + + oidcProvider = provider + return provider, nil +} + func Login(c *gin.Context) { var account models.Account var subscription models.Subscription @@ -121,3 +164,891 @@ func Logout(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "success"}) } } + +// Name of the cookie that binds the OIDC state to the browser that +// initiated the login flow (prevents login CSRF / session fixation) +const oidcStateCookieName = "icaro_oidc_state" + +// OIDCStateData holds the per-login-attempt secrets generated at /login +// and needed again at /callback +type OIDCStateData struct { + Expires time.Time + Nonce string + PKCEVerifier string + // PairID links this login attempt to a device pairing started via + // /auth/oidc/device/start (empty for regular SPA logins) + PairID string +} + +// OIDCStateStore manages OIDC state tokens with expiration +type OIDCStateStore struct { + mu sync.RWMutex + states map[string]OIDCStateData +} + +// OIDCCodeStore manages temporary codes for secure token exchange +type OIDCCodeStore struct { + mu sync.RWMutex + codes map[string]OIDCCodeData +} + +type OIDCCodeData struct { + Token string + AccountID int + Expires time.Time +} + +// Global state store instance +var stateStore = &OIDCStateStore{ + states: make(map[string]OIDCStateData), +} + +// Global code store instance +var codeStore = &OIDCCodeStore{ + codes: make(map[string]OIDCCodeData), +} + +// maxPendingOIDCStates bounds the memory used by pending login attempts: +// above this threshold new attempts are rejected until older ones expire +// (10 minutes) or complete, so spamming /auth/oidc/login cannot grow the +// store without limit +const maxPendingOIDCStates = 10000 + +// StoreState stores a state token with its associated per-attempt data. +// Returns false if the store is full. +func (s *OIDCStateStore) StoreState(state string, data OIDCStateData) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.states) >= maxPendingOIDCStates { + // purge expired entries before giving up + now := time.Now() + for st, d := range s.states { + if now.After(d.Expires) { + delete(s.states, st) + } + } + if len(s.states) >= maxPendingOIDCStates { + return false + } + } + + s.states[state] = data + return true +} + +// ValidateAndRemoveState checks if state is valid and removes it +func (s *OIDCStateStore) ValidateAndRemoveState(state string) (OIDCStateData, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + data, exists := s.states[state] + if !exists { + return OIDCStateData{}, false + } + + // Remove the state regardless of expiration (one-time use) + delete(s.states, state) + + // Check if state has expired + if time.Now().After(data.Expires) { + return OIDCStateData{}, false + } + + return data, true +} + +// CleanupExpiredStates removes expired states (should be called periodically) +func (s *OIDCStateStore) CleanupExpiredStates() { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + for state, data := range s.states { + if now.After(data.Expires) { + delete(s.states, state) + } + } +} + +// StoreCode stores a temporary code with token and account data +func (c *OIDCCodeStore) StoreCode(code string, token string, accountID int, expiration time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + c.codes[code] = OIDCCodeData{ + Token: token, + AccountID: accountID, + Expires: expiration, + } +} + +// ValidateAndRemoveCode checks if code is valid and removes it (one-time use) +func (c *OIDCCodeStore) ValidateAndRemoveCode(code string) (OIDCCodeData, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + data, exists := c.codes[code] + if !exists { + return OIDCCodeData{}, false + } + + // Remove the code regardless of expiration (one-time use) + delete(c.codes, code) + + // Check if code has expired + if time.Now().After(data.Expires) { + return OIDCCodeData{}, false + } + + return data, true +} + +// CleanupExpiredCodes removes expired codes +func (c *OIDCCodeStore) CleanupExpiredCodes() { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + for code, data := range c.codes { + if now.After(data.Expires) { + delete(c.codes, code) + } + } +} + +// Initialize cleanup routine with graceful shutdown support +func init() { + cleanupCtx, cleanupCancel = context.WithCancel(context.Background()) + + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-cleanupCtx.Done(): + return + case <-ticker.C: + stateStore.CleanupExpiredStates() + codeStore.CleanupExpiredCodes() + pairingStore.CleanupExpiredPairings() + } + } + }() +} + +// StopCleanupRoutine stops the background cleanup goroutine gracefully +func StopCleanupRoutine() { + if cleanupCancel != nil { + cleanupCancel() + } +} + +// generateSecureRandomString generates a cryptographically secure random +// URL-safe string (used for state, nonce and PKCE verifier) +func generateSecureRandomString() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// pkceChallengeS256 derives the S256 code challenge from a PKCE verifier +func pkceChallengeS256(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// oidcFail reports a callback failure to the right consumer: the SPA via +// redirect for regular logins, the polling unit (plus a minimal page in +// the popup) for device pairing flows +func oidcFail(c *gin.Context, pairID string, errorCode string) { + if pairID != "" { + pairingStore.Fail(pairID, errorCode) + pairingResultPage(c, false, "Unit not linked", + fmt.Sprintf("The unit could not be linked (%s).", errorCode), nil) + return + } + c.Redirect(http.StatusTemporaryRedirect, configuration.Config.OIDC.FrontendURL+"/?error="+errorCode) +} + +// createOAuth2Config creates the OAuth2 configuration based on the OIDC config +func createOAuth2Config(config configuration.Configuration, provider *oidc.Provider) oauth2.Config { + scopes := []string{oidc.ScopeOpenID, "profile", "email"} + + // Add configured scopes if available, otherwise use defaults + if len(config.OIDC.Scopes) > 0 { + scopes = config.OIDC.Scopes + } else { + // Default scopes for Logto + scopes = append(scopes, "roles", "urn:logto:scope:organizations", "urn:logto:scope:organization_roles") + } + + return oauth2.Config{ + ClientID: config.OIDC.ClientID, + ClientSecret: config.OIDC.ClientSecret, + RedirectURL: config.OIDC.RedirectURI, + Endpoint: provider.Endpoint(), + Scopes: scopes, + } +} + +// hasRoleIgnoreCase reports whether the given role appears in roles +func hasRoleIgnoreCase(roles []string, want string) bool { + for _, role := range roles { + if strings.EqualFold(role, want) { + return true + } + } + return false +} + +// extractOrgIDsWithRole returns the IDs of the organizations where the user +// holds the given role, from the Logto "organization_roles" claim +// (entries have the form ":") +func extractOrgIDsWithRole(claims map[string]interface{}, adminRole string) []string { + var orgIDs []string + + rolesClaim, ok := claims["organization_roles"].([]interface{}) + if !ok { + return orgIDs + } + + for _, entry := range rolesClaim { + entryStr, ok := entry.(string) + if !ok { + continue + } + idx := strings.LastIndex(entryStr, ":") + if idx <= 0 { + continue + } + if strings.EqualFold(entryStr[idx+1:], adminRole) { + orgIDs = append(orgIDs, entryStr[:idx]) + } + } + + return orgIDs +} + +// extractRolesFromClaims extracts roles from all possible claim fields. +// All fields are collected (not just the first non-empty one) so that +// e.g. Logto organization roles are considered even when the user also +// has unrelated global roles. +func extractRolesFromClaims(claims map[string]interface{}) []string { + var roles []string + + // List of possible role claim fields + roleFields := []string{"roles", "role", "groups", "organizations", "organization_roles"} + + for _, field := range roleFields { + fieldValue, exists := claims[field] + if !exists { + continue + } + // Handle array of roles + if roleSlice, ok := fieldValue.([]interface{}); ok { + for _, role := range roleSlice { + if roleStr, ok := role.(string); ok { + roles = append(roles, roleStr) + } + } + } + // Handle single role as string + if roleStr, ok := fieldValue.(string); ok { + roles = append(roles, roleStr) + } + } + + return roles +} + +// mapRoleToIcaro maps external roles to Icaro roles based on configuration. +// Mappings are evaluated in configuration order, so the first mapping wins: +// list the most privileged roles first (e.g. "super admin:admin" before +// "admin:reseller"). Returns empty string if no role is authorized. +func mapRoleToIcaro(externalRoles []string, config configuration.Configuration) string { + // If no role mapping configured, deny access - no default mappings for security + for _, mapping := range config.OIDC.RoleMapping { + // Parse role mapping from configuration: "external:internal" format + parts := strings.Split(mapping, ":") + if len(parts) != 2 { + continue + } + configRole := strings.TrimSpace(parts[0]) + icaroRole := strings.TrimSpace(parts[1]) + + for _, externalRole := range externalRoles { + if strings.EqualFold(externalRole, configRole) { + return icaroRole + } + // Logto organization roles come as ":": + // match the role name part as well + if idx := strings.LastIndex(externalRole, ":"); idx >= 0 { + if strings.EqualFold(externalRole[idx+1:], configRole) { + return icaroRole + } + } + } + } + + // Return empty string if no authorized role found + return "" +} + +// validateOIDCConfig validates that OIDC configuration is complete +func validateOIDCConfig(config configuration.Configuration) error { + if config.OIDC.Issuer == "" { + return fmt.Errorf("OIDC issuer not configured") + } + if config.OIDC.ClientID == "" { + return fmt.Errorf("OIDC client ID not configured") + } + if config.OIDC.ClientSecret == "" { + return fmt.Errorf("OIDC client secret not configured") + } + if config.OIDC.RedirectURI == "" { + return fmt.Errorf("OIDC redirect URI not configured") + } + if config.OIDC.FrontendURL == "" { + return fmt.Errorf("OIDC frontend URL not configured") + } + return nil +} + +// myResellerResponse mirrors the relevant part of My's GET /resellers/:id +type myResellerResponse struct { + Code int `json:"code"` + Data struct { + ID string `json:"id"` + LogtoID *string `json:"logto_id"` + Name string `json:"name"` + CustomData map[string]interface{} `json:"custom_data"` + SuspendedAt *string `json:"suspended_at"` + } `json:"data"` +} + +// provisionCompanyAccount creates the Icaro company account for a My +// reseller organization on its first OIDC login. The organization is +// verified against the My API (must exist as an active reseller) before +// anything is created. Returns the account, or a frontend error code. +func provisionCompanyAccount(orgID string) (*models.Account, string) { + config := configuration.Config + + if config.OIDC.MyAPIURL == "" || config.OIDC.MyAPIKey == "" || config.OIDC.DefaultSubscriptionPlanID == 0 { + // JIT provisioning not configured: only organizations already + // linked to an account may log in + log.Println("OIDC company login denied: organization", orgID, "not linked and JIT provisioning not configured") + return nil, "account_not_found" + } + + // Verify the organization on My: GET /resellers/:id looks up by + // Logto organization ID and excludes deleted organizations + req, err := http.NewRequest("GET", strings.TrimRight(config.OIDC.MyAPIURL, "/")+"/resellers/"+url.PathEscape(orgID), nil) + if err != nil { + log.Println("OIDC provisioning: request creation failed:", err) + return nil, "provisioning_failed" + } + req.Header.Set("Authorization", "Bearer "+config.OIDC.MyAPIKey) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Println("OIDC provisioning: My API unreachable:", err) + return nil, "provisioning_failed" + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + // The organization exists on Logto but is not a reseller on My + log.Println("OIDC company login denied: organization", orgID, "is not a reseller on My") + return nil, "account_not_found" + } + if resp.StatusCode != http.StatusOK { + log.Println("OIDC provisioning: My API returned status", resp.StatusCode, "for organization", orgID) + return nil, "provisioning_failed" + } + + var myResp myResellerResponse + if err := json.NewDecoder(resp.Body).Decode(&myResp); err != nil { + log.Println("OIDC provisioning: invalid My API response:", err) + return nil, "provisioning_failed" + } + + if myResp.Data.SuspendedAt != nil { + log.Println("OIDC company login denied: organization", orgID, "is suspended on My") + return nil, "org_suspended" + } + + // Organization contact email from custom data, when available + orgEmail := "" + for _, key := range []string{"email", "contactEmail", "contact_email"} { + if v, ok := myResp.Data.CustomData[key].(string); ok && v != "" { + orgEmail = v + break + } + } + + name := myResp.Data.Name + if name == "" { + name = orgID + } + + // JIT company accounts are created by the primary admin + db := database.Instance() + var admin models.Account + db.Where("type = ?", "admin").Order("id ASC").First(&admin) + + logtoOrgID := orgID + account := models.Account{ + CreatorId: admin.Id, + Uuid: orgID, // stable unique identifier for accounts born from My organizations + LogtoOrgId: &logtoOrgID, + Type: "reseller", + Name: name, + Username: orgID, + Password: "", // OIDC-only account, no password login + Email: orgEmail, + Created: time.Now().UTC(), + } + if err := db.Create(&account).Error; err != nil { + log.Println("OIDC provisioning: account creation failed for organization", orgID, ":", err) + return nil, "account_creation_failed" + } + + // Create the subscription with the configured default plan and the + // SMS accounting, mirroring the classic account creation + var subscriptionPlan models.SubscriptionPlan + db.Where("id = ?", config.OIDC.DefaultSubscriptionPlanID).First(&subscriptionPlan) + if subscriptionPlan.ID == 0 { + log.Println("OIDC provisioning: default subscription plan", config.OIDC.DefaultSubscriptionPlanID, "not found") + return nil, "account_creation_failed" + } + + subscription := models.Subscription{ + AccountID: account.Id, + SubscriptionPlanID: subscriptionPlan.ID, + ValidFrom: time.Now().UTC(), + ValidUntil: time.Now().UTC().AddDate(0, 0, subscriptionPlan.Period), + Created: time.Now().UTC(), + } + db.Save(&subscription) + + accountSMS := models.AccountSmsCount{ + AccountId: account.Id, + SmsMaxCount: subscriptionPlan.IncludedSMS, + } + db.Save(&accountSMS) + + log.Println("OIDC provisioning: created company account", account.Id, "for My organization", orgID, "("+name+")") + return &account, "" +} + +func GetOIDCConfig(c *gin.Context) { + config := configuration.Config + + enabled := config.OIDC.Issuer != "" && config.OIDC.ClientID != "" + + // The button is shown only when OIDC works and it is not explicitly + // hidden. Hiding it (show_login_button=false) keeps the OIDC endpoints + // live for the entry from the My dashboard while removing the button + // from the Sun login page (soft launch). + showButton := enabled && (config.OIDC.ShowLoginButton == nil || *config.OIDC.ShowLoginButton) + + // Return only public configuration information + response := gin.H{ + "enabled": enabled, + "login_button": showButton, + } + + // Only add provider name if OIDC is properly configured + if enabled { + response["provider_name"] = "My Nethesis" + } + + c.JSON(http.StatusOK, response) +} + +func OIDCLogin(c *gin.Context) { + config := configuration.Config + + // Validate OIDC configuration + if err := validateOIDCConfig(config); err != nil { + log.Println("OIDC configuration error:", err) + c.JSON(http.StatusInternalServerError, gin.H{"message": "OIDC configuration error"}) + return + } + + // Always use auto-discovery for endpoints (cached after first use) + provider, err := getOIDCProvider(config.OIDC.Issuer) + if err != nil { + log.Println("OIDC provider initialization failed:", err) + c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to get OIDC provider"}) + return + } + + oauth2Config := createOAuth2Config(config, provider) + + // Device pairing flow: bind this login attempt to a pending pairing + // created via /auth/oidc/device/start + pairID := c.Query("pair") + if pairID != "" && !pairingStore.IsPending(pairID) { + c.JSON(http.StatusBadRequest, gin.H{"message": "Unknown or expired pairing request"}) + return + } + + // Generate secure random state, nonce and PKCE verifier + state, err := generateSecureRandomString() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to generate state"}) + return + } + nonce, err := generateSecureRandomString() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to generate nonce"}) + return + } + pkceVerifier, err := generateSecureRandomString() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to generate PKCE verifier"}) + return + } + + // Store state with expiration (valid for 10 minutes) + stored := stateStore.StoreState(state, OIDCStateData{ + Expires: time.Now().Add(10 * time.Minute), + Nonce: nonce, + PKCEVerifier: pkceVerifier, + PairID: pairID, + }) + if !stored { + log.Println("OIDC state store is full, rejecting login attempt") + c.JSON(http.StatusServiceUnavailable, gin.H{"message": "Too many pending login attempts, retry later"}) + return + } + + // Bind the state to this browser with a short-lived HttpOnly cookie: + // the callback only accepts a state that matches the cookie, so a state + // minted for another browser cannot be replayed (login CSRF protection). + // SameSite=Lax cookies are sent on the top-level GET navigation back + // from the identity provider. + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(oidcStateCookieName, state, 600, "/", "", strings.HasPrefix(config.OIDC.RedirectURI, "https://"), true) + + // Get authorization URL (with nonce and PKCE challenge) + authURL := oauth2Config.AuthCodeURL( + state, + oauth2.SetAuthURLParam("nonce", nonce), + oauth2.SetAuthURLParam("code_challenge", pkceChallengeS256(pkceVerifier)), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + + // Redirect to authorization URL + c.Redirect(http.StatusTemporaryRedirect, authURL) +} + +func OIDCCallback(c *gin.Context) { + config := configuration.Config + ctx := context.Background() + + // Validate OIDC configuration + if err := validateOIDCConfig(config); err != nil { + log.Println("OIDC configuration error:", err) + c.JSON(http.StatusInternalServerError, gin.H{"message": "OIDC configuration error"}) + return + } + + // Get authorization code and state from query parameters + code := c.Query("code") + state := c.Query("state") + + if code == "" { + c.Redirect(http.StatusTemporaryRedirect, config.OIDC.FrontendURL+"/?error=missing_code") + return + } + + if state == "" { + c.Redirect(http.StatusTemporaryRedirect, config.OIDC.FrontendURL+"/?error=missing_state") + return + } + + // The state must match the cookie set when this browser started the + // flow: a valid state minted for a different browser is rejected + // (login CSRF protection) + stateCookie, err := c.Cookie(oidcStateCookieName) + if err != nil || stateCookie != state { + c.Redirect(http.StatusTemporaryRedirect, config.OIDC.FrontendURL+"/?error=invalid_state") + return + } + + // Clear the state cookie: it is one-time use like the state itself + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(oidcStateCookieName, "", -1, "/", "", strings.HasPrefix(config.OIDC.RedirectURI, "https://"), true) + + // Validate state and retrieve the nonce and PKCE verifier of this attempt + stateData, valid := stateStore.ValidateAndRemoveState(state) + if !valid { + c.Redirect(http.StatusTemporaryRedirect, config.OIDC.FrontendURL+"/?error=invalid_state") + return + } + + // Initialize OIDC provider (cached after first use) + provider, err := getOIDCProvider(config.OIDC.Issuer) + if err != nil { + log.Println("OIDC provider initialization failed:", err) + oidcFail(c, stateData.PairID, "provider_init_failed") + return + } + + oauth2Config := createOAuth2Config(config, provider) + + // Exchange authorization code for token (PKCE verifier proves this is + // the same client that started the flow) + token, err := oauth2Config.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", stateData.PKCEVerifier)) + if err != nil { + log.Println("OIDC token exchange failed:", err) + oidcFail(c, stateData.PairID, "token_exchange_failed") + return + } + + // Extract and verify ID token + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + oidcFail(c, stateData.PairID, "no_id_token") + return + } + + // Create verifier + oidcConfig := &oidc.Config{ + ClientID: config.OIDC.ClientID, + } + verifier := provider.Verifier(oidcConfig) + + // Verify ID token + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + log.Println("OIDC ID token verification failed:", err) + oidcFail(c, stateData.PairID, "token_verification_failed") + return + } + + // The ID token must carry the nonce generated for this attempt: + // binds the token to this login flow and prevents replay + if idToken.Nonce != stateData.Nonce { + oidcFail(c, stateData.PairID, "invalid_nonce") + return + } + + // Extract claims + var claims map[string]interface{} + if err := idToken.Claims(&claims); err != nil { + oidcFail(c, stateData.PairID, "claims_extraction_failed") + return + } + + // Extract user information + sub, _ := claims["sub"].(string) + email, _ := claims["email"].(string) + + if sub == "" || email == "" { + oidcFail(c, stateData.PairID, "missing_user_info") + return + } + + // Extract and map global roles: the only mapping expected here is + // "super admin:admin" (My super admins act as the Icaro admin); any + // other user logs in as their company via the organization claims + roles := extractRolesFromClaims(claims) + icaroRole := mapRoleToIcaro(roles, config) + + // Handle account mapping based on role + db := database.Instance() + var account models.Account + + if icaroRole == "admin" { + // If user should be admin, find the primary admin account (lowest ID) + db.Where("type = ?", "admin").Order("id ASC").First(&account) + + if account.Id == 0 { + // No admin account found, create error + oidcFail(c, stateData.PairID, "no_admin_account_found") + return + } + + // Use the existing admin account - don't modify it + // This allows super admin from Logto to act as the main admin + } else { + // Company login: on My the global role says what the user may do + // ("Admin" of their company) and the organization-role encodes + // the organization type ("Reseller"). Both are required; the + // organization then maps to the Icaro company account through + // accounts.logto_org_id (immutable link, never matched by email) + if !hasRoleIgnoreCase(roles, config.OIDC.OrgAdminRole) { + log.Printf("OIDC company login denied for %s: missing global role %q (roles=%v)", + email, config.OIDC.OrgAdminRole, claims["roles"]) + oidcFail(c, stateData.PairID, "unauthorized_role") + return + } + + adminOrgIDs := extractOrgIDsWithRole(claims, config.OIDC.OrgResellerRole) + if len(adminOrgIDs) == 0 { + log.Printf("OIDC company login denied for %s: no organization with role %q (organization_roles=%v)", + email, config.OIDC.OrgResellerRole, claims["organization_roles"]) + oidcFail(c, stateData.PairID, "unauthorized_role") + return + } + + db.Where("logto_org_id IN (?)", adminOrgIDs).First(&account) + + if account.Id == 0 { + // First login of this organization: verify on My that it is + // an active reseller, then create the linked company account + // (the modern equivalent of the old "Get credentials" action) + newAccount, errCode := provisionCompanyAccount(adminOrgIDs[0]) + if errCode != "" { + oidcFail(c, stateData.PairID, errCode) + return + } + account = *newAccount + } + + // The linked account must be a company (reseller) account + if account.Type != "reseller" { + oidcFail(c, stateData.PairID, "role_mismatch") + return + } + } + + // Create authorization token for the session (256 random bits, same + // length as the sha256-hex tokens issued by the password login) + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + oidcFail(c, stateData.PairID, "code_generation_failed") + return + } + authToken := fmt.Sprintf("%x", tokenBytes) + + // Set expiration date + expires := time.Now().UTC().AddDate(0, 0, configuration.Config.TokenExpiresDays) + + // The company account is shared by all org admins: record which My + // user actually logged in for auditing; device pairings also record + // which unit holds the token, so it can be told apart from browser + // sessions and revoked selectively + description := fmt.Sprintf("OIDC login by %s (%s)", email, sub) + if stateData.PairID != "" { + description = fmt.Sprintf("OIDC device pairing by %s (%s), unit: %s", + email, sub, pairingStore.PendingUnitName(stateData.PairID)) + } + + accessToken := models.AccessToken{ + AccountId: account.Id, + Token: authToken, + Role: account.Type, + Type: "oidc", + Expires: expires, + ACLs: "full", + Description: description, + } + + db.Save(&accessToken) + + // Device pairing flow: hand the token to the unit that started the + // pairing (picked up via /auth/oidc/device/poll) instead of the SPA; + // the popup only tells the user the window can be closed, the outcome + // summary lives in the unit UI + if stateData.PairID != "" { + unitName, ok := pairingStore.Complete(stateData.PairID, authToken, expires, account.Id, account.Name, email) + if !ok { + // the pairing expired while the user was logging in: nobody + // can ever pick this token up, drop it + db.Delete(&accessToken) + pairingResultPage(c, false, "Pairing expired", + "The pairing request expired, restart it from your unit.", nil) + return + } + pairingResultPage(c, true, "Unit linked", + "The unit has been authenticated on the hotspot manager.", []pairingDetail{ + {Label: "Account", Value: account.Name}, + {Label: "Logged in as", Value: email}, + {Label: "Unit", Value: unitName}, + }) + return + } + + // Generate a cryptographically secure temporary one-time code (expires in 2 minutes) + codeBytes := make([]byte, 16) + if _, err := rand.Read(codeBytes); err != nil { + oidcFail(c, stateData.PairID, "code_generation_failed") + return + } + tempCode := fmt.Sprintf("%x", codeBytes) + codeExpiration := time.Now().Add(2 * time.Minute) + + // Store the code with token and account information + codeStore.StoreCode(tempCode, authToken, account.Id, codeExpiration) + + // Redirect to frontend with temporary code instead of token + callbackURL := fmt.Sprintf("%s/#/login/callback?code=%s", + config.OIDC.FrontendURL, + tempCode, + ) + + c.Redirect(http.StatusTemporaryRedirect, callbackURL) +} + +// OIDCExchange exchanges a temporary code for authentication token +func OIDCExchange(c *gin.Context) { + // Get the temporary code from request + var request struct { + Code string `json:"code" binding:"required"` + } + + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid request", "error": err.Error()}) + return + } + + // Validate and retrieve the code data + codeData, valid := codeStore.ValidateAndRemoveCode(request.Code) + if !valid { + c.JSON(http.StatusUnauthorized, gin.H{"message": "Invalid or expired code"}) + return + } + + // Get account information + db := database.Instance() + var account models.Account + if err := db.Where("id = ?", codeData.AccountID).First(&account).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Account not found"}) + return + } + + // Get token expiration from database + var accessToken models.AccessToken + if err := db.Where("token = ?", codeData.Token).First(&accessToken).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Token not found"}) + return + } + + // Load the subscription like the password login does + var subscription models.Subscription + db.Set("gorm:auto_preload", true) + if account.Type == "reseller" { + db.Preload("SubscriptionPlan").Where("account_id = ?", account.Id).First(&subscription) + } else { + db.Preload("SubscriptionPlan").Where("account_id = ?", account.CreatorId).First(&subscription) + } + subscription.Expired = subscription.ValidUntil.Before(time.Now().UTC()) + + // Return the token information + c.JSON(http.StatusOK, gin.H{ + "token": codeData.Token, + "expires": accessToken.Expires.Unix(), + "id": account.Id, + "account_type": account.Type, + "subscription": subscription, + }) +} diff --git a/sun/sun-api/methods/device_pairing.go b/sun/sun-api/methods/device_pairing.go new file mode 100644 index 000000000..6f8111ba6 --- /dev/null +++ b/sun/sun-api/methods/device_pairing.go @@ -0,0 +1,415 @@ +/* + * Copyright (C) 2026 Nethesis S.r.l. + * http://www.nethesis.it - info@nethesis.it + * + * This file is part of Icaro project. + * + * Icaro is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, + * or any later version. + * + * Icaro is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Icaro. If not, see COPYING. + * + * author: Edoardo Spadoni + */ + +package methods + +import ( + "fmt" + "html" + "net/http" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + + "github.com/nethesis/icaro/sun/sun-api/configuration" +) + +// Device pairing lets a headless client (a NethSecurity unit) obtain a +// session token through the browser OIDC login without sharing an origin +// with the frontend: +// +// 1. the unit calls POST /auth/oidc/device/start and receives a public +// pair_id (put in the browser URL) and a secret device_code (never +// shown to the browser) +// 2. the unit opens the returned verification_url in the user's browser: +// it is the normal /auth/oidc/login flow carrying ?pair= +// 3. the OIDC callback, instead of handing the session to the SPA, +// attaches the freshly minted token to the pairing +// 4. the unit polls POST /auth/oidc/device/poll with the device_code and +// receives the token once the user has completed the login +// +// The token is released only to the holder of the device_code, so knowing +// the pair_id (visible in the URL) is never enough to steal a session. + +const ( + // how long a pairing stays valid waiting for the user to log in + devicePairingTTL = 10 * time.Minute + + // suggested client poll interval, returned by /device/start + devicePairingPollSeconds = 2 + + // same anti-flood bound used for pending OIDC states + maxPendingPairings = 10000 +) + +type pairingStatus int + +const ( + pairingPending pairingStatus = iota + pairingReady + pairingFailed +) + +// PairingData tracks one device pairing attempt from creation to the +// one-shot token pickup +type PairingData struct { + DeviceCode string + UnitName string + Status pairingStatus + Expires time.Time + Token string + TokenExpiry time.Time + AccountID int + AccountName string + LoggedBy string + ErrorCode string +} + +// PairingStore manages pending device pairings, indexed both by pair_id +// (used by the browser flow) and by device_code (used by the polling unit) +type PairingStore struct { + mu sync.Mutex + byPairID map[string]*PairingData + byDevice map[string]string // device_code -> pair_id +} + +var pairingStore = &PairingStore{ + byPairID: make(map[string]*PairingData), + byDevice: make(map[string]string), +} + +// StartPairing registers a new pending pairing. Returns false if the store +// is full. +func (p *PairingStore) StartPairing(pairID string, deviceCode string, unitName string) bool { + p.mu.Lock() + defer p.mu.Unlock() + + if len(p.byPairID) >= maxPendingPairings { + now := time.Now() + for id, data := range p.byPairID { + if now.After(data.Expires) { + delete(p.byDevice, data.DeviceCode) + delete(p.byPairID, id) + } + } + if len(p.byPairID) >= maxPendingPairings { + return false + } + } + + p.byPairID[pairID] = &PairingData{ + DeviceCode: deviceCode, + UnitName: unitName, + Status: pairingPending, + Expires: time.Now().Add(devicePairingTTL), + } + p.byDevice[deviceCode] = pairID + return true +} + +// IsPending tells whether a pairing exists and is still waiting for a +// login: the OIDC login entry point uses it to reject unknown or already +// consumed pair ids before starting a flow +func (p *PairingStore) IsPending(pairID string) bool { + p.mu.Lock() + defer p.mu.Unlock() + + data, exists := p.byPairID[pairID] + return exists && data.Status == pairingPending && time.Now().Before(data.Expires) +} + +// PendingUnitName returns the unit name declared for a pending pairing +// (empty if the pairing is unknown or no longer pending) +func (p *PairingStore) PendingUnitName(pairID string) string { + p.mu.Lock() + defer p.mu.Unlock() + + if data, exists := p.byPairID[pairID]; exists && data.Status == pairingPending { + return data.UnitName + } + return "" +} + +// Complete attaches the minted session token to a pending pairing and +// returns the unit name declared when the pairing was started +func (p *PairingStore) Complete(pairID string, token string, tokenExpiry time.Time, accountID int, accountName string, loggedBy string) (string, bool) { + p.mu.Lock() + defer p.mu.Unlock() + + data, exists := p.byPairID[pairID] + if !exists || data.Status != pairingPending || time.Now().After(data.Expires) { + return "", false + } + + data.Status = pairingReady + data.Token = token + data.TokenExpiry = tokenExpiry + data.AccountID = accountID + data.AccountName = accountName + data.LoggedBy = loggedBy + return data.UnitName, true +} + +// Fail marks a pending pairing as failed so the polling unit can report +// the reason instead of timing out +func (p *PairingStore) Fail(pairID string, errorCode string) { + p.mu.Lock() + defer p.mu.Unlock() + + data, exists := p.byPairID[pairID] + if !exists || data.Status != pairingPending { + return + } + + data.Status = pairingFailed + data.ErrorCode = errorCode +} + +// Poll returns the current state of a pairing looked up by device_code. +// Terminal states (ready, failed) are one-shot: the entry is removed as +// it is returned. +func (p *PairingStore) Poll(deviceCode string) (PairingData, pairingStatus, bool) { + p.mu.Lock() + defer p.mu.Unlock() + + pairID, exists := p.byDevice[deviceCode] + if !exists { + return PairingData{}, pairingPending, false + } + data := p.byPairID[pairID] + + if time.Now().After(data.Expires) { + delete(p.byDevice, deviceCode) + delete(p.byPairID, pairID) + return PairingData{}, pairingPending, false + } + + if data.Status == pairingPending { + return PairingData{}, pairingPending, true + } + + // terminal state: hand it out once and forget the pairing + delete(p.byDevice, deviceCode) + delete(p.byPairID, pairID) + return *data, data.Status, true +} + +// CleanupExpiredPairings removes expired pairings (called by the periodic +// cleanup routine together with states and codes) +func (p *PairingStore) CleanupExpiredPairings() { + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now() + for id, data := range p.byPairID { + if now.After(data.Expires) { + delete(p.byDevice, data.DeviceCode) + delete(p.byPairID, id) + } + } +} + +// oidcLoginURL derives the public /auth/oidc/login URL from the configured +// callback redirect URI (same host, sibling path) +func oidcLoginURL() string { + return strings.TrimSuffix(configuration.Config.OIDC.RedirectURI, "/callback") + "/login" +} + +// OIDCDeviceStart creates a new device pairing and returns the URL the +// unit must open in the user's browser plus the secret device_code used +// to poll for the token. The optional unit_name is echoed back to the +// user on the pairing result page. +func OIDCDeviceStart(c *gin.Context) { + config := configuration.Config + + if err := validateOIDCConfig(config); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "OIDC configuration error"}) + return + } + + var request struct { + UnitName string `json:"unit_name"` + } + // the body is optional: an empty or invalid one just means no unit name + _ = c.ShouldBindJSON(&request) + if len(request.UnitName) > 128 { + request.UnitName = request.UnitName[:128] + } + + deviceCode, err := generateSecureRandomString() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to generate device code"}) + return + } + pairID, err := generateSecureRandomString() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to generate pair id"}) + return + } + + if !pairingStore.StartPairing(pairID, deviceCode, request.UnitName) { + c.JSON(http.StatusServiceUnavailable, gin.H{"message": "Too many pending pairing attempts, retry later"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "device_code": deviceCode, + "pair_id": pairID, + "verification_url": fmt.Sprintf("%s?pair=%s", oidcLoginURL(), pairID), + "expires_in": int(devicePairingTTL.Seconds()), + "interval": devicePairingPollSeconds, + }) +} + +// OIDCDevicePoll returns the pairing outcome to the unit holding the +// device_code: pending while the user logs in, then exactly once the +// token (ready) or the error (failed) +func OIDCDevicePoll(c *gin.Context) { + var request struct { + DeviceCode string `json:"device_code" binding:"required"` + } + + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid request"}) + return + } + + data, status, found := pairingStore.Poll(request.DeviceCode) + if !found { + c.JSON(http.StatusOK, gin.H{"status": "expired"}) + return + } + + switch status { + case pairingReady: + c.JSON(http.StatusOK, gin.H{ + "status": "ready", + "token": data.Token, + "expires": data.TokenExpiry.Unix(), + "account_id": data.AccountID, + "account_name": data.AccountName, + "logged_by": data.LoggedBy, + }) + case pairingFailed: + c.JSON(http.StatusOK, gin.H{ + "status": "failed", + "error": data.ErrorCode, + }) + default: + c.JSON(http.StatusOK, gin.H{"status": "pending"}) + } +} + +// pairingDetail is a label/value row shown on the pairing result page +type pairingDetail struct { + Label string + Value string +} + +// pairingResultPage renders the page shown in the popup at the end of a +// pairing flow. It is styled after the NethSecurity UI (Tailwind gray +// palette, cyan primary) so the whole pairing feels like a single product; +// the outcome summary also lives in the unit UI that initiated the flow. +func pairingResultPage(c *gin.Context, success bool, title string, message string, details []pairingDetail) { + icon := `` + if !success { + icon = `` + } + + rows := "" + for _, d := range details { + if d.Value == "" { + continue + } + rows += fmt.Sprintf(`
%s%s
`, + html.EscapeString(d.Label), html.EscapeString(d.Value)) + } + if rows != "" { + rows = `
` + rows + `
` + } + + page := fmt.Sprintf(` + + + + +%s + + + + + + +
+%s +

%s

+

%s

+%s +

You can close this window and go back to your unit.

+
+ +`, html.EscapeString(title), icon, html.EscapeString(title), html.EscapeString(message), rows) + + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(page)) +} diff --git a/sun/sun-api/methods/userinfo.go b/sun/sun-api/methods/userinfo.go new file mode 100644 index 000000000..f9e3419b2 --- /dev/null +++ b/sun/sun-api/methods/userinfo.go @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2026 Nethesis S.r.l. + * http://www.nethesis.it - info@nethesis.it + * + * This file is part of Icaro project. + * + * Icaro is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, + * or any later version. + * + * Icaro is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Icaro. If not, see COPYING. + * + * author: Edoardo Spadoni + */ + +package methods + +import ( + "log" + "net/http" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-gonic/gin" + + "github.com/nethesis/icaro/sun/sun-api/configuration" + "github.com/nethesis/icaro/sun/sun-api/database" + "github.com/nethesis/icaro/sun/sun-api/models" +) + +// emptyUserInfo answers 200 with an empty widget: for the My dashboard a +// missing/invalid/unlinked identity simply means "nothing to show", and a +// non-2xx status would surface an error banner in the UI +func emptyUserInfo(c *gin.Context, msg string) { + c.JSON(http.StatusOK, gin.H{ + "status": "empty", + "msg": msg, + "widget": gin.H{"items": []gin.H{}}, + }) +} + +// UserInfo returns aggregate statistics for the company (reseller) of the +// authenticated My user. The My dashboard widget calls it cross-origin with +// the user's Logto ID token as bearer: the token is verified against the +// same issuer used for the OIDC login, and the company is resolved through +// the organization claims exactly like the login flow (never by email). +func UserInfo(c *gin.Context) { + config := configuration.Config + + if config.OIDC.Issuer == "" { + emptyUserInfo(c, "OIDC not configured") + return + } + + // Bearer token from the Authorization header + authParts := strings.SplitN(c.GetHeader("Authorization"), " ", 2) + if len(authParts) != 2 || !strings.EqualFold(authParts[0], "Bearer") { + emptyUserInfo(c, "Missing or invalid authorization header") + return + } + + provider, err := getOIDCProvider(config.OIDC.Issuer) + if err != nil { + log.Println("userinfo: OIDC provider initialization failed:", err) + emptyUserInfo(c, "Cannot retrieve OIDC provider") + return + } + + // The caller is My's own application, not our third-party client: the + // audience is checked manually against the configured value + verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true}) + idToken, err := verifier.Verify(c.Request.Context(), authParts[1]) + if err != nil { + log.Println("userinfo denied: invalid token:", err) + emptyUserInfo(c, "Invalid token") + return + } + + if config.OIDC.UserinfoAudience != "" { + audOk := false + for _, aud := range idToken.Audience { + if aud == config.OIDC.UserinfoAudience { + audOk = true + break + } + } + if !audOk { + log.Println("userinfo denied: audience mismatch:", idToken.Audience) + emptyUserInfo(c, "Invalid token: audience mismatch") + return + } + } + + var claims map[string]interface{} + if err := idToken.Claims(&claims); err != nil { + emptyUserInfo(c, "Invalid token: claims extraction failed") + return + } + + // The company is the organization where the user holds the reseller + // organization-role, mapped to the account through logto_org_id + orgIDs := extractOrgIDsWithRole(claims, config.OIDC.OrgResellerRole) + if len(orgIDs) == 0 { + log.Printf("userinfo denied for %v: no organization with role %q (organization_roles=%v)", + claims["email"], config.OIDC.OrgResellerRole, claims["organization_roles"]) + emptyUserInfo(c, "No reseller organization in token") + return + } + + db := database.Instance() + var account models.Account + db.Where("logto_org_id IN (?)", orgIDs).First(&account) + if account.Id == 0 || account.Type != "reseller" { + log.Printf("userinfo denied for %v: no company account linked to organizations %v", claims["email"], orgIDs) + emptyUserInfo(c, "No company account linked to the organization") + return + } + + // Aggregate counters over the company's hotspots + countByHotspot := func(table string) int { + var n int + db.Table(table). + Joins("JOIN hotspots ON hotspots.id = "+table+".hotspot_id"). + Where("hotspots.account_id = ?", account.Id). + Count(&n) + return n + } + + var hotspots, managers int + db.Table("hotspots").Where("account_id = ?", account.Id).Count(&hotspots) + db.Table("accounts").Where("creator_id = ? AND type IN (?)", account.Id, []string{"desk", "customer"}).Count(&managers) + units := countByHotspot("units") + guests := countByHotspot("users") + devices := countByHotspot("devices") + sessions := countByHotspot("sessions") + + var sms models.AccountSmsCount + db.Where("account_id = ?", account.Id).First(&sms) + smsRemaining := sms.SmsMaxCount - sms.SmsCount + if smsRemaining < 0 { + smsRemaining = 0 + } + + // SMS tone: warn the reseller when the remaining quota gets thin + smsTone := "neutral" + if sms.SmsMaxCount > 0 { + switch { + case smsRemaining*100 <= sms.SmsMaxCount*10: + smsTone = "danger" + case smsRemaining*100 <= sms.SmsMaxCount*25: + smsTone = "warning" + } + } + + // Generic widget contract consumed by the My dashboard: My renders + // `widget.items` without knowing anything about NethSpot + base := strings.TrimRight(config.OIDC.FrontendURL, "/") + widgetItems := []gin.H{ + {"label": "Hotspot", "value": hotspots, "tone": "neutral", "link": base + "/#/hotspots"}, + {"label": "Unit", "value": units, "tone": "neutral", "link": base + "/#/units"}, + {"label": "Utenti", "value": guests, "tone": "neutral", "link": base + "/#/users"}, + {"label": "SMS residui", "value": smsRemaining, "tone": smsTone, "link": base + "/"}, + } + + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "msg": "Company " + account.Name + " authenticated", + "company": account.Name, + // Raw counters, for consumers that want to build their own view + "hotspots": hotspots, + "units": units, + "users": guests, + "devices": devices, + "sessions": sessions, + "managers": managers, + "sms": gin.H{ + "count": sms.SmsCount, + "max": sms.SmsMaxCount, + "remaining": smsRemaining, + }, + // Generic contract rendered by the My dashboard + "widget": gin.H{ + "items": widgetItems, + }, + }) +} diff --git a/sun/sun-api/methods/users.go b/sun/sun-api/methods/users.go index fb4c72242..4365e79a8 100644 --- a/sun/sun-api/methods/users.go +++ b/sun/sun-api/methods/users.go @@ -347,15 +347,16 @@ func GetUsersExpired(c *gin.Context) { buf := bytes.NewBuffer(b) w := csv.NewWriter(buf) - // extract headers + // extract headers - use User model structure (without UserId) var headers []string if len(users) > 0 { + // User model doesn't have UserId, use it as-is headers = structs.Names(users[0]) + } else if len(userHistories) > 0 { + // UserHistory has UserId as second field (index 1), remove it to match User structure + headersWithUserId := structs.Names(userHistories[0]) + headers = utils.RemoveIndex(headersWithUserId, 1) } - if len(userHistories) > 0 { - headers = structs.Names(userHistories[0]) - } - headers = utils.RemoveIndex(headers, 1) // write headers to writer if err := w.Write(headers); err != nil { // write failed diff --git a/sun/sun-api/models/account.go b/sun/sun-api/models/account.go index 4fe37b9b9..72610c0e4 100644 --- a/sun/sun-api/models/account.go +++ b/sun/sun-api/models/account.go @@ -28,6 +28,7 @@ type Account struct { Id int `db:"id" json:"id"` CreatorId int `db:"creator_id" json:"creator_id"` Uuid string `db:"uuid" json:"uuid"` + LogtoOrgId *string `db:"logto_org_id" json:"logto_org_id"` // My/Logto organization linked to this company account (nil = classic account) Type string `db:"type" json:"type"` Name string `db:"name" json:"name"` Username string `db:"username" json:"username"` diff --git a/sun/sun-ui/src/App.vue b/sun/sun-ui/src/App.vue index 61b0cc946..e745b17cb 100644 --- a/sun/sun-ui/src/App.vue +++ b/sun/sun-ui/src/App.vue @@ -7,7 +7,7 @@  logo
-
+ -
Copyright © {{currentYear()}} {{companyName}}
+
{{ $t("login.copyright") }} © {{currentYear()}} {{companyName}}
@@ -335,11 +352,15 @@
+ + + +