Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5718c9bae2 | ||
|
|
2c79edabea | ||
|
|
6e12a608b3 | ||
|
|
5718492e2b | ||
|
|
3bfb38ebd7 | ||
|
|
1de63af07b | ||
|
|
6c2c278f65 | ||
|
|
21625685b4 | ||
|
|
29da21c28f | ||
|
|
c781e263fc | ||
|
|
b1be3181ef | ||
|
|
08b087337e |
+48
-28
@@ -125,7 +125,15 @@ Created through the UniFi Network API (site `default`):
|
||||
|---|---|---|
|
||||
| Firewall policy "Allow APs RADIUS" | `62c21e63-8c15-4720-adf8-b2333c090e2f` | zone Isolated `10.2.10.0/24` → zone External `10.2.99.91`, UDP `1812-1813`, ALLOW, IPv4 |
|
||||
| RADIUS profile "Cluster 802.1X" | `3e5fcfed-63dc-4e7d-ad51-17cbc074e4bb` | auth `10.2.99.91:1812`, acct `10.2.99.91:1813`, secret `DOT1X_NAS_SECRET`, VLAN assignment on (`vlan_wlan_mode: optional`) |
|
||||
| WLAN "Renner Test" | `3eba27e7-ddc2-41fb-ad3f-82add317847a` | copy of "Renner UniFi" (WPA2/WPA3 Enterprise transition, fast roaming, BSS transition, same AP group and bands), network (90) Guest as fallback, RADIUS profile "Cluster 802.1X" |
|
||||
| WLAN "Renner UniFi" | `ef4122f2-4a83-4dca-9ef3-468d29f39a6b` | WPA3 Enterprise (no transition), PMF required, fast roaming, BSS transition, client isolation off, RADIUS profile "Cluster 802.1X"; Application Hotspot, type Passpoint (set in the UniFi UI: the API format differs from the UI's) |
|
||||
|
||||
Passpoint on "Renner UniFi" (UniFi offers Passpoint only with WPA3 Enterprise):
|
||||
venue "Renner", Residential / Private Residence, network type Private with
|
||||
Guest Access, IPv4 single NATed private, IPv6 available, NAI realm
|
||||
`renner.uno` with EAP-TTLS, sub-methods PAP and username/password, domain
|
||||
`renner.uno`, operator friendly name "Renner", connection capability ICMP
|
||||
allow. The AKMs on air are `FT/802.1X` and `802.1X/SHA-256`; plain `802.1X`
|
||||
is not offered.
|
||||
|
||||
## Live login check
|
||||
|
||||
@@ -146,31 +154,42 @@ For IPv6 set `LIVE_SOURCE` to the workstation's `fd02:50::` address and use
|
||||
## Client setup (SSID "Renner UniFi")
|
||||
|
||||
Setup page: `https://wlan.renner.uno` (behind the Authentik gate, any
|
||||
authentik user; `kubernetes/apps/authentik/wlan-setup*.yaml`). It serves the
|
||||
Apple profile (caller's username filled in), a Windows file that imports the
|
||||
WLAN profile with `netsh`, and the Linux `nmcli` command. None of these carries a password. Android
|
||||
gets a Passpoint profile (`application/x-wifi-config`): the page POSTs the
|
||||
password the user types there, nginx (njs) builds the profile and keeps it in
|
||||
memory for at most 120 s under a one-time token bound to the user, and a tap on
|
||||
the resulting link GETs it, as the Android installer requires. It matches WLANs
|
||||
that advertise Hotspot 2.0 with domain and NAI realm `renner.uno`
|
||||
(EAP-TTLS/PAP).
|
||||
authentik user; `kubernetes/apps/authentik/wlan-setup*.yaml`). Android and
|
||||
Apple get Passpoint profiles with their own app password; Windows gets a file
|
||||
that imports the WLAN profile with `netsh`, Linux an `nmcli` command
|
||||
(NetworkManager has no Passpoint support). The Windows and Linux setups carry
|
||||
no password.
|
||||
|
||||
Passpoint (Android, Apple): `/<platform>/install` (`android` or `apple`) signs
|
||||
the user in through the authentik OAuth2 application `wlan-setup` (scope
|
||||
`goauthentik.io/api`). On `/<platform>/callback`, nginx (njs) exchanges the
|
||||
code, creates an app password (`wlan-<platform>-<user>-<timestamp>-<random>`)
|
||||
with the user's own access token, and returns the profile in the response to
|
||||
that GET: Android `application/x-wifi-config` (as its installer requires),
|
||||
Apple a `.mobileconfig`. The page stores nothing. Deleting the app password in
|
||||
authentik removes that device from the WLAN. The profiles match WLANs that
|
||||
advertise Hotspot 2.0 with domain and NAI realm `renner.uno` (EAP-TTLS/PAP),
|
||||
i.e. "Renner UniFi". Creating the app password needs no admin rights.
|
||||
|
||||
App passwords expire when unused. The page creates them with 29 days (the API
|
||||
caps new tokens at 30 days unless the group attribute
|
||||
`goauthentik.io/user/token-maximum-lifetime` allows more). Every 802.1X login
|
||||
with an app password renews it to 60 days: the policy
|
||||
`dot1x-app-password-renew` on the `dot1x-login` binding (at most one write per
|
||||
day). authentik rejects expired app passwords and deletes them in its
|
||||
`clean_expired_models` task. A device that has not connected for 60 days, or a
|
||||
profile that was never installed, loses its app password and needs a new
|
||||
profile from the page.
|
||||
|
||||
All clients use EAP-TTLS with inner PAP. The server certificate is a public
|
||||
Let's Encrypt certificate for `radius.renner.uno`, so no CA needs to be
|
||||
installed. Username and password are the authentik credentials. PAP runs
|
||||
inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
|
||||
installed. The 802.1X password stage (`dot1x-password`) accepts the authentik
|
||||
password and app passwords. PAP runs inside the TLS tunnel to FreeRADIUS and
|
||||
is never sent in clear over the air.
|
||||
|
||||
### Android
|
||||
|
||||
- EAP method: TTLS
|
||||
- Phase 2 authentication: PAP
|
||||
- CA certificate: Use system certificates
|
||||
- Domain: `radius.renner.uno`
|
||||
- Identity: authentik username
|
||||
- Anonymous identity: `anonymous` (mandatory; empty sends the login to the
|
||||
UDM, where it fails)
|
||||
- Password: authentik password
|
||||
Passpoint profile from the setup page ("Auf Android installieren").
|
||||
|
||||
### Linux (NetworkManager)
|
||||
|
||||
@@ -187,7 +206,7 @@ inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
|
||||
Settings > Network & Internet > Wi-Fi > Manage known networks > Add network:
|
||||
|
||||
- Network name: `Renner UniFi`
|
||||
- Security type: WPA2-Enterprise AES
|
||||
- Security type: WPA3-Enterprise AES
|
||||
- EAP method: EAP-TTLS
|
||||
- Authentication method: Unencrypted password (PAP)
|
||||
- Server validation: where offered ("Verify server identity" / trusted
|
||||
@@ -202,13 +221,14 @@ wireless network" instead.
|
||||
|
||||
### Apple (iOS, iPadOS, macOS)
|
||||
|
||||
Apple devices need a configuration profile; the setup page serves it with the
|
||||
username filled in. Install it via Settings (Profile Downloaded). The device
|
||||
asks for the password on the first join. The profile sets security type Any (WPA2/WPA3), outer identity
|
||||
`anonymous` and trusted server name `radius.renner.uno`; it contains no
|
||||
password and no certificate. Apple
|
||||
devices may show a one-time "trust certificate" prompt for `radius.renner.uno`
|
||||
on the first join.
|
||||
Passpoint configuration profile from the setup page ("Profil laden"),
|
||||
installed via Settings (Profile Downloaded) or, on a Mac, System Settings >
|
||||
General > Device Management. It sets `IsHotspot` with domain and NAI realm
|
||||
`renner.uno` (no SSID), security type Any, outer identity `anonymous`,
|
||||
trusted server name `radius.renner.uno`, username and app password; it
|
||||
contains no certificate. Its PayloadIdentifier is fixed, so installing it
|
||||
replaces an earlier profile. Apple devices may show a one-time "trust
|
||||
certificate" prompt for `radius.renner.uno` on the first join.
|
||||
|
||||
## Measurement: authentication latency per SSID
|
||||
|
||||
|
||||
@@ -1483,8 +1483,11 @@ data:
|
||||
name: dot1x-password
|
||||
state: present
|
||||
attrs:
|
||||
# App passwords: the WLAN credential that devices store (Android
|
||||
# Passpoint profiles carry it), created by wlan.renner.uno.
|
||||
backends:
|
||||
- authentik.core.auth.InbuiltBackend
|
||||
- authentik.core.auth.TokenBackend
|
||||
|
||||
- id: dot1x-login
|
||||
model: authentik_stages_user_login.userloginstage
|
||||
@@ -1506,12 +1509,54 @@ data:
|
||||
order: 20
|
||||
state: present
|
||||
|
||||
- model: authentik_flows.flowstagebinding
|
||||
- id: binding-dot1x-login
|
||||
model: authentik_flows.flowstagebinding
|
||||
identifiers:
|
||||
target: !KeyOf dot1x-flow
|
||||
stage: !KeyOf dot1x-login
|
||||
order: 100
|
||||
state: present
|
||||
attrs:
|
||||
evaluate_on_plan: false
|
||||
re_evaluate_policies: true
|
||||
|
||||
# Sliding expiry: an app password used for 802.1X expires 60 days
|
||||
# after its last use; authentik rejects and deletes expired app
|
||||
# passwords. auth_method_args.token is set by TokenBackend. Always
|
||||
# passes; failure_result true keeps an error from blocking the login.
|
||||
- id: dot1x-app-password-renew
|
||||
model: authentik_policies_expression.expressionpolicy
|
||||
identifiers:
|
||||
name: dot1x-app-password-renew
|
||||
state: present
|
||||
attrs:
|
||||
expression: |
|
||||
from datetime import timedelta
|
||||
from django.utils.timezone import now
|
||||
from authentik.core.models import Token, TokenIntents
|
||||
ref = (request.context.get("auth_method_args") or {}).get("token")
|
||||
if request.context.get("auth_method") == "token" and isinstance(ref, dict):
|
||||
renewed = now() + timedelta(days=60)
|
||||
count = Token.objects.filter(
|
||||
pk=ref.get("pk"),
|
||||
user=request.user,
|
||||
intent=TokenIntents.INTENT_APP_PASSWORD,
|
||||
expiring=True,
|
||||
expires__lt=renewed - timedelta(days=1),
|
||||
).update(expires=renewed)
|
||||
ak_logger.info("dot1x app password", token=ref.get("name"), renewed=count)
|
||||
return True
|
||||
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
policy: !KeyOf dot1x-app-password-renew
|
||||
target: !KeyOf binding-dot1x-login
|
||||
order: 0
|
||||
state: present
|
||||
attrs:
|
||||
enabled: true
|
||||
negate: false
|
||||
failure_result: true
|
||||
|
||||
- id: dot1x-has-vlan
|
||||
model: authentik_policies_expression.expressionpolicy
|
||||
@@ -1624,3 +1669,45 @@ data:
|
||||
kubernetes_httproute_annotations: {}
|
||||
kubernetes_httproute_parent_refs: []
|
||||
kubernetes_json_patches: null
|
||||
|
||||
# wlan.renner.uno (wlan-setup.yaml): signs the user in and, with the
|
||||
# user's own access token (scope goauthentik.io/api), creates the WLAN
|
||||
# app password for the Android Passpoint profile. Confidential client;
|
||||
# the secret comes from wlan-setup-oauth (wlan-setup-oauth.yaml). No
|
||||
# policy binding: every authentik user may set up the WLAN.
|
||||
- id: wlan-setup-provider
|
||||
model: authentik_providers_oauth2.oauth2provider
|
||||
identifiers:
|
||||
name: WLAN setup
|
||||
state: present
|
||||
attrs:
|
||||
authentication_flow: null
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
client_type: confidential
|
||||
client_id: wlan-setup
|
||||
client_secret: !Env WLAN_SETUP_CLIENT_SECRET
|
||||
grant_types:
|
||||
- authorization_code
|
||||
access_token_validity: minutes=5
|
||||
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
|
||||
property_mappings:
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-authentik_api]]
|
||||
redirect_uris:
|
||||
- url: https://wlan.renner.uno/android/callback
|
||||
matching_mode: strict
|
||||
- url: https://wlan.renner.uno/apple/callback
|
||||
matching_mode: strict
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: wlan-setup
|
||||
state: present
|
||||
attrs:
|
||||
name: WLAN setup
|
||||
slug: wlan-setup
|
||||
provider: !KeyOf wlan-setup-provider
|
||||
meta_launch_url: https://wlan.renner.uno
|
||||
meta_description: "WLAN setup page; creates WLAN app passwords"
|
||||
|
||||
@@ -171,10 +171,11 @@ spec:
|
||||
worker:
|
||||
# Overrides the global key; keep authentik-secrets in the list.
|
||||
podAnnotations:
|
||||
secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets
|
||||
# Shared secret of the 802.1X Radius provider, set via `!Env` in
|
||||
# dot1x-radius.yaml. Worker only (blueprint applier); optional so a
|
||||
# missing Secret fails only the dot1x blueprint entry.
|
||||
secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets,wlan-setup-oauth
|
||||
# Shared secret of the 802.1X Radius provider and client secret of the
|
||||
# WLAN setup provider, set via `!Env` in dot1x-radius.yaml. Worker only
|
||||
# (blueprint applier); optional so a missing Secret fails only the
|
||||
# blueprint entries that use it.
|
||||
env:
|
||||
- name: DOT1X_OUTPOST_SECRET
|
||||
valueFrom:
|
||||
@@ -182,6 +183,12 @@ spec:
|
||||
name: radius-secrets
|
||||
key: DOT1X_OUTPOST_SECRET
|
||||
optional: true
|
||||
- name: WLAN_SETUP_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wlan-setup-oauth
|
||||
key: client_secret
|
||||
optional: true
|
||||
resources:
|
||||
requests:
|
||||
# Same reasoning as server above, and the worker is burstier still:
|
||||
|
||||
@@ -16,6 +16,7 @@ resources:
|
||||
- freeradius-network-attachment.yaml
|
||||
- freeradius.yaml
|
||||
- freeradius-cnp.yaml
|
||||
- wlan-setup-oauth.yaml
|
||||
- wlan-setup-config.yaml
|
||||
- wlan-setup.yaml
|
||||
# ak-pg CNPG Postgres backend (moved here from the retired seed-authentik KS;
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
# wlan.renner.uno: setup files for "Renner UniFi" (802.1X, EAP-TTLS/PAP
|
||||
# against freeradius, outer identity anonymous, server name radius.renner.uno).
|
||||
# Served by wlan-setup (wlan-setup.yaml) behind the Authentik gate; the
|
||||
# username comes from X-authentik-username. Only the Android Passpoint profile
|
||||
# carries the password (the format requires it); it exists in nginx memory for
|
||||
# at most 120 s and is handed out once.
|
||||
# username comes from X-authentik-username. The Android and Apple Passpoint
|
||||
# profiles carry a credential: a per-device authentik app password that the
|
||||
# page creates with the user's own OAuth access token.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: wlan-setup
|
||||
namespace: authentik
|
||||
data:
|
||||
# The image's nginx.conf plus the njs module and a single worker, so the
|
||||
# one-time tokens of passpoint.js live in one process.
|
||||
# The image's nginx.conf plus the njs module and the OAuth client secret
|
||||
# passpoint.js reads from the environment.
|
||||
nginx.conf: |
|
||||
load_module modules/ngx_http_js_module.so;
|
||||
|
||||
env WLAN_SETUP_CLIENT_SECRET;
|
||||
|
||||
worker_processes 1;
|
||||
error_log /var/log/nginx/error.log notice;
|
||||
pid /tmp/nginx.pid;
|
||||
@@ -52,8 +54,12 @@ data:
|
||||
|
||||
js_path /etc/nginx/njs/;
|
||||
js_import passpoint from passpoint.js;
|
||||
# token -> {user, profile}; entries expire after 120 s.
|
||||
js_shared_dict_zone zone=passpoint:1m timeout=120s evict;
|
||||
|
||||
# authentik-server, resolved once at start; reached only by passpoint.js
|
||||
# subrequests (/_ak/...).
|
||||
upstream authentik {
|
||||
server authentik-server.authentik.svc.cluster.local:80;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
@@ -71,75 +77,6 @@ data:
|
||||
return 200 $wifi_user;
|
||||
}
|
||||
|
||||
# Apple: com.apple.wifi.managed, TTLS (21) with inner PAP. Fixed
|
||||
# PayloadIdentifier/UUIDs so a reinstall replaces the old profile.
|
||||
location = /renner-unifi.mobileconfig {
|
||||
default_type application/x-apple-aspen-config;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header Content-Disposition 'attachment; filename="renner-unifi.mobileconfig"' always;
|
||||
return 200 '<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.wifi.managed</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>uno.renner.wifi.renner-unifi.network</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>824959bf-c55d-4ed2-b569-c33f3759c2b6</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Renner UniFi</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>Renner</string>
|
||||
<key>SSID_STR</key>
|
||||
<string>Renner UniFi</string>
|
||||
<key>HIDDEN_NETWORK</key>
|
||||
<false/>
|
||||
<key>AutoJoin</key>
|
||||
<true/>
|
||||
<key>EncryptionType</key>
|
||||
<string>Any</string>
|
||||
<key>EAPClientConfiguration</key>
|
||||
<dict>
|
||||
<key>UserName</key>
|
||||
<string>$wifi_user</string>
|
||||
<key>OuterIdentity</key>
|
||||
<string>anonymous</string>
|
||||
<key>AcceptEAPTypes</key>
|
||||
<array>
|
||||
<integer>21</integer>
|
||||
</array>
|
||||
<key>TTLSInnerAuthentication</key>
|
||||
<string>PAP</string>
|
||||
<key>TLSTrustedServerNames</key>
|
||||
<array>
|
||||
<string>radius.renner.uno</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>uno.renner.wifi.renner-unifi</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>7175a2a3-ddd3-42ea-b4ad-b7e5501892f3</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>WLAN Renner UniFi</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>Renner</string>
|
||||
</dict>
|
||||
</plist>
|
||||
';
|
||||
}
|
||||
|
||||
location = /renner-unifi-wlan.cmd {
|
||||
default_type application/octet-stream;
|
||||
add_header Cache-Control "no-store" always;
|
||||
@@ -147,19 +84,38 @@ data:
|
||||
try_files /renner-unifi-wlan.cmd =404;
|
||||
}
|
||||
|
||||
# Android: the page POSTs the password and gets a one-time token; a tap
|
||||
# on /android/profile/<token> then GETs the Passpoint profile, as the
|
||||
# Android installer requires (application/x-wifi-config, base64,
|
||||
# no Content-Disposition).
|
||||
location = /android/prepare {
|
||||
limit_except POST { deny all; }
|
||||
client_max_body_size 1k;
|
||||
client_body_buffer_size 1k;
|
||||
js_content passpoint.prepare;
|
||||
# Android and Apple: a tap on /<platform>/install starts an
|
||||
# authorization-code sign-in at authentik (the user is already signed
|
||||
# in there); the callback creates the app password as the user and
|
||||
# answers the same navigation with the Passpoint profile. Android:
|
||||
# application/x-wifi-config, base64, no Content-Disposition, as its
|
||||
# installer requires. Apple: a .mobileconfig download.
|
||||
location ~ ^/(android|apple)/install$ {
|
||||
js_content passpoint.install;
|
||||
}
|
||||
|
||||
location ~ "^/android/profile/[A-Za-z0-9_-]{32}$" {
|
||||
js_content passpoint.deliver;
|
||||
location ~ ^/(android|apple)/callback$ {
|
||||
# The query carries the one-time authorization code.
|
||||
access_log off;
|
||||
js_content passpoint.callback;
|
||||
}
|
||||
|
||||
# Subrequests from passpoint.js only. Credential and content type
|
||||
# arrive as arguments and leave as headers; proxy_pass with a variable
|
||||
# URI does not forward the arguments. $arg_* only: subrequests share
|
||||
# the parent's variable cache, so a map would keep its first value.
|
||||
location ~ ^/_ak(/.*)$ {
|
||||
internal;
|
||||
proxy_pass http://authentik$1;
|
||||
# njs reads the whole reply from this buffer (default one page).
|
||||
subrequest_output_buffer_size 256k;
|
||||
# Only the headers set here reach authentik.
|
||||
proxy_pass_request_headers off;
|
||||
proxy_set_header Host auth.renner.uno;
|
||||
proxy_set_header Cookie "";
|
||||
proxy_set_header Authorization "$arg_scheme $arg_cred";
|
||||
proxy_set_header Content-Type $arg_ct;
|
||||
proxy_set_header Accept "application/json";
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
@@ -187,8 +143,16 @@ data:
|
||||
# the Passpoint WLAN announces; EAP-TTLS (21) with inner PAP. Trust anchor
|
||||
# ISRG Root X1 (the radius-tls chain ends there), AAA server name
|
||||
# radius.renner.uno (Extension/Android/AAAServerTrustedNames, as Android's
|
||||
# PpsMoParser expects it). A token is bound to the user who created it and
|
||||
# can be fetched once.
|
||||
# PpsMoParser expects it). The credential is a new app password per install,
|
||||
# created as the user with the access token of the "WLAN setup" OAuth client
|
||||
# (authorization code, scope goauthentik.io/api). It starts with 29 days
|
||||
# (below the 30-day token cap); each 802.1X login renews it to 60 days
|
||||
# (policy dot1x-app-password-renew).
|
||||
# Apple: com.apple.wifi.managed with IsHotspot, DomainName and NAIRealmNames
|
||||
# renner.uno (no SSID: Passpoint matching), TTLS (21) with inner PAP, outer
|
||||
# identity anonymous, trusted server name radius.renner.uno, username and
|
||||
# app password. Fixed PayloadIdentifier/UUIDs so a reinstall replaces the
|
||||
# previous profile.
|
||||
passpoint.js: |
|
||||
const ISRG_ROOT_X1 = "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZLubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=";
|
||||
const USER = /^[A-Za-z0-9._@+-]{1,64}$/;
|
||||
@@ -226,38 +190,164 @@ data:
|
||||
return b64(mime);
|
||||
}
|
||||
|
||||
function caller(r) {
|
||||
const user = r.headersIn["X-authentik-username"] || "";
|
||||
return USER.test(user) ? user : "";
|
||||
function xml(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function prepare(r) {
|
||||
const user = caller(r);
|
||||
const password = r.requestText || "";
|
||||
if (!user) { r.return(403); return; }
|
||||
if (!password || password.length > 256) { r.return(400); return; }
|
||||
function mobileconfig(user, password) {
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' +
|
||||
'<plist version="1.0"><dict>' +
|
||||
'<key>PayloadContent</key><array><dict>' +
|
||||
'<key>PayloadType</key><string>com.apple.wifi.managed</string>' +
|
||||
'<key>PayloadVersion</key><integer>1</integer>' +
|
||||
'<key>PayloadIdentifier</key><string>uno.renner.wifi.renner-unifi.network</string>' +
|
||||
'<key>PayloadUUID</key><string>824959bf-c55d-4ed2-b569-c33f3759c2b6</string>' +
|
||||
'<key>PayloadDisplayName</key><string>Renner UniFi</string>' +
|
||||
'<key>PayloadOrganization</key><string>Renner</string>' +
|
||||
'<key>IsHotspot</key><true/>' +
|
||||
'<key>DomainName</key><string>renner.uno</string>' +
|
||||
'<key>NAIRealmNames</key><array><string>renner.uno</string></array>' +
|
||||
'<key>DisplayedOperatorName</key><string>Renner</string>' +
|
||||
'<key>ServiceProviderRoamingEnabled</key><false/>' +
|
||||
'<key>AutoJoin</key><true/>' +
|
||||
'<key>EncryptionType</key><string>Any</string>' +
|
||||
'<key>EAPClientConfiguration</key><dict>' +
|
||||
'<key>UserName</key><string>' + xml(user) + '</string>' +
|
||||
'<key>UserPassword</key><string>' + xml(password) + '</string>' +
|
||||
'<key>OuterIdentity</key><string>anonymous</string>' +
|
||||
'<key>AcceptEAPTypes</key><array><integer>21</integer></array>' +
|
||||
'<key>TTLSInnerAuthentication</key><string>PAP</string>' +
|
||||
'<key>TLSTrustedServerNames</key><array><string>radius.renner.uno</string></array>' +
|
||||
'</dict></dict></array>' +
|
||||
'<key>PayloadType</key><string>Configuration</string>' +
|
||||
'<key>PayloadVersion</key><integer>1</integer>' +
|
||||
'<key>PayloadIdentifier</key><string>uno.renner.wifi.renner-unifi</string>' +
|
||||
'<key>PayloadUUID</key><string>7175a2a3-ddd3-42ea-b4ad-b7e5501892f3</string>' +
|
||||
'<key>PayloadDisplayName</key><string>WLAN Renner UniFi</string>' +
|
||||
'<key>PayloadOrganization</key><string>Renner</string>' +
|
||||
'</dict></plist>\n';
|
||||
}
|
||||
|
||||
const PLATFORMS = {
|
||||
android: { label: "Android" },
|
||||
apple: { label: "Apple" },
|
||||
};
|
||||
|
||||
// "android" or "apple" from /<platform>/install or /<platform>/callback.
|
||||
function platform(r) {
|
||||
return r.uri.split("/")[1];
|
||||
}
|
||||
|
||||
function redirectUri(name) {
|
||||
return "https://wlan.renner.uno/" + name + "/callback";
|
||||
}
|
||||
|
||||
const CLIENT_ID = "wlan-setup";
|
||||
const AUTHORIZE = "https://auth.renner.uno/application/o/authorize/";
|
||||
const STATE_COOKIE = "wlan_oauth_state";
|
||||
const LIFETIME_DAYS = 29;
|
||||
|
||||
function random() {
|
||||
const bytes = new Uint8Array(24);
|
||||
crypto.getRandomValues(bytes);
|
||||
const token = Buffer.from(bytes).toString("base64url");
|
||||
ngx.shared.passpoint.set(token, JSON.stringify({ user: user, body: profile(user, password) }));
|
||||
r.headersOut["Content-Type"] = "text/plain";
|
||||
r.return(200, token);
|
||||
return Buffer.from(bytes).toString("base64url");
|
||||
}
|
||||
|
||||
function deliver(r) {
|
||||
const token = r.uri.substring(r.uri.lastIndexOf("/") + 1);
|
||||
const dict = ngx.shared.passpoint;
|
||||
const entry = dict.get(token);
|
||||
if (!entry) { r.return(404); return; }
|
||||
const data = JSON.parse(entry);
|
||||
if (data.user !== caller(r)) { r.return(404); return; }
|
||||
dict.delete(token);
|
||||
r.headersOut["Content-Type"] = "application/x-wifi-config";
|
||||
r.headersOut["Content-Transfer-Encoding"] = "base64";
|
||||
r.return(200, data.body);
|
||||
function cookie(r, name) {
|
||||
const all = r.headersIn["Cookie"] || "";
|
||||
const parts = all.split(";");
|
||||
for (let n = 0; n < parts.length; n++) {
|
||||
const part = parts[n];
|
||||
const i = part.indexOf("=");
|
||||
if (i > 0 && part.substring(0, i).trim() === name) return part.substring(i + 1).trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export default { prepare, deliver };
|
||||
function form(fields) {
|
||||
return Object.keys(fields).map(k => encodeURIComponent(k) + "=" + encodeURIComponent(fields[k])).join("&");
|
||||
}
|
||||
|
||||
const FORM = "application/x-www-form-urlencoded";
|
||||
const JSON_CT = "application/json";
|
||||
const CRED = /^[A-Za-z0-9._~+\/=-]+$/;
|
||||
|
||||
// authentik call through the internal /_ak location; the arguments
|
||||
// become the Authorization and Content-Type headers there (raw, nginx
|
||||
// does not decode $arg_*).
|
||||
async function ak(r, method, path, scheme, cred, ct, body) {
|
||||
if (!CRED.test(cred)) throw new Error(method + " " + path + ": no credential");
|
||||
const args = "scheme=" + scheme + "&cred=" + cred + "&ct=" + ct;
|
||||
const opts = { method: method, args: args };
|
||||
if (body !== undefined) opts.body = body;
|
||||
const reply = await r.subrequest("/_ak" + path, opts);
|
||||
if (reply.status < 200 || reply.status > 299) {
|
||||
throw new Error(method + " " + path + " -> " + reply.status + " " + reply.responseText.substring(0, 300));
|
||||
}
|
||||
return JSON.parse(reply.responseText);
|
||||
}
|
||||
|
||||
function install(r) {
|
||||
const name = platform(r);
|
||||
const state = random();
|
||||
r.headersOut["Set-Cookie"] = STATE_COOKIE + "=" + state +
|
||||
"; Path=/" + name + "/; Max-Age=300; HttpOnly; Secure; SameSite=Lax";
|
||||
r.return(302, AUTHORIZE + "?" + form({
|
||||
response_type: "code",
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: redirectUri(name),
|
||||
scope: "openid profile goauthentik.io/api",
|
||||
state: state,
|
||||
}));
|
||||
}
|
||||
|
||||
async function callback(r) {
|
||||
const name = platform(r);
|
||||
try {
|
||||
const state = cookie(r, STATE_COOKIE);
|
||||
if (!state || r.args.state !== state || !r.args.code) { r.return(400, "Bitte die Einrichtung neu starten.\n"); return; }
|
||||
const client = Buffer.from(CLIENT_ID + ":" + process.env.WLAN_SETUP_CLIENT_SECRET).toString("base64");
|
||||
const grant = await ak(r, "POST", "/application/o/token/", "Basic", client, FORM, form({
|
||||
grant_type: "authorization_code",
|
||||
code: r.args.code,
|
||||
redirect_uri: redirectUri(name),
|
||||
}));
|
||||
const access = grant.access_token;
|
||||
const me = await ak(r, "GET", "/api/v3/core/users/me/", "Bearer", access, "");
|
||||
const user = me.user.username;
|
||||
if (!USER.test(user) || user !== r.headersIn["X-authentik-username"]) { r.return(403); return; }
|
||||
|
||||
const now = new Date();
|
||||
const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "");
|
||||
const identifier = "wlan-" + name + "-" + user.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase() + "-" + stamp + "-" + random().substring(0, 6).replace(/[^A-Za-z0-9]/g, "x");
|
||||
const expires = new Date(now.getTime() + LIFETIME_DAYS * 86400000).toISOString();
|
||||
await ak(r, "POST", "/api/v3/core/tokens/", "Bearer", access, JSON_CT, JSON.stringify({
|
||||
identifier: identifier,
|
||||
intent: "app_password",
|
||||
description: "WLAN " + PLATFORMS[name].label + ", " + now.toISOString().substring(0, 10) + " (wlan.renner.uno)",
|
||||
expiring: true,
|
||||
expires: expires,
|
||||
}));
|
||||
const key = (await ak(r, "GET", "/api/v3/core/tokens/" + identifier + "/view_key/", "Bearer", access, "")).key;
|
||||
|
||||
r.headersOut["Set-Cookie"] = STATE_COOKIE + "=; Path=/" + name + "/; Max-Age=0; HttpOnly; Secure; SameSite=Lax";
|
||||
if (name === "apple") {
|
||||
r.headersOut["Content-Type"] = "application/x-apple-aspen-config";
|
||||
r.headersOut["Content-Disposition"] = 'attachment; filename="renner-unifi.mobileconfig"';
|
||||
r.return(200, mobileconfig(user, key));
|
||||
} else {
|
||||
r.headersOut["Content-Type"] = "application/x-wifi-config";
|
||||
r.headersOut["Content-Transfer-Encoding"] = "base64";
|
||||
r.return(200, profile(user, key));
|
||||
}
|
||||
} catch (e) {
|
||||
r.error("wlan-setup " + name + ": " + e.message);
|
||||
r.return(502, "Die Einrichtung ist fehlgeschlagen.\n");
|
||||
}
|
||||
}
|
||||
|
||||
export default { install, callback };
|
||||
|
||||
index.html: |
|
||||
<!doctype html>
|
||||
@@ -290,7 +380,7 @@ data:
|
||||
<body>
|
||||
<main>
|
||||
<h1>WLAN einrichten</h1>
|
||||
<p>WLAN <strong>Renner UniFi</strong>. Anmeldung mit deinem Konto <strong id="user">…</strong> und deinem authentik-Passwort.</p>
|
||||
<p>WLAN <strong>Renner UniFi</strong> für dein Konto <strong id="user">…</strong>. Android, iPhone, iPad und Mac bekommen ein eigenes WLAN-App-Passwort; Windows und Linux melden sich mit deinem authentik-Passwort an.</p>
|
||||
|
||||
<div id="detected"></div>
|
||||
|
||||
@@ -302,25 +392,20 @@ data:
|
||||
<template id="t-android">
|
||||
<section data-os="android">
|
||||
<h2>Android</h2>
|
||||
<form id="android-form">
|
||||
<p><label>Passwort für <strong class="u">…</strong><br>
|
||||
<input type="password" id="android-password" autocomplete="current-password" required></label></p>
|
||||
<p><button type="submit">Profil vorbereiten</button></p>
|
||||
</form>
|
||||
<p><a class="button" id="android-install" href="#" hidden>Auf Android installieren</a></p>
|
||||
<p class="muted" id="android-status"></p>
|
||||
<p class="muted">Ändert sich dein Passwort, hier einfach neu installieren.</p>
|
||||
<p><a class="button" href="/android/install">Auf Android installieren</a></p>
|
||||
<p class="muted">Das Gerät bekommt ein eigenes WLAN-App-Passwort in deinem authentik-Konto; dort gelöscht, ist das Gerät aus dem WLAN.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template id="t-apple">
|
||||
<section data-os="apple">
|
||||
<h2>iPhone, iPad, Mac</h2>
|
||||
<p><a class="button" href="/renner-unifi.mobileconfig">Profil laden</a></p>
|
||||
<p><a class="button" href="/apple/install">Profil laden</a></p>
|
||||
<ol>
|
||||
<li>iPhone/iPad: Einstellungen → „Profil geladen“ → Installieren. Mac: Systemeinstellungen → Allgemein → Geräteverwaltung → Profil doppelklicken.</li>
|
||||
<li>Beim ersten Verbinden mit „Renner UniFi“ dein Passwort eingeben.</li>
|
||||
<li>Das Gerät verbindet sich danach selbst mit „Renner UniFi“.</li>
|
||||
</ol>
|
||||
<p class="muted">Das Gerät bekommt ein eigenes WLAN-App-Passwort in deinem authentik-Konto; dort gelöscht, ist das Gerät aus dem WLAN.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -382,25 +467,6 @@ data:
|
||||
.then(function (t) { render(t.trim()); })
|
||||
.catch(function () {});
|
||||
|
||||
// Android: the server builds the Passpoint profile from the password and
|
||||
// keeps it under a one-time token; the installer only starts on a GET
|
||||
// that the user triggers by tapping, so the token becomes a link.
|
||||
document.getElementById("android-form").addEventListener("submit", function (ev) {
|
||||
ev.preventDefault();
|
||||
var field = document.getElementById("android-password");
|
||||
var install = document.getElementById("android-install");
|
||||
var status = document.getElementById("android-status");
|
||||
if (!field.value) return;
|
||||
fetch("/android/prepare", { method: "POST", credentials: "same-origin", body: field.value })
|
||||
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.text(); })
|
||||
.then(function (token) {
|
||||
field.value = "";
|
||||
install.href = "/android/profile/" + token.trim();
|
||||
install.hidden = false;
|
||||
status.textContent = "Bereit. Gültig für 2 Minuten.";
|
||||
})
|
||||
.catch(function () { status.textContent = "Das hat nicht geklappt. Bitte neu laden und erneut versuchen."; });
|
||||
});
|
||||
|
||||
document.getElementById("copy-nmcli").addEventListener("click", function () {
|
||||
var text = document.getElementById("nmcli").textContent;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Client secret of the "WLAN setup" OAuth2 provider (dot1x-radius blueprint).
|
||||
# Generated once; the authentik worker reads it as WLAN_SETUP_CLIENT_SECRET
|
||||
# for the blueprint, wlan-setup reads it for the code exchange.
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
metadata:
|
||||
name: wlan-setup-oauth
|
||||
namespace: authentik
|
||||
spec:
|
||||
length: 64
|
||||
digits: 16
|
||||
symbols: 0
|
||||
allowRepeat: true
|
||||
noUpper: false
|
||||
secretKeys:
|
||||
- client_secret
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: wlan-setup-oauth
|
||||
namespace: authentik
|
||||
spec:
|
||||
refreshPolicy: CreatedOnce
|
||||
target:
|
||||
name: wlan-setup-oauth
|
||||
dataFrom:
|
||||
- sourceRef:
|
||||
generatorRef:
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
name: wlan-setup-oauth
|
||||
@@ -1,8 +1,9 @@
|
||||
# wlan.renner.uno: setup page for the 802.1X WLAN "Renner UniFi" (Apple
|
||||
# profile, Windows import file, Linux nmcli command; config and files in
|
||||
# wlan-setup-config.yaml). Public, so a device can reach it before it is in the
|
||||
# WLAN (mobile data, Renner Gast). Any Authentik user passes the gate (no
|
||||
# group): the page holds no secrets, only the caller's own username.
|
||||
# profile, Windows import file, Linux nmcli command, Android Passpoint profile
|
||||
# with its own app password; config and files in wlan-setup-config.yaml).
|
||||
# Public, so a device can reach it before it is in the WLAN (mobile data,
|
||||
# Renner Gast). Any Authentik user passes the gate (no group); the page stores
|
||||
# no user credentials.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
@@ -10,6 +11,9 @@ metadata:
|
||||
namespace: authentik
|
||||
labels:
|
||||
app.kubernetes.io/name: wlan-setup
|
||||
annotations:
|
||||
configmap.reloader.stakater.com/reload: wlan-setup
|
||||
secret.reloader.stakater.com/reload: wlan-setup-oauth
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
@@ -19,8 +23,6 @@ spec:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: wlan-setup
|
||||
annotations:
|
||||
configmap.reloader.stakater.com/reload: wlan-setup
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
@@ -32,6 +34,13 @@ spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
|
||||
env:
|
||||
# Client secret of the "WLAN setup" OAuth2 provider (passpoint.js).
|
||||
- name: WLAN_SETUP_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wlan-setup-oauth
|
||||
key: client_secret
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
|
||||
Reference in New Issue
Block a user