diff --git a/README.md b/README.md index 60fa871c..383d7da9 100644 --- a/README.md +++ b/README.md @@ -562,6 +562,176 @@ When the playbook is done running, if you got no errors you can login at: look for the word "radius" in the [Role variables](#role-variables) section of this document. +### Configuring FreeRADIUS for WPA Enterprise (EAP-TTLS-PAP) + +You can use OpenWISP RADIUS for setting up WPA Enterprise (EAP-TTLS-PAP) +authentication. This allows to authenticate on WiFi networks using Django +user credentials. Prior to proceeding, ensure you've reviewed the tutorial +on [Setting Up WPA Enterprise (EAP-TTLS-PAP) authentication](https://openwisp.io/docs/tutorials/wpa-enterprise.html). +This documentation section complements the tutorial and focuses solely on +demonstrating the ansible role's capabilities to configure FreeRADIUS. + +**Note**: The ansible role supports OpenWISP's multi-tenancy by creating +individual FreeRADIUS sites for each organization. You must include +configuration details for **each organization** that will use WPA +Enterprise. + +Here's an example playbook which enables OpenWISP RADIUS module, +installs FreeRADIUS, and configures it for WPA Enterprise (EAP-TTLS-PAP): + + ```yaml + - hosts: openwisp2 + become: "{{ become | default('yes') }}" + roles: + - openwisp.openwisp2 + vars: + openwisp2_radius: true + openwisp2_freeradius_install: true + # Define a list of dictionaries detailing each organization's + # name, UUID, RADIUS token, and ports for authentication, + # accounting, and the inner tunnel. These details will be used + # to create FreeRADIUS sites tailored for WPA Enterprise + # (EAP-TTLS-PAP) authentication per organization. + freeradius_eap_orgs: + # A reference name for the organization, + # used in FreeRADIUS configurations. + # Don't use spaces or special characters. + - name: openwisp + # UUID of the organization. + # You can retrieve this from the organization admin + # in the OpenWISP web interface. + uuid: 00000000-0000-0000-0000-000000000000 + # Radius token of the organization. + # You can retrieve this from the organization admin + # in the OpenWISP web interface. + radius_token: secret-radius-token + # Port used by the authentication service for + # this FreeRADIUS site + auth_port: 1822 + # Port used by the accounting service for this FreeRADIUS site + acct_port: 1823 + # Port used by the authentication service of inner tunnel + # for this FreeRADIUS site + inner_tunnel_auth_port: 18230 + # If you want to use a custom certificate for FreeRADIUS + # EAP module, you can specify the path to the CA, server + # certificate, and private key, and DH key as follows. + # Ensure that these files can be read by the "freerad" user. + cert: /etc/freeradius/certs/cert.pem + private_key: /etc/freeradius/certs/key.pem + ca: /etc/freeradius/certs/ca.crt + dh: /etc/freeradius/certs/dh + tls_config_extra: | + private_key_password = whatever + ecdh_curve = "prime256v1" + + # You can add as many organizations as you want + - name: demo + uuid: 00000000-0000-0000-0000-000000000001 + radius_secret: demo-radius-token + auth_port: 1832 + acct_port: 1833 + inner_tunnel_auth_port: 18330 + # If you omit the certificate fields, + # the FreeRADIUS site will use the default certificates + # located in /etc/freeradius/certs. +``` + +**Note**: In the example above, custom ports 1822, 1823, and 18230 +are utilized for FreeRADIUS authentication, accounting, and inner tunnel +authentication, respectively. These custom ports are specified because the +Ansible role creates a common FreeRADIUS site for all organizations, which +also supports captive portal functionality. This common site is configured +to listen on the default FreeRADIUS ports 1812, 1813, and 18120. Therefore, +when configuring WPA Enterprise authentication for each organization, +unique ports must be provided to ensure proper isolation and functionality. + +#### Using Let's Encrypt Certificate for WPA Enterprise (EAP-TTLS-PAP) + +In this section, we demonstrate how to utilize Let's Encrypt certificates +for WPA Enterprise (EAP-TTLS-PAP) authentication. Similar to the +[Automatic SSL certificate](#automatic-ssl-certificate), we use +[geerlingguy.certbot](https://galaxy.ansible.com/geerlingguy/certbot/) +role to automatically install and renew a valid SSL certificate. + +The following example playbook achieves the following goals: + +- Provision a separate Let's Encrypt certificate for the + `freeradius.yourdomain.com` hostname. This certificate will be + utilized by the FreeRADIUS site for WPA Enterprise authentication. +- Create a renewal hook to set permissions on the generated certificate + so the FreeRADIUS server can read it. + +**Note**: You can also use the same SSL certificate for both Nginx and +FreeRADIUS, but it's crucial to understand the security implications. +Please exercise caution and refer to the example playbook comments for +guidance. + +```yaml + - hosts: openwisp2 + become: "{{ become | default('yes') }}" + roles: + - geerlingguy.certbot + - openwisp.openwisp2 + vars: + # certbot configuration + certbot_auto_renew_minute: "20" + certbot_auto_renew_hour: "5" + certbot_create_if_missing: true + certbot_auto_renew_user: "" + certbot_certs: + - email: "" + domains: + - "{{ inventory_hostname }}" + # If you choose to re-use the same certificate for both services, + # you can omit the following item in your playbook. + - email: "" + domains: + - "freeradius.yourdomain.com" + # Configuration to use Let's Encrypt certificate for OpenWISP server (Nnginx) + openwisp2_ssl_cert: "/etc/letsencrypt/live/{{ inventory_hostname }}/fullchain.pem" + openwisp2_ssl_key: "/etc/letsencrypt/live/{{ inventory_hostname }}/privkey.pem" + # Configuration for openwisp-radius + openwisp2_radius: true + openwisp2_freeradius_install: true + freeradius_eap_orgs: + - name: demo + uuid: 00000000-0000-0000-0000-000000000001 + radius_secret: demo-radius-token + auth_port: 1832 + acct_port: 1833 + inner_tunnel_auth_port: 18330 + # Update the cert_file and private_key paths to point to the + # Let's Encrypt certificate. + cert: /etc/letsencrypt/live/freeradius.yourdomain.com/fullchain.pem + private_key: /etc/letsencrypt/live/freeradius.yourdomain.com/privkey.pem + # If you choose to re-use the same certificate for both services, + # your configuration would look like this + # cert: /etc/letsencrypt/live/{{ inventory_hostname }}/fullchain.pem + # private_key: /etc/letsencrypt/live/{{ inventory_hostname }}/privkey.pem + tasks: + # Tasks to ensure the Let's Encrypt certificate can be read by the FreeRADIUS server. + # If you are using the same certificate for both services, you need to + # replace "freeradius.yourdomain.com" with "{{ inventory_hostname }}" + # in the following task. + - name: "Create a renewal hook for setting permissions on /etc/letsencrypt/live/freeradius.yourdomain.com" + copy: + content: | + #!/bin/bash + chown -R root:freerad /etc/letsencrypt/live/ /etc/letsencrypt/archive/ + chmod 0750 /etc/letsencrypt/live/ /etc/letsencrypt/archive/ + chmod -R 0640 /etc/letsencrypt/archive/freeradius.yourdomain.com/ + chmod 0750 /etc/letsencrypt/archive/freeradius.yourdomain.com/ + dest: /etc/letsencrypt/renewal-hooks/post/chown_freerad + owner: root + group: root + mode: '0700' + register: chown_freerad_result + - name: Change the ownership of the certificate files + when: chown_freerad_result.changed + command: /etc/letsencrypt/renewal-hooks/post/chown_freerad +``` + Configuring CORS Headers ------------------------ @@ -1252,9 +1422,55 @@ Below are listed all the variables you can customize (you may also want to take # Sets the source path of the template that contains freeradius site configuration. # Defaults to "templates/freeradius/openwisp_site.j2" shipped in the role. freeradius_openwisp_site_template_src: custom_freeradius_site.j2 + # Whether to deploy the default openwisp_site for FreeRADIUS. + # Defaults to true. + freeradius_deploy_openwisp_site: false # FreeRADIUS listen address for the openwisp_site. # Defaults to "*", i.e. listen on all interfaces. freeradius_openwisp_site_listen_ipaddr: "10.8.0.1" + # A list of dict that includes organization's name, UUID, RADIUS token, + # TLS configuration, and ports for authentication, accounting, and inner tunnel. + # This list of dict is used to generate FreeRADIUS sites that support + # WPA Enterprise (EAP-TTLS-PAP) authentication. + # Defaults to an empty list. + freeradius_eap_orgs: + # The name should not contain spaces or special characters + - name: openwisp + # UUID of the organization can be retrieved from the OpenWISP admin + uuid: 00000000-0000-0000-0000-000000000000 + # Radius token of the organization can be retrieved from the OpenWISP admin + radius_token: secret-radius-token + # Port used by the authentication service for this FreeRADIUS site + auth_port: 1832 + # Port used by the accounting service for this FreeRADIUS site + acct_port: 1833 + # Port used by the authentication service of inner tunnel for this FreeRADIUS site + inner_tunnel_auth_port: 18330 + # CA certificate for the FreeRADIUS site + ca: /etc/freeradius/certs/ca.crt + # TLS certificate for the FreeRADIUS site + cert: /etc/freeradius/certs/cert.pem + # TLS private key for the FreeRADIUS site + private_key: /etc/freeradius/certs/key.pem + # Diffie-Hellman key for the FreeRADIUS site + dh: /etc/freeradius/certs/dh + # Extra instructions for the "tls-config" section of the EAP module + # for the FreeRADIUS site + tls_config_extra: | + private_key_password = whatever + ecdh_curve = "prime256v1" + # Sets the source path of the template that contains freeradius site configuration + # for WPA Enterprise (EAP-TTLS-PAP) authentication. + # Defaults to "templates/freeradius/eap/openwisp_site.j2" shipped in the role. + freeradius_eap_openwisp_site_template_src: custom_eap_openwisp_site.j2 + # Sets the source path of the template that contains freeradius inner tunnel + # configuration for WPA Enterprise (EAP-TTLS-PAP) authentication. + # Defaults to "templates/freeradius/eap/inner_tunnel.j2" shipped in the role. + freeradius_eap_inner_tunnel_template_src: custom_eap_inner_tunnel.j2 + # Sets the source path of the template that contains freeradius EAP configuration + # for WPA Enterprise (EAP-TTLS-PAP) authentication. + # Defaults to "templates/freeradius/eap/eap.j2" shipped in the role. + freeradius_eap_template_src: custom_eap.j2 cron_delete_old_notifications: "'hour': 0, 'minute': 0" cron_deactivate_expired_users: "'hour': 0, 'minute': 5" cron_delete_old_radiusbatch_users: "'hour': 0, 'minute': 10" diff --git a/defaults/main.yml b/defaults/main.yml index 13a0c2e8..cb1060f7 100755 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -176,6 +176,7 @@ freeradius_mods_config_dir: "{{ freeradius_dir }}/mods-config" freeradius_sites_available_dir: "{{ freeradius_dir }}/sites-available" freeradius_sites_enabled_dir: "{{ freeradius_dir }}/sites-enabled" freeradius_openwisp_site_template_src: freeradius/openwisp_site.j2 +freeradius_deploy_openwisp_site: true freeradius_db_map: django.contrib.gis.db.backends.spatialite: driver: rlm_sql_sqlite @@ -197,6 +198,10 @@ freeradius_rest: freeradius_expire_attr_after_seconds: 86400 freeradius_safe_characters: "+@abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_: /" freeradius_openwisp_site_listen_ipaddr: "*" +freeradius_eap_orgs: [] +freeradius_eap_openwisp_site_template_src: freeradius/eap/openwisp_site.j2 +freeradius_eap_inner_tunnel_template_src: freeradius/eap/inner_tunnel.j2 +freeradius_eap_template_src: freeradius/eap/eap.j2 cron_delete_old_notifications: "'hour': 0, 'minute': 0" cron_deactivate_expired_users: "'hour': 0, 'minute': 5" cron_delete_old_radiusbatch_users: "'hour': 0, 'minute': 10" diff --git a/molecule/default/molecule.yml b/molecule/default/molecule.yml index c0ac9349..da1dc533 100644 --- a/molecule/default/molecule.yml +++ b/molecule/default/molecule.yml @@ -15,7 +15,8 @@ platforms: image: "geerlingguy/docker-${MOLECULE_DISTRO}-ansible:${tag:-latest}" command: ${MOLECULE_DOCKER_COMMAND:-""} volumes: - - /sys/fs/cgroup:/sys/fs/cgroup:ro + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroupns_mode: host privileged: true pre_build_image: true provisioner: diff --git a/molecule/local/molecule.yml b/molecule/local/molecule.yml index 9b273ff8..a029cca5 100644 --- a/molecule/local/molecule.yml +++ b/molecule/local/molecule.yml @@ -15,21 +15,24 @@ platforms: image: "geerlingguy/docker-ubuntu2204-ansible:latest" command: ${MOLECULE_DOCKER_COMMAND:-""} volumes: - - /sys/fs/cgroup:/sys/fs/cgroup:ro + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroupns_mode: host privileged: true pre_build_image: true - name: "openwisp2-ubuntu2004" image: "geerlingguy/docker-ubuntu2004-ansible:latest" command: ${MOLECULE_DOCKER_COMMAND:-""} volumes: - - /sys/fs/cgroup:/sys/fs/cgroup:ro + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroupns_mode: host privileged: true pre_build_image: true - name: "openwisp2-debian11" image: "geerlingguy/docker-debian11-ansible:latest" command: ${MOLECULE_DOCKER_COMMAND:-""} volumes: - - /sys/fs/cgroup:/sys/fs/cgroup:ro + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroupns_mode: host privileged: true pre_build_image: true provisioner: diff --git a/molecule/resources/converge.yml b/molecule/resources/converge.yml index c91955d6..a721af4f 100644 --- a/molecule/resources/converge.yml +++ b/molecule/resources/converge.yml @@ -12,6 +12,13 @@ openwisp2_uwsgi_extra_conf: | single-interpreter=True openwisp2_usage_metric_collection: false + freeradius_eap_orgs: + - name: openwisp + uuid: 00000000-0000-0000-0000-000000000000 + radius_token: secret-radius-token + auth_port: 1822 + acct_port: 1823 + inner_tunnel_auth_port: 18230 pre_tasks: - name: Update apt cache @@ -20,6 +27,11 @@ cache_valid_time: 600 when: ansible_os_family == 'Debian' + - name: Install net-tools + apt: + name: + - net-tools + - name: Remove the .dockerenv file file: path: /.dockerenv diff --git a/molecule/resources/verify.yml b/molecule/resources/verify.yml index f8804fc2..5cffad38 100644 --- a/molecule/resources/verify.yml +++ b/molecule/resources/verify.yml @@ -60,3 +60,9 @@ - name: Show OpenWisp log debug: var: openwisp_log + + - name: Check if FreeRADIUS is listening on WPA Enterprise site ports + shell: "netstat -tuln | grep -Eq '1822|1823|18230'" + register: freeradius_eap_ports # Register the output and return code + failed_when: freeradius_eap_ports.rc + changed_when: false diff --git a/tasks/freeradius.yml b/tasks/freeradius.yml index d6851df6..a66c4cb8 100644 --- a/tasks/freeradius.yml +++ b/tasks/freeradius.yml @@ -190,6 +190,7 @@ mode: 0640 owner: freerad group: freerad + when: freeradius_deploy_openwisp_site notify: Restart freeradius - name: Inner tunnel @@ -199,4 +200,13 @@ mode: 0640 owner: freerad group: freerad + when: freeradius_deploy_openwisp_site notify: Restart freeradius + +- name: Copy configuration for WPA Enterprise TTLS + include_tasks: tasks/freeradius_eap.yml + loop: "{{ freeradius_eap_orgs }}" + loop_control: + loop_var: org + when: freeradius_eap_orgs + tags: [freeradius_eap] diff --git a/tasks/freeradius_eap.yml b/tasks/freeradius_eap.yml new file mode 100644 index 00000000..6115b0e1 --- /dev/null +++ b/tasks/freeradius_eap.yml @@ -0,0 +1,71 @@ +--- + + + +- name: Generate default DH key and certificates + block: + - name: "Check if {{ freeradius_dir }}/certs/dh exists" + ansible.builtin.stat: + path: "{{ freeradius_dir }}/certs/dh" + register: cert_dh_exists + - name: Generate DH key and certificates + command: + cmd: make destroycerts dh all + chdir: "{{ freeradius_dir }}/certs" + when: not cert_dh_exists.stat.exists + notify: Restart freeradius + tags: [freeradius_eap] + +- name: "Copy {{ org.name }} EAP openwisp_site" + template: + src: "{{ freeradius_eap_openwisp_site_template_src }}" + dest: "{{ freeradius_dir }}/sites-available/{{ org.name }}_eap_openwisp_site" + owner: freerad + group: freerad + mode: '0644' + notify: Restart freeradius + tags: [freeradius_eap] + +- name: "Create a symlink in sites-enabled for {{ org.name }} EAP openwisp_site" + ansible.builtin.file: + src: "{{ freeradius_dir }}/sites-available/{{ org.name }}_eap_openwisp_site" + dest: "{{ freeradius_dir }}/sites-enabled/{{ org.name }}_eap_openwisp_site" + state: link + notify: Restart freeradius + tags: [freeradius_eap] + +- name: "Copy {{ org.name }} eap_inner_tunnel" + template: + src: "{{ freeradius_eap_inner_tunnel_template_src }}" + dest: "{{ freeradius_dir }}/sites-available/{{ org.name }}_eap_inner_tunnel" + owner: freerad + group: freerad + mode: '0644' + notify: Restart freeradius + tags: [freeradius_eap] + +- name: "Create a symlink in sites-enabled for {{ org.name }} eap_inner_tunnel" + ansible.builtin.file: + src: "{{ freeradius_dir }}/sites-available/{{ org.name }}_eap_inner_tunnel" + dest: "{{ freeradius_dir }}/sites-enabled/{{ org.name }}_eap_inner_tunnel" + state: link + notify: Restart freeradius + tags: [freeradius_eap] + +- name: Copy {{ org.name }} custom EAP configuration in mods-available + template: + src: "{{ freeradius_eap_template_src }}" + dest: "{{ freeradius_dir }}/mods-available/{{ org.name }}_eap" + owner: freerad + group: freerad + mode: '0644' + notify: Restart freeradius + tags: [freeradius_eap] + +- name: Create a symlink in mods-enabled + ansible.builtin.file: + src: "{{ freeradius_dir }}/mods-available/{{ org.name }}_eap" + dest: "{{ freeradius_dir }}/mods-enabled/{{ org.name }}_eap" + state: link + notify: Restart freeradius + tags: [freeradius_eap] diff --git a/templates/freeradius/eap/eap.j2 b/templates/freeradius/eap/eap.j2 new file mode 100644 index 00000000..fa311c48 --- /dev/null +++ b/templates/freeradius/eap/eap.j2 @@ -0,0 +1,47 @@ +eap {{ org.name }}_eap { + default_eap_type = ttls + timer_expire = 60 + ignore_unknown_eap_types = no + cisco_accounting_username_bug = no + max_sessions = ${max_requests} + + tls-config tls-common { + # make sure to have a valid SSL certificate for production usage + private_key_password = whatever + private_key_file = {{ org.private_key | default('${certdir}/server.pem') }} + certificate_file = {{ org.cert | default('${certdir}/server.pem') }} + ca_file = {{ org.ca | default('${cadir}/ca.pem') }} + dh_file = {{ org.dh | default('${certdir}/dh') }} + ca_path = ${cadir} + cipher_list = "DEFAULT" + cipher_server_preference = no + tls_min_version = "1.2" + tls_max_version = "1.2" + check_crl = no + check_cert_issuer = no + fragment_size = 2048 + auto_chain = yes + + {% if 'tls_config_extra' in org %} + {{ org.tls_config_extra }} + {% endif %} + + cache { + enable = no + } + + ocsp { + enable = no + override_cert_url = yes + url = "http://127.0.0.1/ocsp/" + } + } + + ttls { + tls = tls-common + default_eap_type = pap + copy_request_to_tunnel = yes + use_tunneled_reply = yes + virtual_server = "{{ org.name }}_eap_inner_tunnel" + } +} diff --git a/templates/freeradius/eap/inner_tunnel.j2 b/templates/freeradius/eap/inner_tunnel.j2 new file mode 100644 index 00000000..89500f2f --- /dev/null +++ b/templates/freeradius/eap/inner_tunnel.j2 @@ -0,0 +1,84 @@ +server {{ org.name }}_eap_inner_tunnel { + listen { + ipaddr = 127.0.0.1 + port = {{ org.inner_tunnel_auth_port }} + type = auth + } + + api_token_header = "Authorization: Bearer {{ org.uuid }} {{ org.radius_token }}" + authorize { + filter_username + update control { &REST-HTTP-Header += "${...api_token_header}" } + rest + {{ org.name }}_eap { + ok = return + } + + chap + mschap + suffix + + update control { + &Proxy-To-Realm := LOCAL + } + + eap { + ok = return + } + + -ldap + + pap + + expiration + logintime + } + + authenticate { + Auth-Type PAP { + pap + } + + Auth-Type CHAP { + chap + } + + Auth-Type MS-CHAP { + mschap + } + eap + } + + session {} + + post-auth { + if (0) { + update reply { + User-Name !* ANY + Message-Authenticator !* ANY + EAP-Message !* ANY + Proxy-State !* ANY + MS-MPPE-Encryption-Types !* ANY + MS-MPPE-Encryption-Policy !* ANY + MS-MPPE-Send-Key !* ANY + MS-MPPE-Recv-Key !* ANY + } + update { + &outer.session-state: += &reply: + } + } + + Post-Auth-Type REJECT { + attr_filter.access_reject + update outer.session-state { + &Module-Failure-Message := &request:Module-Failure-Message + } + } + } + + pre-proxy {} + post-proxy { + {{ org.name }}_eap + eap + } +} diff --git a/templates/freeradius/eap/openwisp_site.j2 b/templates/freeradius/eap/openwisp_site.j2 new file mode 100644 index 00000000..1af20abb --- /dev/null +++ b/templates/freeradius/eap/openwisp_site.j2 @@ -0,0 +1,79 @@ +server {{ org.name }}_eap_openwisp_site { + listen { + type = auth + ipaddr = {{ org.listen_ipaddr | default(freeradius_openwisp_site_listen_ipaddr) }} + port = {{ org.auth_port }} + limit { + max_connections = 16 + lifetime = 0 + idle_timeout = 30 + } + } + + listen { + ipaddr = {{ org.listen_ipaddr | default(freeradius_openwisp_site_listen_ipaddr) }} + port = {{ org.acct_port }} + type = acct + limit {} + } + + api_token_header = "Authorization: Bearer {{ org.uuid }} {{ org.radius_token }}" + authorize { + {{ org.name }}_eap { + ok = return + } + update control { &REST-HTTP-Header += "${...api_token_header}" } + filter_username + rest + expiration + logintime + } + + authenticate { + Auth-Type {{ org.name }}_eap { + {{ org.name }}_eap + } + Auth-Type PAP { + pap + } + + Auth-Type CHAP { + chap + } + + Auth-Type MS-CHAP { + mschap + } + + Auth-Type EAP { + eap + } + } + + preacct { + preprocess + acct_unique + suffix + files + } + + accounting { + update control { &REST-HTTP-Header += "${...api_token_header}" } + rest + } + + session {} + + post-auth { + update control { &REST-HTTP-Header += "${...api_token_header}" } + rest + + Post-Auth-Type REJECT { + update control { &REST-HTTP-Header += "${....api_token_header}" } + rest + } + } + + pre-proxy {} + post-proxy {} +}