feat(radius): Android WLAN profile with a per-device app password
wlan.renner.uno signs the user in through the new authentik OAuth2 application wlan-setup (confidential, scope goauthentik.io/api). The callback exchanges the code (client_secret_basic), creates an app password with the user's own access token (10 years) and returns the Passpoint profile carrying it in the response to the user's GET. The page asks for no password and stores nothing; deleting the app password in authentik removes the device. - dot1x-password stage also accepts app passwords (TokenBackend). - Client secret: ESO Password generator wlan-setup-oauth, read by the authentik worker (blueprint) and by wlan-setup. - authentik calls go through an internal nginx location; headers come from $arg_* because subrequests share the parent's variable cache. - Reloader annotations on the wlan-setup Deployment metadata. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+20
-8
@@ -148,18 +148,30 @@ For IPv6 set `LIVE_SOURCE` to the workstation's `fd02:50::` address and use
|
||||
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`
|
||||
WLAN profile with `netsh`, and the Linux `nmcli` command. None of these carries a password.
|
||||
|
||||
Android gets a Passpoint profile (`application/x-wifi-config`) with its own
|
||||
app password. "Auf Android installieren" (`/android/install`) signs the user in
|
||||
through the authentik OAuth2 application `wlan-setup` (scope
|
||||
`goauthentik.io/api`). On `/android/callback`, nginx (njs) exchanges the code,
|
||||
creates an app password (`wlan-android-<user>-<timestamp>`, 10 years) with the
|
||||
user's own access token, and returns the profile in the response to that GET,
|
||||
as the Android installer requires. The page stores nothing. Deleting the app
|
||||
password in authentik removes that device from the WLAN. The profile matches
|
||||
WLANs that advertise Hotspot 2.0 with domain and NAI realm `renner.uno`
|
||||
(EAP-TTLS/PAP).
|
||||
|
||||
App password lifetimes are capped by the group attribute
|
||||
`goauthentik.io/user/token-maximum-lifetime` (default: the token default of
|
||||
30 minutes). WLAN users need a group with
|
||||
`goauthentik.io/user/token-maximum-lifetime: days=3650`, or the creation fails
|
||||
(502 on the callback, reason in the wlan-setup log).
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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 the user in authentik.
|
||||
backends:
|
||||
- authentik.core.auth.InbuiltBackend
|
||||
- authentik.core.auth.TokenBackend
|
||||
|
||||
- id: dot1x-login
|
||||
model: authentik_stages_user_login.userloginstage
|
||||
@@ -1624,3 +1627,43 @@ 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
|
||||
|
||||
- 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;
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
# 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.
|
||||
# carries a credential (the format requires it): 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;
|
||||
@@ -147,19 +153,33 @@ 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: a tap on /android/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 (application/x-wifi-config,
|
||||
# base64, no Content-Disposition), as the Android installer requires.
|
||||
location = /android/install {
|
||||
js_content passpoint.install;
|
||||
}
|
||||
|
||||
location ~ "^/android/profile/[A-Za-z0-9_-]{32}$" {
|
||||
js_content passpoint.deliver;
|
||||
location = /android/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;
|
||||
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 +207,10 @@ 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); its lifetime needs the
|
||||
# group attribute goauthentik.io/user/token-maximum-lifetime >= days=3650.
|
||||
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 +248,104 @@ data:
|
||||
return b64(mime);
|
||||
}
|
||||
|
||||
function caller(r) {
|
||||
const user = r.headersIn["X-authentik-username"] || "";
|
||||
return USER.test(user) ? user : "";
|
||||
}
|
||||
const CLIENT_ID = "wlan-setup";
|
||||
const REDIRECT_URI = "https://wlan.renner.uno/android/callback";
|
||||
const AUTHORIZE = "https://auth.renner.uno/application/o/authorize/";
|
||||
const STATE_COOKIE = "wlan_oauth_state";
|
||||
const LIFETIME_DAYS = 3650;
|
||||
|
||||
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 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 state = random();
|
||||
r.headersOut["Set-Cookie"] = STATE_COOKIE + "=" + state +
|
||||
"; Path=/android/; Max-Age=300; HttpOnly; Secure; SameSite=Lax";
|
||||
r.return(302, AUTHORIZE + "?" + form({
|
||||
response_type: "code",
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: "openid profile goauthentik.io/api",
|
||||
state: state,
|
||||
}));
|
||||
}
|
||||
|
||||
async function callback(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: REDIRECT_URI,
|
||||
}));
|
||||
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-android-" + user.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase() + "-" + stamp;
|
||||
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 Android, " + 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=/android/; Max-Age=0; HttpOnly; Secure; SameSite=Lax";
|
||||
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 android: " + e.message);
|
||||
r.return(502, "Die Einrichtung ist fehlgeschlagen.\n");
|
||||
}
|
||||
}
|
||||
|
||||
export default { install, callback };
|
||||
|
||||
index.html: |
|
||||
<!doctype html>
|
||||
@@ -302,14 +390,8 @@ 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>
|
||||
|
||||
@@ -382,25 +464,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