From 44c70816f327fc9319eb165477c0cbe1e15257f4 Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:59:13 -0600 Subject: [PATCH 01/48] Adding header auth for existing users --- application/config/config.sample.php | 10 +++ application/controllers/Header_auth.php | 81 +++++++++++++++++++++++++ application/controllers/User.php | 2 + application/views/user/login.php | 8 +++ install/config/config.php | 10 +++ 5 files changed, 111 insertions(+) create mode 100644 application/controllers/Header_auth.php diff --git a/application/config/config.sample.php b/application/config/config.sample.php index 15c3cb3c7..db8660972 100644 --- a/application/config/config.sample.php +++ b/application/config/config.sample.php @@ -100,6 +100,11 @@ $config['qrzru_password'] = ''; | 'auth_mode' Minimum user level required 0 = anonymous, 1 = viewer, | 2 = editor, 3 = api user, 99 = owner | 'auth_level[]' Defines level titles +| +| 'auth_header_enable' False disables header based authentication +| 'auth_header_create' False disables user creation if user doesn't exist +| 'auth_header_value' Which header provides authenticated username +| 'auth_header_text' Display text on login screen */ $config['use_auth'] = true; @@ -109,6 +114,11 @@ $config['auth_mode'] = '3'; $config['auth_level'][3] = 'Operator'; $config['auth_level'][99] = 'Administrator'; +$config['auth_header_enable'] = false; +$config['auth_header_create'] = false; +$config['auth_header_value'] = "HTTP_X-Username"; +$config['auth_header_text'] = "Login with SSO"; + /* |-------------------------------------------------------------------------- | Base Site URL diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php new file mode 100644 index 000000000..8037cc16f --- /dev/null +++ b/application/controllers/Header_auth.php @@ -0,0 +1,81 @@ +load->model('user_model'); + $this->load->library('session'); + $this->load->helper('url'); + } + + /** + * Authenticate using a trusted request header. + * Expected to be called from a login-screen button. + */ + public function login() + { + // Guard: feature must be enabled + if (!$this->config->item('auth_header_enable')) { + $this->session->set_flashdata('error', __('Header authentication is disabled.')); + redirect('user/login'); + } + + $headerName = $this->config->item('auth_header_value') ?: ''; + if (empty($headerName)) { + $this->session->set_flashdata('error', __('Missing header setting.')); + redirect('user/login'); + } + $username = $this->input->server($headerName, true); + + if (empty($username)) { + $this->session->set_flashdata('error', __('Missing username header.')); + redirect('user/login'); + } + + // Look up user by the header value + $query = $this->user_model->get($username); + if (!$query || $query->num_rows() !== 1) { + $this->session->set_flashdata('error', __('User not found.')); + redirect('user/login'); + } + + + $user = $query->row(); + + // Prevent clubstation direct login via header (mirrors User::login) + if (!empty($user->clubstation) && $user->clubstation == 1) { + $this->session->set_flashdata('error', __("You can't login to a clubstation directly. Use your personal account instead.")); + redirect('user/login'); + } + + // Maintenance mode check (admin only allowed) + if (ENVIRONMENT === 'maintenance' && (int)$user->user_type !== 99) { + $this->session->set_flashdata('error', __("Sorry. This instance is currently in maintenance mode. Only administrators are currently allowed to log in.")); + redirect('user/login'); + } + + // Establish session + $this->user_model->update_session($user->user_id); + $this->user_model->set_last_seen($user->user_id); + + // Set language cookie (mirrors User::login) + $cookie = [ + 'name' => $this->config->item('gettext_cookie', 'gettext'), + 'value' => $user->user_language, + 'expire' => 1000, + 'secure' => false, + ]; + $this->input->set_cookie($cookie); + + log_message('info', "User ID [{$user->user_id}] logged in via header auth."); + redirect('dashboard'); + } +} diff --git a/application/controllers/User.php b/application/controllers/User.php index 34069fe54..11e2c5c53 100644 --- a/application/controllers/User.php +++ b/application/controllers/User.php @@ -1211,6 +1211,8 @@ class User extends CI_Controller { if ($this->form_validation->run() == FALSE) { $data['page_title'] = __("Login"); $data['https_check'] = $this->https_check(); + $data['auth_header_enable'] = $this->config->item('auth_header_enable'); + $data['auth_header_text'] = $this->config->item('auth_header_text'); $this->load->view('interface_assets/mini_header', $data); $this->load->view('user/login'); $this->load->view('interface_assets/footer'); diff --git a/application/views/user/login.php b/application/views/user/login.php index c53fd56ed..102e6ff7b 100644 --- a/application/views/user/login.php +++ b/application/views/user/login.php @@ -73,6 +73,14 @@ " autocomplete="current-password"> + +
+ + + +
+
diff --git a/install/config/config.php b/install/config/config.php index 1e4ad582d..71c65a986 100644 --- a/install/config/config.php +++ b/install/config/config.php @@ -100,6 +100,11 @@ $config['qrzru_password'] = '%qrzru_password%'; | 'auth_mode' Minimum user level required 0 = anonymous, 1 = viewer, | 2 = editor, 3 = api user, 99 = owner | 'auth_level[]' Defines level titles +| +| 'auth_header_enable' False disables header based authentication +| 'auth_header_create' False disables user creation if user doesn't exist +| 'auth_header_value' Which header provides authenticated username +| 'auth_header_text' Display text on login screen */ $config['use_auth'] = true; @@ -109,6 +114,11 @@ $config['auth_mode'] = '3'; $config['auth_level'][3] = 'Operator'; $config['auth_level'][99] = 'Administrator'; +$config['auth_header_enable'] = false; +$config['auth_header_create'] = false; +$config['auth_header_value'] = "HTTP_X-Username"; +$config['auth_header_text'] = "Login with SSO"; + /* |-------------------------------------------------------------------------- | Base Site URL From 8027474f2d03c5eed1b0042e32865934d2fdfc8d Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:46:44 -0600 Subject: [PATCH 02/48] Adding user creation with club memebership --- application/config/config.sample.php | 2 + application/controllers/Header_auth.php | 51 ++++++++++++++++- application/models/User_model.php | 76 +++++++++++++++++++++++++ install/config/config.php | 2 + 4 files changed, 128 insertions(+), 3 deletions(-) diff --git a/application/config/config.sample.php b/application/config/config.sample.php index db8660972..b387eeeae 100644 --- a/application/config/config.sample.php +++ b/application/config/config.sample.php @@ -105,6 +105,7 @@ $config['qrzru_password'] = ''; | 'auth_header_create' False disables user creation if user doesn't exist | 'auth_header_value' Which header provides authenticated username | 'auth_header_text' Display text on login screen +| 'auth_header_club_id' Default club ID to add new users to */ $config['use_auth'] = true; @@ -118,6 +119,7 @@ $config['auth_header_enable'] = false; $config['auth_header_create'] = false; $config['auth_header_value'] = "HTTP_X-Username"; $config['auth_header_text'] = "Login with SSO"; +$config['auth_header_club_id'] = ""; /* |-------------------------------------------------------------------------- diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 8037cc16f..a00f326b4 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -43,10 +43,33 @@ class Header_auth extends CI_Controller // Look up user by the header value $query = $this->user_model->get($username); if (!$query || $query->num_rows() !== 1) { - $this->session->set_flashdata('error', __('User not found.')); - redirect('user/login'); + + // Config check if create user + if ($this->config->item('auth_header_create')) { + $this->load->model('user_model'); + $club_id = $this->config->item('auth_header_club_id'); + $result = $this->user_model->add_minimal(username: $username, club_id: $club_id); + + switch ($result) { + case EUSERNAMEEXISTS: + $data['username_error'] = sprintf(__("Username %s already in use!"), '' . $this->input->post('user_name') . ''); + break; + case EEMAILEXISTS: + $data['email_error'] = sprintf(__("E-mail %s already in use!"), '' . $this->input->post('user_email') . ''); + break; + case EPASSWORDINVALID: + $data['password_error'] = __("Invalid Password!"); + break; + case OK: + redirect('header_auth/login'); + return; + } + } else { + $this->session->set_flashdata('error', __('User not found.')); + redirect('user/login'); + } } - + $user = $query->row(); @@ -75,6 +98,28 @@ class Header_auth extends CI_Controller ]; $this->input->set_cookie($cookie); + $this->load->model('user_model'); + // Get full user record + $user = $this->user_model->get($username)->row(); + + // Critical: Update session data + $this->user_model->update_session($user->user_id); + $this->user_model->set_last_seen($user->user_id); + + // Set essential session data + $this->session->set_userdata(array( + 'user_id' => $user->user_id, + 'user_name' => $user->user_name, + 'user_type' => $user->user_type, + 'user_stylesheet' => $user->user_stylesheet ?? 'bootstrap', + 'user_column1' => $user->user_column1 ?? 'Mode', + 'user_column2' => $user->user_column2 ?? 'RSTS', + 'user_column3' => $user->user_column3 ?? 'RSTR', + 'user_column4' => $user->user_column4 ?? 'Band', + 'user_column5' => $user->user_column5 ?? 'Country', + // Add other preferences as needed + )); + log_message('info', "User ID [{$user->user_id}] logged in via header auth."); redirect('dashboard'); } diff --git a/application/models/User_model.php b/application/models/User_model.php index 34385a7e6..8c9e66da4 100644 --- a/application/models/User_model.php +++ b/application/models/User_model.php @@ -323,6 +323,82 @@ class User_Model extends CI_Model { } } + /** + * FUNCTION: bool add_minimal($username, $firstname = null, $lastname = null, $callsign = null, $email = null, $club_id = null) + * Add a user with minimal required fields (username only) with option to add to club as user + */ + function add_minimal($username, $firstname = null, $lastname = null, $callsign = null, $email = null, $club_id = null) { + // Check that the username isn't already used + if(!$this->exists($username)) { + $data = array( + 'user_name' => xss_clean($username), + 'user_password' => bin2hex(random_bytes(16)), // Random password + 'user_email' => xss_clean($email) ?? '', + 'user_firstname' => xss_clean($firstname) ?? '', + 'user_lastname' => xss_clean($lastname) ?? '', + 'user_callsign' => strtoupper(xss_clean($callsign)) ?? '', + 'user_type' => 3, + 'user_locator' => '', + 'user_stylesheet' => 'darkly', + 'user_language' => 'english', + 'user_timezone' => '1', + 'user_date_format' => 'd/m/y', + 'user_measurement_base' => 'M', + 'user_column1' => 'Mode', + 'user_column2' => 'RSTS', + 'user_column3' => 'RSTR', + 'user_column4' => 'Band', + 'user_column5' => 'Country', + 'user_qso_end_times' => 0, + 'user_show_profile_image' => 0, + 'user_qth_lookup' => 0, + 'user_sota_lookup' => 0, + 'user_wwff_lookup' => 0, + 'user_pota_lookup' => 0, + 'user_show_notes' => 0, + 'user_quicklog' => 0, + 'user_quicklog_enter' => 0, + 'user_previous_qsl_type' => 0, + 'user_default_band' => 'All', + 'user_lotw_name' => '', + 'user_lotw_password' => '', + 'user_eqsl_name' => '', + 'user_eqsl_password' => '', + 'user_clublog_name' => '', + 'user_clublog_password' => '', + 'user_amsat_status_upload' => 0, + 'user_mastodon_url' => '', + ); + + // Check the email address isn't in use (if provided) + if($email && $this->exists_by_email($email)) { + return EEMAILEXISTS; + } + + // Generate user-slug + if (!$this->load->is_loaded('encryption')) { + $this->load->library('encryption'); + } + $user_slug_base = md5($this->encryption->encrypt($username)); + $user_slug = substr($user_slug_base, 0, USER_SLUG_LENGTH); + $data['slug'] = $user_slug; + + // Add user + $this->db->insert($this->config->item('auth_table'), $data); + $insert_id = $this->db->insert_id(); + + // Add user to club + if ($club_id && is_numeric($club_id)) { + $this->load->model('club_model'); + $this->club_model->alter_member($club_id, $insert_id, 3); + } + + return OK; + } else { + return EUSERNAMEEXISTS; + } + } + // FUNCTION: bool edit() // Edit a user function edit($fields) { diff --git a/install/config/config.php b/install/config/config.php index 71c65a986..e90ff13da 100644 --- a/install/config/config.php +++ b/install/config/config.php @@ -105,6 +105,7 @@ $config['qrzru_password'] = '%qrzru_password%'; | 'auth_header_create' False disables user creation if user doesn't exist | 'auth_header_value' Which header provides authenticated username | 'auth_header_text' Display text on login screen +| 'auth_header_club_id' Default club ID to add new users to */ $config['use_auth'] = true; @@ -118,6 +119,7 @@ $config['auth_header_enable'] = false; $config['auth_header_create'] = false; $config['auth_header_value'] = "HTTP_X-Username"; $config['auth_header_text'] = "Login with SSO"; +$config['auth_header_club_id'] = ""; /* |-------------------------------------------------------------------------- From 7ffc4b87733da104646819967c5297aa6d58af80 Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:20:33 -0600 Subject: [PATCH 03/48] Adding config for name, callsign, and email --- application/config/config.sample.php | 20 ++++++++++++----- application/controllers/Header_auth.php | 30 ++++++++++++++++++++----- install/config/config.php | 20 ++++++++++++----- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/application/config/config.sample.php b/application/config/config.sample.php index b387eeeae..9bf39420b 100644 --- a/application/config/config.sample.php +++ b/application/config/config.sample.php @@ -101,11 +101,15 @@ $config['qrzru_password'] = ''; | 2 = editor, 3 = api user, 99 = owner | 'auth_level[]' Defines level titles | -| 'auth_header_enable' False disables header based authentication -| 'auth_header_create' False disables user creation if user doesn't exist -| 'auth_header_value' Which header provides authenticated username -| 'auth_header_text' Display text on login screen -| 'auth_header_club_id' Default club ID to add new users to +| 'auth_header_enable' False disables header based authentication +| 'auth_header_create' False disables user creation for header based authentication +| 'auth_headers_username' Which header provides authenticated username +| 'auth_headers_firstname' Which header provides authenticated first name +| 'auth_headers_lastname' Which header provides authenticated last name +| 'auth_headers_callsign' Which header provides authenticated callsign +| 'auth_headers_email' Which header provides authenticated email +| 'auth_header_text' Display text on login screen +| 'auth_header_club_id' Default club ID to add new users to */ $config['use_auth'] = true; @@ -117,7 +121,11 @@ $config['auth_level'][99] = 'Administrator'; $config['auth_header_enable'] = false; $config['auth_header_create'] = false; -$config['auth_header_value'] = "HTTP_X-Username"; +$config['auth_headers_username'] = "HTTP_X-Username"; +$config['auth_headers_firstname'] = null; +$config['auth_headers_lastname'] = null; +$config['auth_headers_callsign'] = null; +$config['auth_headers_email'] = null; $config['auth_header_text'] = "Login with SSO"; $config['auth_header_club_id'] = ""; diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index a00f326b4..11f843b9e 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -28,12 +28,14 @@ class Header_auth extends CI_Controller redirect('user/login'); } - $headerName = $this->config->item('auth_header_value') ?: ''; - if (empty($headerName)) { + + // Get username from header + $headerUsername = $this->config->item('auth_headers_username') ?: ''; + if (empty($headerUsername)) { $this->session->set_flashdata('error', __('Missing header setting.')); redirect('user/login'); } - $username = $this->input->server($headerName, true); + $username = $this->input->server($headerUsername, true); if (empty($username)) { $this->session->set_flashdata('error', __('Missing username header.')); @@ -47,8 +49,26 @@ class Header_auth extends CI_Controller // Config check if create user if ($this->config->item('auth_header_create')) { $this->load->model('user_model'); - $club_id = $this->config->item('auth_header_club_id'); - $result = $this->user_model->add_minimal(username: $username, club_id: $club_id); + $firstnameHeader = $this->config->item('auth_headers_firstname') ?: ''; + if (!empty($firstnameHeader)) { + $firstname = $this->input->server($firstnameHeader, true); + } + $lastnameHeader = $this->config->item('auth_headers_lastname') ?: ''; + if (!empty($lastnameHeader)) { + $lastname = $this->input->server($lastnameHeader, true); + } + $callsignHeader = $this->config->item('auth_headers_callsign') ?: ''; + if (!empty($callsignHeader)) { + $callsign = $this->input->server($callsignHeader, true); + } + $emailHeader = $this->config->item('auth_headers_email') ?: ''; + if (!empty($emailHeader)) { + $email = $this->input->server($emailHeader, true); + } + + $club_id = $this->config->item('auth_header_club_id') ?: ''; + + $result = $this->user_model->add_minimal($username, $firstname, $lastname, $callsign, $email, $club_id); switch ($result) { case EUSERNAMEEXISTS: diff --git a/install/config/config.php b/install/config/config.php index e90ff13da..7478c8f8f 100644 --- a/install/config/config.php +++ b/install/config/config.php @@ -101,11 +101,15 @@ $config['qrzru_password'] = '%qrzru_password%'; | 2 = editor, 3 = api user, 99 = owner | 'auth_level[]' Defines level titles | -| 'auth_header_enable' False disables header based authentication -| 'auth_header_create' False disables user creation if user doesn't exist -| 'auth_header_value' Which header provides authenticated username -| 'auth_header_text' Display text on login screen -| 'auth_header_club_id' Default club ID to add new users to +| 'auth_header_enable' False disables header based authentication +| 'auth_header_create' False disables user creation for header based authentication +| 'auth_headers_username' Which header provides authenticated username +| 'auth_headers_firstname' Which header provides authenticated first name +| 'auth_headers_lastname' Which header provides authenticated last name +| 'auth_headers_callsign' Which header provides authenticated callsign +| 'auth_headers_email' Which header provides authenticated email +| 'auth_header_text' Display text on login screen +| 'auth_header_club_id' Default club ID to add new users to */ $config['use_auth'] = true; @@ -117,7 +121,11 @@ $config['auth_level'][99] = 'Administrator'; $config['auth_header_enable'] = false; $config['auth_header_create'] = false; -$config['auth_header_value'] = "HTTP_X-Username"; +$config['auth_headers_username'] = "HTTP_X-Username"; +$config['auth_headers_firstname'] = null; +$config['auth_headers_lastname'] = null; +$config['auth_headers_callsign'] = null; +$config['auth_headers_email'] = null; $config['auth_header_text'] = "Login with SSO"; $config['auth_header_club_id'] = ""; From a8560275749144b7e7021ff44a08f554eb1a506d Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Sat, 21 Feb 2026 12:50:37 -0600 Subject: [PATCH 04/48] Partial rollback 8027474f2d03c5eed1b0042e32865934d2fdfc8d remove User_model::add_minimal() --- application/controllers/Header_auth.php | 73 +++++++++++++++++++++++- application/models/User_model.php | 76 ------------------------- 2 files changed, 72 insertions(+), 77 deletions(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 11f843b9e..08e688416 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -52,23 +52,94 @@ class Header_auth extends CI_Controller $firstnameHeader = $this->config->item('auth_headers_firstname') ?: ''; if (!empty($firstnameHeader)) { $firstname = $this->input->server($firstnameHeader, true); + } else { + $firstname = ''; } $lastnameHeader = $this->config->item('auth_headers_lastname') ?: ''; if (!empty($lastnameHeader)) { $lastname = $this->input->server($lastnameHeader, true); + } else { + $lastname = ''; } $callsignHeader = $this->config->item('auth_headers_callsign') ?: ''; if (!empty($callsignHeader)) { $callsign = $this->input->server($callsignHeader, true); + } else { + $callsign = ''; } $emailHeader = $this->config->item('auth_headers_email') ?: ''; if (!empty($emailHeader)) { $email = $this->input->server($emailHeader, true); + } else { + $email = ''; } $club_id = $this->config->item('auth_header_club_id') ?: ''; - $result = $this->user_model->add_minimal($username, $firstname, $lastname, $callsign, $email, $club_id); + $result = $this->user_model->add( + $username, + bin2hex(random_bytes(16)), // password + $email, + 3, // $data['user_type'], Anlage auf 3 + $firstname, + $lastname, + $callsign, + "", // locator + 102, // user_timezone + "M", // measurement + "Y", // dashboard_map + "Y-m-d", // user_date_format + 'darkly', // user_stylesheet + '0', // user_qth_lookup + '0', // user_sota_lookup + '0', // user_wwff_lookup + '0', // user_pota_lookup + 1, // user_show_notes + 'Mode', // user_column1 + 'RSTS', // user_column2 + 'RSTR', // user_column3 + 'Band', // user_column4 + 'Country', // user_column5 + '0', // user_show_profile_image + '0', // user_previous_qsl_type + '0', // user_amsat_status_upload + '', // user_mastodon_url + 'ALL', // user_default_band + 'QL', // user_default_confirmation + '0', // user_qso_end_times + "Y", // user_qso_db_search_priority + '0', // user_quicklog + '0', // user_quicklog_enter + "en", // user_language + '', // user_hamsat_key + '', // user_hamsat_workable_only + '', // user_iota_to_qso_tab + '', // user_sota_to_qso_tab + '', // user_wwff_to_qso_tab + '', // user_pota_to_qso_tab + '', // user_sig_to_qso_tab + '', // user_dok_to_qso_tab + 0, // user_station_to_qso_tab + '', // user_lotw_name + '', // user_lotw_password + '', // user_eqsl_name + '', // user_eqsl_password + '', // user_clublog_name + '', // user_clublog_password + '0', // user_winkey + "", // on_air_widget_enabled + "", // on_air_widget_display_last_seen + "", // on_air_widget_show_only_most_recent_radio + "", // qso_widget_display_qso_time + "", // dashboard_banner + "", // dashboard_solar + "", // global_oqrs_text + "", // oqrs_grouped_search + "", // oqrs_grouped_search_show_station_name + "", // oqrs_auto_matching + "", // oqrs_direct_auto_matching + "", // user_dxwaterfall_enable + ); switch ($result) { case EUSERNAMEEXISTS: diff --git a/application/models/User_model.php b/application/models/User_model.php index 8c9e66da4..34385a7e6 100644 --- a/application/models/User_model.php +++ b/application/models/User_model.php @@ -323,82 +323,6 @@ class User_Model extends CI_Model { } } - /** - * FUNCTION: bool add_minimal($username, $firstname = null, $lastname = null, $callsign = null, $email = null, $club_id = null) - * Add a user with minimal required fields (username only) with option to add to club as user - */ - function add_minimal($username, $firstname = null, $lastname = null, $callsign = null, $email = null, $club_id = null) { - // Check that the username isn't already used - if(!$this->exists($username)) { - $data = array( - 'user_name' => xss_clean($username), - 'user_password' => bin2hex(random_bytes(16)), // Random password - 'user_email' => xss_clean($email) ?? '', - 'user_firstname' => xss_clean($firstname) ?? '', - 'user_lastname' => xss_clean($lastname) ?? '', - 'user_callsign' => strtoupper(xss_clean($callsign)) ?? '', - 'user_type' => 3, - 'user_locator' => '', - 'user_stylesheet' => 'darkly', - 'user_language' => 'english', - 'user_timezone' => '1', - 'user_date_format' => 'd/m/y', - 'user_measurement_base' => 'M', - 'user_column1' => 'Mode', - 'user_column2' => 'RSTS', - 'user_column3' => 'RSTR', - 'user_column4' => 'Band', - 'user_column5' => 'Country', - 'user_qso_end_times' => 0, - 'user_show_profile_image' => 0, - 'user_qth_lookup' => 0, - 'user_sota_lookup' => 0, - 'user_wwff_lookup' => 0, - 'user_pota_lookup' => 0, - 'user_show_notes' => 0, - 'user_quicklog' => 0, - 'user_quicklog_enter' => 0, - 'user_previous_qsl_type' => 0, - 'user_default_band' => 'All', - 'user_lotw_name' => '', - 'user_lotw_password' => '', - 'user_eqsl_name' => '', - 'user_eqsl_password' => '', - 'user_clublog_name' => '', - 'user_clublog_password' => '', - 'user_amsat_status_upload' => 0, - 'user_mastodon_url' => '', - ); - - // Check the email address isn't in use (if provided) - if($email && $this->exists_by_email($email)) { - return EEMAILEXISTS; - } - - // Generate user-slug - if (!$this->load->is_loaded('encryption')) { - $this->load->library('encryption'); - } - $user_slug_base = md5($this->encryption->encrypt($username)); - $user_slug = substr($user_slug_base, 0, USER_SLUG_LENGTH); - $data['slug'] = $user_slug; - - // Add user - $this->db->insert($this->config->item('auth_table'), $data); - $insert_id = $this->db->insert_id(); - - // Add user to club - if ($club_id && is_numeric($club_id)) { - $this->load->model('club_model'); - $this->club_model->alter_member($club_id, $insert_id, 3); - } - - return OK; - } else { - return EUSERNAMEEXISTS; - } - } - // FUNCTION: bool edit() // Edit a user function edit($fields) { From e0f0dd3a049d0665b7d8605bff3cb2689bc7960f Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Sat, 21 Feb 2026 14:09:52 -0600 Subject: [PATCH 05/48] Random password to 512 bit --- application/controllers/Header_auth.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 08e688416..e8516eb3a 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -78,7 +78,7 @@ class Header_auth extends CI_Controller $result = $this->user_model->add( $username, - bin2hex(random_bytes(16)), // password + bin2hex(random_bytes(64)), // password $email, 3, // $data['user_type'], Anlage auf 3 $firstname, From 632990b7f7faed748f629794bb2d31addcae68fb Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Sun, 8 Mar 2026 11:46:59 +0100 Subject: [PATCH 06/48] fix formatting (using k&r for braces) --- application/controllers/Header_auth.php | 43 ++++++++++++------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index e8516eb3a..7052f1063 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -5,11 +5,9 @@ Handles header based authentication */ -class Header_auth extends CI_Controller -{ +class Header_auth extends CI_Controller { - public function __construct() - { + public function __construct() { parent::__construct(); $this->load->model('user_model'); $this->load->library('session'); @@ -20,8 +18,7 @@ class Header_auth extends CI_Controller * Authenticate using a trusted request header. * Expected to be called from a login-screen button. */ - public function login() - { + public function login() { // Guard: feature must be enabled if (!$this->config->item('auth_header_enable')) { $this->session->set_flashdata('error', __('Header authentication is disabled.')); @@ -80,7 +77,7 @@ class Header_auth extends CI_Controller $username, bin2hex(random_bytes(64)), // password $email, - 3, // $data['user_type'], Anlage auf 3 + 3, // $data['user_type'], Anlage auf 3 $firstname, $lastname, $callsign, @@ -140,7 +137,7 @@ class Header_auth extends CI_Controller "", // oqrs_direct_auto_matching "", // user_dxwaterfall_enable ); - + switch ($result) { case EUSERNAMEEXISTS: $data['username_error'] = sprintf(__("Username %s already in use!"), '' . $this->input->post('user_name') . ''); @@ -189,25 +186,25 @@ class Header_auth extends CI_Controller ]; $this->input->set_cookie($cookie); - $this->load->model('user_model'); + $this->load->model('user_model'); // Get full user record - $user = $this->user_model->get($username)->row(); - + $user = $this->user_model->get($username)->row(); + // Critical: Update session data - $this->user_model->update_session($user->user_id); - $this->user_model->set_last_seen($user->user_id); + $this->user_model->update_session($user->user_id); + $this->user_model->set_last_seen($user->user_id); // Set essential session data - $this->session->set_userdata(array( - 'user_id' => $user->user_id, - 'user_name' => $user->user_name, - 'user_type' => $user->user_type, - 'user_stylesheet' => $user->user_stylesheet ?? 'bootstrap', - 'user_column1' => $user->user_column1 ?? 'Mode', - 'user_column2' => $user->user_column2 ?? 'RSTS', - 'user_column3' => $user->user_column3 ?? 'RSTR', - 'user_column4' => $user->user_column4 ?? 'Band', - 'user_column5' => $user->user_column5 ?? 'Country', + $this->session->set_userdata(array( + 'user_id' => $user->user_id, + 'user_name' => $user->user_name, + 'user_type' => $user->user_type, + 'user_stylesheet' => $user->user_stylesheet ?? 'bootstrap', + 'user_column1' => $user->user_column1 ?? 'Mode', + 'user_column2' => $user->user_column2 ?? 'RSTS', + 'user_column3' => $user->user_column3 ?? 'RSTR', + 'user_column4' => $user->user_column4 ?? 'Band', + 'user_column5' => $user->user_column5 ?? 'Country', // Add other preferences as needed )); From 852de9ef9dfa92728fc625d78567969cdaf7c6a6 Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Sun, 8 Mar 2026 12:38:01 +0100 Subject: [PATCH 07/48] catch missing callsign and email header data as those values are required for a user creation --- application/controllers/Header_auth.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 7052f1063..638f50baa 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -62,13 +62,15 @@ class Header_auth extends CI_Controller { if (!empty($callsignHeader)) { $callsign = $this->input->server($callsignHeader, true); } else { - $callsign = ''; + $this->session->set_flashdata('error', __('Missing callsign header.')); + redirect('user/login'); } $emailHeader = $this->config->item('auth_headers_email') ?: ''; if (!empty($emailHeader)) { $email = $this->input->server($emailHeader, true); } else { - $email = ''; + $this->session->set_flashdata('error', __('Missing email header.')); + redirect('user/login'); } $club_id = $this->config->item('auth_header_club_id') ?: ''; From c3337d0fda9cbf98b5d83fde8508dafb9f530d41 Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Sun, 8 Mar 2026 12:42:20 +0100 Subject: [PATCH 08/48] default user timezone should be utc --- application/controllers/Header_auth.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 638f50baa..5dd4b5d25 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -84,7 +84,7 @@ class Header_auth extends CI_Controller { $lastname, $callsign, "", // locator - 102, // user_timezone + 24, // user_timezone is default UTC "M", // measurement "Y", // dashboard_map "Y-m-d", // user_date_format From 5b717f3ae22f77ea99375ad16716d70b43231076 Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Sun, 8 Mar 2026 12:43:44 +0100 Subject: [PATCH 09/48] fix user language --- application/controllers/Header_auth.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 5dd4b5d25..25bf464d8 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -109,7 +109,7 @@ class Header_auth extends CI_Controller { "Y", // user_qso_db_search_priority '0', // user_quicklog '0', // user_quicklog_enter - "en", // user_language + "english", // user_language '', // user_hamsat_key '', // user_hamsat_workable_only '', // user_iota_to_qso_tab From 94e791b9cb097b0309151144c1525a97eb37b9df Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Sun, 8 Mar 2026 12:45:39 +0100 Subject: [PATCH 10/48] prevent loading user_model multiple times --- application/controllers/Header_auth.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 25bf464d8..8a67f63d4 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -45,7 +45,6 @@ class Header_auth extends CI_Controller { // Config check if create user if ($this->config->item('auth_header_create')) { - $this->load->model('user_model'); $firstnameHeader = $this->config->item('auth_headers_firstname') ?: ''; if (!empty($firstnameHeader)) { $firstname = $this->input->server($firstnameHeader, true); @@ -188,7 +187,6 @@ class Header_auth extends CI_Controller { ]; $this->input->set_cookie($cookie); - $this->load->model('user_model'); // Get full user record $user = $this->user_model->get($username)->row(); From 20a8fe5bd45a38249528052925ff141931e656db Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Sun, 8 Mar 2026 12:48:08 +0100 Subject: [PATCH 11/48] duplicate logic --- application/controllers/Header_auth.php | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 8a67f63d4..9b1d7141c 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -187,27 +187,6 @@ class Header_auth extends CI_Controller { ]; $this->input->set_cookie($cookie); - // Get full user record - $user = $this->user_model->get($username)->row(); - - // Critical: Update session data - $this->user_model->update_session($user->user_id); - $this->user_model->set_last_seen($user->user_id); - - // Set essential session data - $this->session->set_userdata(array( - 'user_id' => $user->user_id, - 'user_name' => $user->user_name, - 'user_type' => $user->user_type, - 'user_stylesheet' => $user->user_stylesheet ?? 'bootstrap', - 'user_column1' => $user->user_column1 ?? 'Mode', - 'user_column2' => $user->user_column2 ?? 'RSTS', - 'user_column3' => $user->user_column3 ?? 'RSTR', - 'user_column4' => $user->user_column4 ?? 'Band', - 'user_column5' => $user->user_column5 ?? 'Country', - // Add other preferences as needed - )); - log_message('info', "User ID [{$user->user_id}] logged in via header auth."); redirect('dashboard'); } From 670afc0f50c1e8c07be6c46c2e6fddb39f6cafc1 Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Sun, 8 Mar 2026 12:53:52 +0100 Subject: [PATCH 12/48] catch null --- application/controllers/User.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/controllers/User.php b/application/controllers/User.php index 7ee578fd2..44b6df6db 100644 --- a/application/controllers/User.php +++ b/application/controllers/User.php @@ -1224,8 +1224,8 @@ class User extends CI_Controller { if ($this->form_validation->run() == FALSE) { $data['page_title'] = __("Login"); $data['https_check'] = $this->https_check(); - $data['auth_header_enable'] = $this->config->item('auth_header_enable'); - $data['auth_header_text'] = $this->config->item('auth_header_text'); + $data['auth_header_enable'] = $this->config->item('auth_header_enable') ?? false; + $data['auth_header_text'] = $this->config->item('auth_header_text') ?: ''; $this->load->view('interface_assets/mini_header', $data); $this->load->view('user/login'); $this->load->view('interface_assets/footer'); From ce04003621bfcbf3140900285aa6ba7cfd3d0602 Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Sun, 8 Mar 2026 13:39:39 +0100 Subject: [PATCH 13/48] added missing user_config --- application/controllers/Header_auth.php | 1 + 1 file changed, 1 insertion(+) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 9b1d7141c..702fdd310 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -137,6 +137,7 @@ class Header_auth extends CI_Controller { "", // oqrs_auto_matching "", // oqrs_direct_auto_matching "", // user_dxwaterfall_enable + "", // user_qso_show_map ); switch ($result) { From ee5dd2425f4a2a35fd5b0ea697bc875304126639 Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Sun, 8 Mar 2026 18:32:17 +0100 Subject: [PATCH 14/48] jwt logic --- application/controllers/Header_auth.php | 157 ++++++++++++++++++------ 1 file changed, 118 insertions(+), 39 deletions(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 702fdd310..36d28bc01 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -9,76 +9,155 @@ class Header_auth extends CI_Controller { public function __construct() { parent::__construct(); - $this->load->model('user_model'); - $this->load->library('session'); - $this->load->helper('url'); } - /** - * Authenticate using a trusted request header. - * Expected to be called from a login-screen button. + /** + * Decode a JWT token + * + * @param string $token + * + * @return array|null + */ + private function _decode_jwt_payload(string $token): ?array { + $parts = explode('.', $token); + if (count($parts) !== 3) { + return null; + } + + $decode = function(string $part): ?array { + $json = base64_decode(str_pad(strtr($part, '-_', '+/'), strlen($part) % 4, '=', STR_PAD_RIGHT)); + if ($json === false) return null; + $data = json_decode($json, true); + return is_array($data) ? $data : null; + }; + + $header = $decode($parts[0]); + $payload = $decode($parts[1]); + if ($payload === null) { + return null; + } + + // Merge alg from header into payload so _verify_jwtdata can check it + if (isset($header['alg'])) { + $payload['alg'] = $header['alg']; + } + + return $payload; + } + + /** + * Helper to verify some long hangig fruits. We are not verifying the JWT token against the issuer at this point. + * Reason is the need for a crypto lib which is not necessary at this point. An administrator is responsible + * for the proper isolation of Wavelog and needs to make sure that Wavelog is not exposed directly. + * + * Additonal verificarion steps can be added at a later point. + * + * @param array claim data + * + * @return bool + */ + private function _verify_jwtdata($claims = null) { + // No claim, no verificiation + if (!$claims) { + log_message('error', 'JWT Verification: No claim data received.'); + return false; + } + + // Check expire date + if (($claims['exp'] ?? 0) < time()) { + log_message('error', 'JWT Verification: JWT Token is expired.'); + return false; + } + + // Is the token already valid + if (isset($claims['nbf']) && $claims['nbf'] > time()) { + log_message('error', 'JWT Verification: JWT Token is not valid yet.'); + return false; + } + + // The token should not be older then 24 hours which would be absurd old for an JWT token + if (isset($claims['iat']) && $claims['iat'] < (time() - 86400)) { + log_message('error', 'JWT Verification: Token is older then 24 hours. This is very unusual. Verification failed.'); + return false; + } + + // Is it a bearer token? + if (isset($claims['typ']) && $claims['typ'] !== 'Bearer') { + log_message('error', 'JWT Verification: JWT Token is no Bearer Token.'); + return false; + } + + // prevent alg: none attacks + if (!in_array($claims['alg'], ['RS256', 'RS384', 'RS512', 'ES256', 'ES384'], true)) { + log_message('error', 'JWT Verification: Algorithm ' . ($claims['alg'] ?? '???') . ' is not allowed. Create an issue on github https://github.com/wavelog/wavelog.'); + return false; + } + + return true; + } + + /** + * Authenticate using a trusted request header. */ public function login() { - // Guard: feature must be enabled + // Guard: feature must be enabled if (!$this->config->item('auth_header_enable')) { $this->session->set_flashdata('error', __('Header authentication is disabled.')); redirect('user/login'); } - - // Get username from header - $headerUsername = $this->config->item('auth_headers_username') ?: ''; - if (empty($headerUsername)) { - $this->session->set_flashdata('error', __('Missing header setting.')); + // Decode JWT access token forwarded by idp + $token = $this->input->server('HTTP_X_FORWARDED_ACCESS_TOKEN', true); + if (empty($token)) { + $this->session->set_flashdata('error', __('Missing access token header.')); redirect('user/login'); } - $username = $this->input->server($headerUsername, true); + + $claims = $this->_decode_jwt_payload($token); + if (empty($claims)) { + $this->session->set_flashdata('error', __('Invalid access token.')); + redirect('user/login'); + } + + if (!$this->_verify_jwtdata($claims)) { + $this->session->set_flashdata('error', __('Token validation failed. For more information check out the error log.')); + redirect('user/login'); + } + + $username = $claims['preferred_username'] ?? ''; + $email = $claims['email'] ?? ''; + $callsign = $claims['callsign'] ?? ''; + $firstname = $claims['given_name'] ?? ''; + $lastname = $claims['family_name'] ?? ''; if (empty($username)) { - $this->session->set_flashdata('error', __('Missing username header.')); + $this->session->set_flashdata('error', __('Missing username in access token.')); redirect('user/login'); } - // Look up user by the header value + // Look up user by the header value + $this->load->model('user_model'); $query = $this->user_model->get($username); if (!$query || $query->num_rows() !== 1) { // Config check if create user if ($this->config->item('auth_header_create')) { - $firstnameHeader = $this->config->item('auth_headers_firstname') ?: ''; - if (!empty($firstnameHeader)) { - $firstname = $this->input->server($firstnameHeader, true); - } else { - $firstname = ''; - } - $lastnameHeader = $this->config->item('auth_headers_lastname') ?: ''; - if (!empty($lastnameHeader)) { - $lastname = $this->input->server($lastnameHeader, true); - } else { - $lastname = ''; - } - $callsignHeader = $this->config->item('auth_headers_callsign') ?: ''; - if (!empty($callsignHeader)) { - $callsign = $this->input->server($callsignHeader, true); - } else { - $this->session->set_flashdata('error', __('Missing callsign header.')); + if (empty($email)) { + $this->session->set_flashdata('error', __('Missing email in access token.')); redirect('user/login'); } - $emailHeader = $this->config->item('auth_headers_email') ?: ''; - if (!empty($emailHeader)) { - $email = $this->input->server($emailHeader, true); - } else { - $this->session->set_flashdata('error', __('Missing email header.')); + if (empty($callsign)) { + $this->session->set_flashdata('error', __('Missing callsign in access token.')); redirect('user/login'); } - $club_id = $this->config->item('auth_header_club_id') ?: ''; + // $club_id = $this->config->item('auth_header_club_id') ?: ''; // TODO: Add support to add a user to a clubstation $result = $this->user_model->add( $username, bin2hex(random_bytes(64)), // password $email, - 3, // $data['user_type'], Anlage auf 3 + 3, // $data['user_type'], we don't create admins for security reasons $firstname, $lastname, $callsign, From c049434f2afd69a42bad9530a09fcd10146f0d40 Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Sun, 8 Mar 2026 23:28:03 +0100 Subject: [PATCH 15/48] external_account flag for sso accounts --- application/config/migration.php | 2 +- application/controllers/Header_auth.php | 2 ++ application/controllers/User.php | 5 +++++ .../migrations/272_external_account.php | 21 +++++++++++++++++++ application/models/User_model.php | 3 ++- 5 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 application/migrations/272_external_account.php diff --git a/application/config/migration.php b/application/config/migration.php index dcd5de722..d6b180776 100644 --- a/application/config/migration.php +++ b/application/config/migration.php @@ -22,7 +22,7 @@ $config['migration_enabled'] = TRUE; | */ -$config['migration_version'] = 271; +$config['migration_version'] = 272; /* |-------------------------------------------------------------------------- diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 36d28bc01..78b85a302 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -217,6 +217,8 @@ class Header_auth extends CI_Controller { "", // oqrs_direct_auto_matching "", // user_dxwaterfall_enable "", // user_qso_show_map + 0, // clubstation + true, // external_account ); switch ($result) { diff --git a/application/controllers/User.php b/application/controllers/User.php index 44b6df6db..49d6f4251 100644 --- a/application/controllers/User.php +++ b/application/controllers/User.php @@ -1300,6 +1300,11 @@ class User extends CI_Controller { $this->input->set_cookie('tmp_msg', json_encode(['notice', sprintf(__("User %s logged out."), $user_name)]), 10, ''); } + $logout = $this->config->item('auth_url_logout'); + if ($this->config->item('auth_header_enable') && $logout !== null && $logout !== '') { + redirect($logout); + } + redirect('user/login'); } diff --git a/application/migrations/272_external_account.php b/application/migrations/272_external_account.php new file mode 100644 index 000000000..bcb468d09 --- /dev/null +++ b/application/migrations/272_external_account.php @@ -0,0 +1,21 @@ +dbtry("ALTER TABLE users ADD COLUMN external_account tinyint DEFAULT 0 AFTER clubstation"); + } + + public function down() { + $this->dbtry("ALTER TABLE users DROP COLUMN external_account"); + } + + function dbtry($what) { + try { + $this->db->query($what); + } catch (Exception $e) { + log_message("error", "Something gone wrong while altering users table. Executing: " . $this->db->last_query()); + } + } +} diff --git a/application/models/User_model.php b/application/models/User_model.php index 3fc0e422b..c1102a6dc 100644 --- a/application/models/User_model.php +++ b/application/models/User_model.php @@ -225,7 +225,7 @@ class User_Model extends CI_Model { $user_lotw_name, $user_lotw_password, $user_eqsl_name, $user_eqsl_password, $user_clublog_name, $user_clublog_password, $user_winkey, $on_air_widget_enabled, $on_air_widget_display_last_seen, $on_air_widget_show_only_most_recent_radio, $qso_widget_display_qso_time, $dashboard_banner, $dashboard_solar, $global_oqrs_text, $oqrs_grouped_search, - $oqrs_grouped_search_show_station_name, $oqrs_auto_matching, $oqrs_direct_auto_matching,$user_dxwaterfall_enable, $user_qso_show_map, $clubstation = 0) { + $oqrs_grouped_search_show_station_name, $oqrs_auto_matching, $oqrs_direct_auto_matching,$user_dxwaterfall_enable, $user_qso_show_map, $clubstation = 0, $external_account = false) { // Check that the user isn't already used if(!$this->exists($username)) { $data = array( @@ -269,6 +269,7 @@ class User_Model extends CI_Model { 'user_clublog_password' => xss_clean($user_clublog_password), 'winkey' => xss_clean($user_winkey), 'clubstation' => $clubstation, + 'external_account' => $external_account ); // Check the password is valid From 4ed08b379c82e6831d316f0b21c7ed561b2123e1 Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Sun, 8 Mar 2026 23:40:34 +0100 Subject: [PATCH 16/48] improve logging --- application/controllers/Header_auth.php | 26 ++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 78b85a302..a3722ce17 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -107,20 +107,29 @@ class Header_auth extends CI_Controller { } // Decode JWT access token forwarded by idp - $token = $this->input->server('HTTP_X_FORWARDED_ACCESS_TOKEN', true); + $accesstoken_path = $this->config->item('auth_headers_accesstoken') ?? false; + if (!$accesstoken_path) { + log_message('error', 'SSO Authentication: Access Token Path not configured in config.php.'); + $this->session->set_flashdata('error', __('SSO Config Error. Check error log.')); + redirect('user/login'); + } + $token = $this->input->server($accesstoken_path, true); if (empty($token)) { - $this->session->set_flashdata('error', __('Missing access token header.')); + log_message('error', 'SSO Authentication: Missing access token header.'); + $this->session->set_flashdata('error', __('SSO Config Error. Check error log.')); redirect('user/login'); } $claims = $this->_decode_jwt_payload($token); if (empty($claims)) { - $this->session->set_flashdata('error', __('Invalid access token.')); + log_message('error', 'SSO Authentication: Invalid access token format. Failed to decode JWT token.'); + $this->session->set_flashdata('error', __('Config Error. Check error log.')); redirect('user/login'); } if (!$this->_verify_jwtdata($claims)) { - $this->session->set_flashdata('error', __('Token validation failed. For more information check out the error log.')); + log_message('error', 'SSO Authentication: Token validation failed.'); + $this->session->set_flashdata('error', __('Config Error. Check error log.')); redirect('user/login'); } @@ -131,7 +140,8 @@ class Header_auth extends CI_Controller { $lastname = $claims['family_name'] ?? ''; if (empty($username)) { - $this->session->set_flashdata('error', __('Missing username in access token.')); + log_message('error', 'SSO Authentication: Missing username claim in access token.'); + $this->session->set_flashdata('error', __('Config Error. Check error log.')); redirect('user/login'); } @@ -143,11 +153,13 @@ class Header_auth extends CI_Controller { // Config check if create user if ($this->config->item('auth_header_create')) { if (empty($email)) { - $this->session->set_flashdata('error', __('Missing email in access token.')); + log_message('error', 'SSO Authentication: Missing email claim in access token.'); + $this->session->set_flashdata('error', __('Config Error. Check error log.')); redirect('user/login'); } if (empty($callsign)) { - $this->session->set_flashdata('error', __('Missing callsign in access token.')); + log_message('error', 'SSO Authentication: Missing callsign claim in access token.'); + $this->session->set_flashdata('error', __('Config Error. Check error log.')); redirect('user/login'); } From 651845bf77de74bc0b25005ffe031e19d117ab20 Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Mon, 9 Mar 2026 12:02:37 +0100 Subject: [PATCH 17/48] add option to hide the normal login form --- application/controllers/User.php | 1 + application/models/User_model.php | 5 +++++ application/views/user/login.php | 20 +++++++++++--------- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/application/controllers/User.php b/application/controllers/User.php index 49d6f4251..6da69968e 100644 --- a/application/controllers/User.php +++ b/application/controllers/User.php @@ -1226,6 +1226,7 @@ class User extends CI_Controller { $data['https_check'] = $this->https_check(); $data['auth_header_enable'] = $this->config->item('auth_header_enable') ?? false; $data['auth_header_text'] = $this->config->item('auth_header_text') ?: ''; + $data['hide_login_form'] = ($data['auth_header_enable'] && !($this->config->item('auth_allow_direct_login') ?? true)); $this->load->view('interface_assets/mini_header', $data); $this->load->view('user/login'); $this->load->view('interface_assets/footer'); diff --git a/application/models/User_model.php b/application/models/User_model.php index c1102a6dc..2798b4c32 100644 --- a/application/models/User_model.php +++ b/application/models/User_model.php @@ -519,6 +519,11 @@ class User_Model extends CI_Model { // This is really just a wrapper around User_Model::authenticate function login() { + if (($this->config->item('auth_header_enable') ?? false) && !($this->config->item('auth_allow_direct_login') ?? true)) { + $this->session->set_flashdata('error', 'Direct login is disabled. Please use the SSO option to log in.'); + redirect('user/login'); + } + $username = $this->input->post('user_name', true); $password = htmlspecialchars_decode($this->input->post('user_password', true)); diff --git a/application/views/user/login.php b/application/views/user/login.php index 102e6ff7b..ddfbd9125 100644 --- a/application/views/user/login.php +++ b/application/views/user/login.php @@ -64,6 +64,7 @@ ?>
form_validation->set_error_delimiters('', ''); ?> +
@@ -73,14 +74,6 @@ " autocomplete="current-password">
- -
- - - -
-
@@ -95,8 +88,17 @@
- load->view('layout/messages'); ?> + + +
+ + + +
+ + load->view('layout/messages'); ?>
From 29cc658f140440c50aa50055df2aa57a7e8bc1ec Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Mon, 9 Mar 2026 12:05:46 +0100 Subject: [PATCH 18/48] updated sample config to reflect current development --- application/config/config.sample.php | 38 +++++++++++++++++++++------- install/config/config.php | 38 +++++++++++++++++++++------- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/application/config/config.sample.php b/application/config/config.sample.php index 339badf20..2530108e6 100644 --- a/application/config/config.sample.php +++ b/application/config/config.sample.php @@ -119,15 +119,35 @@ $config['auth_mode'] = '3'; $config['auth_level'][3] = 'Operator'; $config['auth_level'][99] = 'Administrator'; -$config['auth_header_enable'] = false; -$config['auth_header_create'] = false; -$config['auth_headers_username'] = "HTTP_X-Username"; -$config['auth_headers_firstname'] = null; -$config['auth_headers_lastname'] = null; -$config['auth_headers_callsign'] = null; -$config['auth_headers_email'] = null; -$config['auth_header_text'] = "Login with SSO"; -$config['auth_header_club_id'] = ""; + +/* +|-------------------------------------------------------------------------- +| Third-Party Authentication (SSO) +|-------------------------------------------------------------------------- +| +| This section defines settings for third-party authentication over an external identity provider. +| When enabled, Wavelog will look for a specific HTTP header containing an access token, which can be used to authenticate users. +| This is particularly useful in environments where users are already authenticated through another system, such as a corporate SSO or an identity provider. +| Please refer to the documentation of Wavelog for more details on how to set up and use this feature. +| Link to documentation: https://docs.wavelog.org/admin-guide/configuration/third-party-authentication +| +| 'auth_header_enable' = Enable or disable third-party authentication via HTTP headers. +| 'auth_header_create' = Automatically create a local user account if a valid access token is provided and no corresponding user exists. +| 'auth_allow_direct_login' = Allow users to log in directly through the Wavelog login form even when third-party authentication is enabled. This can be useful for administrators or in cases where some users do not have access to the SSO system. +| 'auth_headers_accesstoken' = The name of the HTTP header that contains the access token. This should match the header sent by your SSO or authentication system. +| 'auth_header_text' = The text displayed on the login button for third-party authentication. +| 'auth_header_club_id' = Optional club ID to assign to users authenticated via the header. This can be used to group users or assign specific permissions based on their club affiliation. +| 'auth_url_logout' = The URL to redirect users to when they log out, which can be used to log them out of the SSO system as well. +*/ + + +// $config['auth_header_enable'] = false; +// $config['auth_header_create'] = true; +// $config['auth_allow_direct_login'] = true; +// $config['auth_headers_accesstoken'] = "HTTP_X_FORWARDED_ACCESS_TOKEN"; +// $config['auth_header_text'] = "Login with SSO"; +// $config['auth_header_club_id'] = ""; +// $config['auth_url_logout'] = 'https://auth.example.org/realms/example/protocol/openid-connect/logout?post_logout_redirect_uri=https://log.example.org/index.php/user/login'; /* |-------------------------------------------------------------------------- diff --git a/install/config/config.php b/install/config/config.php index b7d43ae26..b643903af 100644 --- a/install/config/config.php +++ b/install/config/config.php @@ -119,15 +119,35 @@ $config['auth_mode'] = '3'; $config['auth_level'][3] = 'Operator'; $config['auth_level'][99] = 'Administrator'; -$config['auth_header_enable'] = false; -$config['auth_header_create'] = false; -$config['auth_headers_username'] = "HTTP_X-Username"; -$config['auth_headers_firstname'] = null; -$config['auth_headers_lastname'] = null; -$config['auth_headers_callsign'] = null; -$config['auth_headers_email'] = null; -$config['auth_header_text'] = "Login with SSO"; -$config['auth_header_club_id'] = ""; + +/* +|-------------------------------------------------------------------------- +| Third-Party Authentication (SSO) +|-------------------------------------------------------------------------- +| +| This section defines settings for third-party authentication over an external identity provider. +| When enabled, Wavelog will look for a specific HTTP header containing an access token, which can be used to authenticate users. +| This is particularly useful in environments where users are already authenticated through another system, such as a corporate SSO or an identity provider. +| Please refer to the documentation of Wavelog for more details on how to set up and use this feature. +| Link to documentation: https://docs.wavelog.org/admin-guide/configuration/third-party-authentication +| +| 'auth_header_enable' = Enable or disable third-party authentication via HTTP headers. +| 'auth_header_create' = Automatically create a local user account if a valid access token is provided and no corresponding user exists. +| 'auth_allow_direct_login' = Allow users to log in directly through the Wavelog login form even when third-party authentication is enabled. This can be useful for administrators or in cases where some users do not have access to the SSO system. +| 'auth_headers_accesstoken' = The name of the HTTP header that contains the access token. This should match the header sent by your SSO or authentication system. +| 'auth_header_text' = The text displayed on the login button for third-party authentication. +| 'auth_header_club_id' = Optional club ID to assign to users authenticated via the header. This can be used to group users or assign specific permissions based on their club affiliation. +| 'auth_url_logout' = The URL to redirect users to when they log out, which can be used to log them out of the SSO system as well. +*/ + + +// $config['auth_header_enable'] = false; +// $config['auth_header_create'] = true; +// $config['auth_allow_direct_login'] = true; +// $config['auth_headers_accesstoken'] = "HTTP_X_FORWARDED_ACCESS_TOKEN"; +// $config['auth_header_text'] = "Login with SSO"; +// $config['auth_header_club_id'] = ""; +// $config['auth_url_logout'] = 'https://auth.example.org/realms/example/protocol/openid-connect/logout?post_logout_redirect_uri=https://log.example.org/index.php/user/login'; /* |-------------------------------------------------------------------------- From 6c43d8d14828668ca8493acdefcd4d6f71190bf8 Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Mon, 9 Mar 2026 12:09:16 +0100 Subject: [PATCH 19/48] removed old comments --- application/config/config.sample.php | 10 ---------- install/config/config.php | 10 ---------- 2 files changed, 20 deletions(-) diff --git a/application/config/config.sample.php b/application/config/config.sample.php index 2530108e6..df1eb5f4c 100644 --- a/application/config/config.sample.php +++ b/application/config/config.sample.php @@ -100,16 +100,6 @@ $config['qrzru_password'] = ''; | 'auth_mode' Minimum user level required 0 = anonymous, 1 = viewer, | 2 = editor, 3 = api user, 99 = owner | 'auth_level[]' Defines level titles -| -| 'auth_header_enable' False disables header based authentication -| 'auth_header_create' False disables user creation for header based authentication -| 'auth_headers_username' Which header provides authenticated username -| 'auth_headers_firstname' Which header provides authenticated first name -| 'auth_headers_lastname' Which header provides authenticated last name -| 'auth_headers_callsign' Which header provides authenticated callsign -| 'auth_headers_email' Which header provides authenticated email -| 'auth_header_text' Display text on login screen -| 'auth_header_club_id' Default club ID to add new users to */ $config['use_auth'] = true; diff --git a/install/config/config.php b/install/config/config.php index b643903af..777112c18 100644 --- a/install/config/config.php +++ b/install/config/config.php @@ -100,16 +100,6 @@ $config['qrzru_password'] = '%qrzru_password%'; | 'auth_mode' Minimum user level required 0 = anonymous, 1 = viewer, | 2 = editor, 3 = api user, 99 = owner | 'auth_level[]' Defines level titles -| -| 'auth_header_enable' False disables header based authentication -| 'auth_header_create' False disables user creation for header based authentication -| 'auth_headers_username' Which header provides authenticated username -| 'auth_headers_firstname' Which header provides authenticated first name -| 'auth_headers_lastname' Which header provides authenticated last name -| 'auth_headers_callsign' Which header provides authenticated callsign -| 'auth_headers_email' Which header provides authenticated email -| 'auth_header_text' Display text on login screen -| 'auth_header_club_id' Default club ID to add new users to */ $config['use_auth'] = true; From 00ab6f75e8c930fff657422c1413aa2060305b58 Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:44:04 -0500 Subject: [PATCH 20/48] Pull callsign JWT claim from config --- application/controllers/Header_auth.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index a3722ce17..bdf8231eb 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -133,9 +133,11 @@ class Header_auth extends CI_Controller { redirect('user/login'); } + $callsign_claim = $this->config->item('auth_headers_callsign_claim') ?? 'callsign'; + $username = $claims['preferred_username'] ?? ''; $email = $claims['email'] ?? ''; - $callsign = $claims['callsign'] ?? ''; + $callsign = $claims[$callsign_claim] ?? ''; $firstname = $claims['given_name'] ?? ''; $lastname = $claims['family_name'] ?? ''; From a039b9c1abef72ce59b466c21112498e95c82b1a Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:44:40 -0500 Subject: [PATCH 21/48] Update config template --- install/config/config.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/install/config/config.php b/install/config/config.php index 777112c18..f139d31fb 100644 --- a/install/config/config.php +++ b/install/config/config.php @@ -125,9 +125,10 @@ $config['auth_level'][99] = 'Administrator'; | 'auth_header_create' = Automatically create a local user account if a valid access token is provided and no corresponding user exists. | 'auth_allow_direct_login' = Allow users to log in directly through the Wavelog login form even when third-party authentication is enabled. This can be useful for administrators or in cases where some users do not have access to the SSO system. | 'auth_headers_accesstoken' = The name of the HTTP header that contains the access token. This should match the header sent by your SSO or authentication system. +| 'auth_headers_callsign_claim' = The JWT claim that contains the user's Callsign. | 'auth_header_text' = The text displayed on the login button for third-party authentication. | 'auth_header_club_id' = Optional club ID to assign to users authenticated via the header. This can be used to group users or assign specific permissions based on their club affiliation. -| 'auth_url_logout' = The URL to redirect users to when they log out, which can be used to log them out of the SSO system as well. +| 'auth_url_logout' = The URL to redirect users to when they log out. If OAuth2 Proxy and Keycloak is used, must hit OAuth2 Proxy first then Keycloak. whitelist_domains for OAuth2 Proxy must also be set to the IdP domain. */ @@ -135,9 +136,10 @@ $config['auth_level'][99] = 'Administrator'; // $config['auth_header_create'] = true; // $config['auth_allow_direct_login'] = true; // $config['auth_headers_accesstoken'] = "HTTP_X_FORWARDED_ACCESS_TOKEN"; +// $config['auth_headers_callsign_claim'] = "callsign"; // $config['auth_header_text'] = "Login with SSO"; // $config['auth_header_club_id'] = ""; -// $config['auth_url_logout'] = 'https://auth.example.org/realms/example/protocol/openid-connect/logout?post_logout_redirect_uri=https://log.example.org/index.php/user/login'; +// $config['auth_url_logout'] = 'https://log.example.org/oauth2/sign_out?rd=https://auth.example.org/realms/example/protocol/openid-connect/logout'; /* |-------------------------------------------------------------------------- From eae999b2a30791382f7f7366b58ff1a2a0088f7d Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Wed, 11 Mar 2026 19:44:05 +0100 Subject: [PATCH 22/48] update sample config based on installer config template --- application/config/config.sample.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/application/config/config.sample.php b/application/config/config.sample.php index df1eb5f4c..1e4ae6d40 100644 --- a/application/config/config.sample.php +++ b/application/config/config.sample.php @@ -125,9 +125,10 @@ $config['auth_level'][99] = 'Administrator'; | 'auth_header_create' = Automatically create a local user account if a valid access token is provided and no corresponding user exists. | 'auth_allow_direct_login' = Allow users to log in directly through the Wavelog login form even when third-party authentication is enabled. This can be useful for administrators or in cases where some users do not have access to the SSO system. | 'auth_headers_accesstoken' = The name of the HTTP header that contains the access token. This should match the header sent by your SSO or authentication system. +| 'auth_headers_callsign_claim' = The JWT claim that contains the user's Callsign. | 'auth_header_text' = The text displayed on the login button for third-party authentication. | 'auth_header_club_id' = Optional club ID to assign to users authenticated via the header. This can be used to group users or assign specific permissions based on their club affiliation. -| 'auth_url_logout' = The URL to redirect users to when they log out, which can be used to log them out of the SSO system as well. +| 'auth_url_logout' = The URL to redirect users to when they log out. If OAuth2 Proxy and Keycloak is used, must hit OAuth2 Proxy first then Keycloak. whitelist_domains for OAuth2 Proxy must also be set to the IdP domain. */ @@ -135,9 +136,10 @@ $config['auth_level'][99] = 'Administrator'; // $config['auth_header_create'] = true; // $config['auth_allow_direct_login'] = true; // $config['auth_headers_accesstoken'] = "HTTP_X_FORWARDED_ACCESS_TOKEN"; +// $config['auth_headers_callsign_claim'] = "callsign"; // $config['auth_header_text'] = "Login with SSO"; // $config['auth_header_club_id'] = ""; -// $config['auth_url_logout'] = 'https://auth.example.org/realms/example/protocol/openid-connect/logout?post_logout_redirect_uri=https://log.example.org/index.php/user/login'; +// $config['auth_url_logout'] = 'https://log.example.org/oauth2/sign_out?rd=https://auth.example.org/realms/example/protocol/openid-connect/logout'; /* |-------------------------------------------------------------------------- From e31633089d6e0a91449a149ad17f71c10675728d Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Mon, 16 Mar 2026 15:20:51 +0100 Subject: [PATCH 23/48] updated migration version --- application/config/migration.php | 2 +- .../{272_external_account.php => 273_external_account.php} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename application/migrations/{272_external_account.php => 273_external_account.php} (100%) diff --git a/application/config/migration.php b/application/config/migration.php index d6b180776..2e0cc8992 100644 --- a/application/config/migration.php +++ b/application/config/migration.php @@ -22,7 +22,7 @@ $config['migration_enabled'] = TRUE; | */ -$config['migration_version'] = 272; +$config['migration_version'] = 273; /* |-------------------------------------------------------------------------- diff --git a/application/migrations/272_external_account.php b/application/migrations/273_external_account.php similarity index 100% rename from application/migrations/272_external_account.php rename to application/migrations/273_external_account.php From ae992957770cff2ee3da15b2e5d3cfbd8196dcdb Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Mon, 16 Mar 2026 15:54:28 +0100 Subject: [PATCH 24/48] refactored class structure --- application/controllers/Header_auth.php | 392 ++++++++++++------------ 1 file changed, 202 insertions(+), 190 deletions(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index bdf8231eb..2e5fc4ac5 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -11,6 +11,93 @@ class Header_auth extends CI_Controller { parent::__construct(); } + /** + * Authenticate using a trusted request header/JWT token. + */ + public function login() { + // Guard: feature must be enabled + if (!$this->config->item('auth_header_enable')) { + $this->_sso_error(__("SSO Authentication is not enabled. Check your configuration.")); + } + + // Decode JWT access token forwarded by idp + $accesstoken_path = $this->config->item('auth_headers_accesstoken') ?? false; + if (!$accesstoken_path) { + log_message('error', 'SSO Authentication: Access Token Path not configured in config.php.'); + $this->_sso_error(); + } + $token = $this->input->server($accesstoken_path, true); + if (empty($token)) { + log_message('error', 'SSO Authentication: Missing access token header.'); + $this->_sso_error(); + } + + $claims = $this->_decode_jwt_payload($token); + if (empty($claims)) { + log_message('error', 'SSO Authentication: Invalid access token format. Failed to decode JWT token.'); + $this->_sso_error(); + } + + if (!$this->_verify_jwtdata($claims)) { + log_message('error', 'SSO Authentication: Token validation failed.'); + $this->_sso_error(); + } + + $callsign_claim = $this->config->item('auth_headers_callsign_claim') ?? 'callsign'; + + $username = $claims['preferred_username'] ?? ''; + $email = $claims['email'] ?? ''; + $callsign = $claims[$callsign_claim] ?? ''; + $firstname = $claims['given_name'] ?? ''; + $lastname = $claims['family_name'] ?? ''; + + if (empty($username)) { + log_message('error', 'SSO Authentication: Missing username claim in access token.'); + $this->_sso_error(); + } + + // Look up user by the header value + $this->load->model('user_model'); + $query = $this->user_model->get($username); + + if (!$query || $query->num_rows() !== 1) { + // Config check if create user + if ($this->config->item('auth_header_create')) { + $this->_create_user($username, $email, $callsign, $firstname, $lastname); + } else { + $this->_sso_error(__("User not found.")); + } + } + + $user = $query->row(); + + // Prevent clubstation direct login via header (mirrors User::login) + if (!empty($user->clubstation) && $user->clubstation == 1) { + $this->_sso_error(__("You can't login to a clubstation directly. Use your personal account instead.")); + } + + // Maintenance mode check (admin only allowed) + if (ENVIRONMENT === 'maintenance' && (int)$user->user_type !== 99) { + $this->_sso_error(__("Sorry. This instance is currently in maintenance mode. Only administrators are currently allowed to log in.")); + } + + // Establish session + $this->user_model->update_session($user->user_id); + $this->user_model->set_last_seen($user->user_id); + + // Set language cookie (mirrors User::login) + $cookie = [ + 'name' => $this->config->item('gettext_cookie', 'gettext'), + 'value' => $user->user_language, + 'expire' => 1000, + 'secure' => $this->config->item('cookie_secure'), + ]; + $this->input->set_cookie($cookie); + + log_message('info', "User ID [{$user->user_id}] logged in via SSO."); + redirect('dashboard'); + } + /** * Decode a JWT token * @@ -24,7 +111,7 @@ class Header_auth extends CI_Controller { return null; } - $decode = function(string $part): ?array { + $decode = function (string $part): ?array { $json = base64_decode(str_pad(strtr($part, '-_', '+/'), strlen($part) % 4, '=', STR_PAD_RIGHT)); if ($json === false) return null; $data = json_decode($json, true); @@ -70,21 +157,21 @@ class Header_auth extends CI_Controller { } // Is the token already valid - if (isset($claims['nbf']) && $claims['nbf'] > time()) { + if (isset($claims['nbf']) && $claims['nbf'] > time()) { log_message('error', 'JWT Verification: JWT Token is not valid yet.'); - return false; + return false; } // The token should not be older then 24 hours which would be absurd old for an JWT token - if (isset($claims['iat']) && $claims['iat'] < (time() - 86400)) { + if (isset($claims['iat']) && $claims['iat'] < (time() - 86400)) { log_message('error', 'JWT Verification: Token is older then 24 hours. This is very unusual. Verification failed.'); - return false; + return false; } // Is it a bearer token? - if (isset($claims['typ']) && $claims['typ'] !== 'Bearer') { + if (isset($claims['typ']) && $claims['typ'] !== 'Bearer') { log_message('error', 'JWT Verification: JWT Token is no Bearer Token.'); - return false; + return false; } // prevent alg: none attacks @@ -97,193 +184,118 @@ class Header_auth extends CI_Controller { } /** - * Authenticate using a trusted request header. + * Helper to create a user if it does not exist. + * + * @param string $username + * @param string $email + * @param string $callsign + * @param string $firstname + * @param string $lastname + * + * @return void */ - public function login() { - // Guard: feature must be enabled - if (!$this->config->item('auth_header_enable')) { - $this->session->set_flashdata('error', __('Header authentication is disabled.')); - redirect('user/login'); + private function _create_user($username, $email, $callsign, $firstname, $lastname) { + if (empty($email) || empty($callsign)) { + log_message('error', 'SSO Authentication: Missing email or callsign claim in access token.'); + $this->_sso_error(); } - // Decode JWT access token forwarded by idp - $accesstoken_path = $this->config->item('auth_headers_accesstoken') ?? false; - if (!$accesstoken_path) { - log_message('error', 'SSO Authentication: Access Token Path not configured in config.php.'); - $this->session->set_flashdata('error', __('SSO Config Error. Check error log.')); - redirect('user/login'); + // $club_id = $this->config->item('auth_header_club_id') ?: ''; // TODO: Add support to add a user to a clubstation + + $result = $this->user_model->add( + $username, + bin2hex(random_bytes(64)), // password + $email, + 3, // $data['user_type'], we don't create admins for security reasons + $firstname, + $lastname, + $callsign, + "", // locator + 24, // user_timezone is default UTC + "M", // measurement + "Y", // dashboard_map + "Y-m-d", // user_date_format + 'darkly', // user_stylesheet + '0', // user_qth_lookup + '0', // user_sota_lookup + '0', // user_wwff_lookup + '0', // user_pota_lookup + 1, // user_show_notes + 'Mode', // user_column1 + 'RSTS', // user_column2 + 'RSTR', // user_column3 + 'Band', // user_column4 + 'Country', // user_column5 + '0', // user_show_profile_image + '0', // user_previous_qsl_type + '0', // user_amsat_status_upload + '', // user_mastodon_url + 'ALL', // user_default_band + 'QL', // user_default_confirmation + '0', // user_qso_end_times + "Y", // user_qso_db_search_priority + '0', // user_quicklog + '0', // user_quicklog_enter + "english", // user_language + '', // user_hamsat_key + '', // user_hamsat_workable_only + '', // user_iota_to_qso_tab + '', // user_sota_to_qso_tab + '', // user_wwff_to_qso_tab + '', // user_pota_to_qso_tab + '', // user_sig_to_qso_tab + '', // user_dok_to_qso_tab + 0, // user_station_to_qso_tab + '', // user_lotw_name + '', // user_lotw_password + '', // user_eqsl_name + '', // user_eqsl_password + '', // user_clublog_name + '', // user_clublog_password + '0', // user_winkey + "", // on_air_widget_enabled + "", // on_air_widget_display_last_seen + "", // on_air_widget_show_only_most_recent_radio + "", // qso_widget_display_qso_time + "", // dashboard_banner + "", // dashboard_solar + "", // global_oqrs_text + "", // oqrs_grouped_search + "", // oqrs_grouped_search_show_station_name + "", // oqrs_auto_matching + "", // oqrs_direct_auto_matching + "", // user_dxwaterfall_enable + "", // user_qso_show_map + 0, // clubstation + true, // external_account + ); + + switch ($result) { + case EUSERNAMEEXISTS: + log_message('error', 'SSO Authentication: The SSO Integration tried to create a new User because the Username was not found. But the Username already exists. This should not happen as the user should be looked up by the same username before. Check your user provisioning and claims mapping configuration. Otherwise create an issue on https://github.com/wavelog/wavelog'); + $this->_sso_error(__("Something went terribly wrong. Check the error log.")); + break; + case EEMAILEXISTS: + log_message('error', 'SSO Authentication: The SSO Integration tried to create a new User because the Username was not found. But the E-mail for the new User already exists for an existing user. Check for existing Wavelog users with the same e-mail address as the one provided by your IdP.'); + $this->_sso_error(__("Something went terribly wrong. Check the error log.")); + break; + case OK: + return; } - $token = $this->input->server($accesstoken_path, true); - if (empty($token)) { - log_message('error', 'SSO Authentication: Missing access token header.'); - $this->session->set_flashdata('error', __('SSO Config Error. Check error log.')); - redirect('user/login'); + } + + /** + * Helper to set flashdata and redirect to login with an error message. We use this a lot in the SSO login process, so we need a helper for this. + * + * @param string|null $message + * + * @return void + */ + private function _sso_error($message = null) { + if ($message === null) { + $message = __("SSO Config Error. Check error log."); } - - $claims = $this->_decode_jwt_payload($token); - if (empty($claims)) { - log_message('error', 'SSO Authentication: Invalid access token format. Failed to decode JWT token.'); - $this->session->set_flashdata('error', __('Config Error. Check error log.')); - redirect('user/login'); - } - - if (!$this->_verify_jwtdata($claims)) { - log_message('error', 'SSO Authentication: Token validation failed.'); - $this->session->set_flashdata('error', __('Config Error. Check error log.')); - redirect('user/login'); - } - - $callsign_claim = $this->config->item('auth_headers_callsign_claim') ?? 'callsign'; - - $username = $claims['preferred_username'] ?? ''; - $email = $claims['email'] ?? ''; - $callsign = $claims[$callsign_claim] ?? ''; - $firstname = $claims['given_name'] ?? ''; - $lastname = $claims['family_name'] ?? ''; - - if (empty($username)) { - log_message('error', 'SSO Authentication: Missing username claim in access token.'); - $this->session->set_flashdata('error', __('Config Error. Check error log.')); - redirect('user/login'); - } - - // Look up user by the header value - $this->load->model('user_model'); - $query = $this->user_model->get($username); - if (!$query || $query->num_rows() !== 1) { - - // Config check if create user - if ($this->config->item('auth_header_create')) { - if (empty($email)) { - log_message('error', 'SSO Authentication: Missing email claim in access token.'); - $this->session->set_flashdata('error', __('Config Error. Check error log.')); - redirect('user/login'); - } - if (empty($callsign)) { - log_message('error', 'SSO Authentication: Missing callsign claim in access token.'); - $this->session->set_flashdata('error', __('Config Error. Check error log.')); - redirect('user/login'); - } - - // $club_id = $this->config->item('auth_header_club_id') ?: ''; // TODO: Add support to add a user to a clubstation - - $result = $this->user_model->add( - $username, - bin2hex(random_bytes(64)), // password - $email, - 3, // $data['user_type'], we don't create admins for security reasons - $firstname, - $lastname, - $callsign, - "", // locator - 24, // user_timezone is default UTC - "M", // measurement - "Y", // dashboard_map - "Y-m-d", // user_date_format - 'darkly', // user_stylesheet - '0', // user_qth_lookup - '0', // user_sota_lookup - '0', // user_wwff_lookup - '0', // user_pota_lookup - 1, // user_show_notes - 'Mode', // user_column1 - 'RSTS', // user_column2 - 'RSTR', // user_column3 - 'Band', // user_column4 - 'Country', // user_column5 - '0', // user_show_profile_image - '0', // user_previous_qsl_type - '0', // user_amsat_status_upload - '', // user_mastodon_url - 'ALL', // user_default_band - 'QL', // user_default_confirmation - '0', // user_qso_end_times - "Y", // user_qso_db_search_priority - '0', // user_quicklog - '0', // user_quicklog_enter - "english", // user_language - '', // user_hamsat_key - '', // user_hamsat_workable_only - '', // user_iota_to_qso_tab - '', // user_sota_to_qso_tab - '', // user_wwff_to_qso_tab - '', // user_pota_to_qso_tab - '', // user_sig_to_qso_tab - '', // user_dok_to_qso_tab - 0, // user_station_to_qso_tab - '', // user_lotw_name - '', // user_lotw_password - '', // user_eqsl_name - '', // user_eqsl_password - '', // user_clublog_name - '', // user_clublog_password - '0', // user_winkey - "", // on_air_widget_enabled - "", // on_air_widget_display_last_seen - "", // on_air_widget_show_only_most_recent_radio - "", // qso_widget_display_qso_time - "", // dashboard_banner - "", // dashboard_solar - "", // global_oqrs_text - "", // oqrs_grouped_search - "", // oqrs_grouped_search_show_station_name - "", // oqrs_auto_matching - "", // oqrs_direct_auto_matching - "", // user_dxwaterfall_enable - "", // user_qso_show_map - 0, // clubstation - true, // external_account - ); - - switch ($result) { - case EUSERNAMEEXISTS: - $data['username_error'] = sprintf(__("Username %s already in use!"), '' . $this->input->post('user_name') . ''); - break; - case EEMAILEXISTS: - $data['email_error'] = sprintf(__("E-mail %s already in use!"), '' . $this->input->post('user_email') . ''); - break; - case EPASSWORDINVALID: - $data['password_error'] = __("Invalid Password!"); - break; - case OK: - redirect('header_auth/login'); - return; - } - } else { - $this->session->set_flashdata('error', __('User not found.')); - redirect('user/login'); - } - } - - - $user = $query->row(); - - // Prevent clubstation direct login via header (mirrors User::login) - if (!empty($user->clubstation) && $user->clubstation == 1) { - $this->session->set_flashdata('error', __("You can't login to a clubstation directly. Use your personal account instead.")); - redirect('user/login'); - } - - // Maintenance mode check (admin only allowed) - if (ENVIRONMENT === 'maintenance' && (int)$user->user_type !== 99) { - $this->session->set_flashdata('error', __("Sorry. This instance is currently in maintenance mode. Only administrators are currently allowed to log in.")); - redirect('user/login'); - } - - // Establish session - $this->user_model->update_session($user->user_id); - $this->user_model->set_last_seen($user->user_id); - - // Set language cookie (mirrors User::login) - $cookie = [ - 'name' => $this->config->item('gettext_cookie', 'gettext'), - 'value' => $user->user_language, - 'expire' => 1000, - 'secure' => false, - ]; - $this->input->set_cookie($cookie); - - log_message('info', "User ID [{$user->user_id}] logged in via header auth."); - redirect('dashboard'); + $this->session->set_flashdata('error', $message); + redirect('user/login'); } } From 180edac244753da13a634fc387f87e5b8a72ba75 Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Mon, 16 Mar 2026 15:56:25 +0100 Subject: [PATCH 25/48] be safe the execution stopps in case the redirect fails --- application/controllers/Header_auth.php | 1 + 1 file changed, 1 insertion(+) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 2e5fc4ac5..2a7c42ea7 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -297,5 +297,6 @@ class Header_auth extends CI_Controller { } $this->session->set_flashdata('error', $message); redirect('user/login'); + die; } } From 30fe503d9b7de165e8962a3f6f900d022b87a422 Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Mon, 16 Mar 2026 15:57:38 +0100 Subject: [PATCH 26/48] load the user model --- application/controllers/Header_auth.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 2a7c42ea7..b9775edd6 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -201,7 +201,7 @@ class Header_auth extends CI_Controller { } // $club_id = $this->config->item('auth_header_club_id') ?: ''; // TODO: Add support to add a user to a clubstation - + $this->load->model('user_model'); $result = $this->user_model->add( $username, bin2hex(random_bytes(64)), // password From 0f1a2f1f89d2c9e55bf3b16ab05189df9c2e3f9d Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Mon, 16 Mar 2026 15:59:09 +0100 Subject: [PATCH 27/48] define the type --- application/controllers/Header_auth.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index b9775edd6..c51622dab 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -143,7 +143,7 @@ class Header_auth extends CI_Controller { * * @return bool */ - private function _verify_jwtdata($claims = null) { + private function _verify_jwtdata(?array $claims = null): bool { // No claim, no verificiation if (!$claims) { log_message('error', 'JWT Verification: No claim data received.'); From 8e3229b474acc1bac20a0eff0ff6b34053d51bcb Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Mon, 16 Mar 2026 16:11:05 +0100 Subject: [PATCH 28/48] fix $query after user was created --- application/controllers/Header_auth.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index c51622dab..83ef2d3a3 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -64,11 +64,19 @@ class Header_auth extends CI_Controller { // Config check if create user if ($this->config->item('auth_header_create')) { $this->_create_user($username, $email, $callsign, $firstname, $lastname); + $query = $this->user_model->get($username); } else { $this->_sso_error(__("User not found.")); + return; } } + if (!$query || $query->num_rows() !== 1) { + log_message('error', 'SSO Authentication: User could not be found or created.'); + $this->_sso_error(); + return; + } + $user = $query->row(); // Prevent clubstation direct login via header (mirrors User::login) From 684919648734d6d9280b586f04868500386f4e0b Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Wed, 18 Mar 2026 10:24:12 +0100 Subject: [PATCH 29/48] added security comment for changes at user_model::add() --- application/models/User_model.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/application/models/User_model.php b/application/models/User_model.php index 7c5ac6806..114ed3458 100644 --- a/application/models/User_model.php +++ b/application/models/User_model.php @@ -213,7 +213,8 @@ class User_Model extends CI_Model { // FUNCTION: bool add($username, $password, $email, $type) // Add a user // !!!!!!!!!!!!!!!! - // !! IMPORTANT NOTICE: Please inform DJ7NT and/or DF2ET when adding/removing/changing parameters here. + // !! IMPORTANT NOTICE: Please inform DJ7NT and/or DF2ET when adding/removing/changing parameters here. + // !! Also make sure you modify Header_auth::_create_user accordingly, otherwise SSO user creation will break. // !!!!!!!!!!!!!!!! function add($username, $password, $email, $type, $firstname, $lastname, $callsign, $locator, $timezone, $measurement, $dashboard_map, $user_date_format, $user_stylesheet, $user_qth_lookup, $user_sota_lookup, $user_wwff_lookup, From 2e4594cf5f2b5babc9eb0de661f3bee435bb696e Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Wed, 18 Mar 2026 10:25:26 +0100 Subject: [PATCH 30/48] implemented firebase/php-jwt --- src/jwt/LICENSE | 30 + src/jwt/README.md | 426 ++++++++++ src/jwt/src/BeforeValidException.php | 18 + src/jwt/src/CachedKeySet.php | 274 +++++++ src/jwt/src/ExpiredException.php | 30 + src/jwt/src/JWK.php | 355 +++++++++ src/jwt/src/JWT.php | 748 ++++++++++++++++++ .../src/JWTExceptionWithPayloadInterface.php | 20 + src/jwt/src/Key.php | 54 ++ src/jwt/src/SignatureInvalidException.php | 7 + 10 files changed, 1962 insertions(+) create mode 100644 src/jwt/LICENSE create mode 100644 src/jwt/README.md create mode 100644 src/jwt/src/BeforeValidException.php create mode 100644 src/jwt/src/CachedKeySet.php create mode 100644 src/jwt/src/ExpiredException.php create mode 100644 src/jwt/src/JWK.php create mode 100644 src/jwt/src/JWT.php create mode 100644 src/jwt/src/JWTExceptionWithPayloadInterface.php create mode 100644 src/jwt/src/Key.php create mode 100644 src/jwt/src/SignatureInvalidException.php diff --git a/src/jwt/LICENSE b/src/jwt/LICENSE new file mode 100644 index 000000000..11c014665 --- /dev/null +++ b/src/jwt/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2011, Neuman Vong + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the copyright holder nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/jwt/README.md b/src/jwt/README.md new file mode 100644 index 000000000..e9f20426a --- /dev/null +++ b/src/jwt/README.md @@ -0,0 +1,426 @@ +#### +SOURCE https://github.com/firebase/php-jwt +VERSION 7.0.3 +IMPORTED 18th March 2026, HB9HIL +#### + +PHP-JWT +======= +A simple library to encode and decode JSON Web Tokens (JWT) in PHP, conforming to [RFC 7519](https://tools.ietf.org/html/rfc7519). + +Installation +------------ + +Use composer to manage your dependencies and download PHP-JWT: + +```bash +composer require firebase/php-jwt +``` + +Optionally, install the `paragonie/sodium_compat` package from composer if your +php env does not have libsodium installed: + +```bash +composer require paragonie/sodium_compat +``` + +Example +------- +```php +use Firebase\JWT\JWT; +use Firebase\JWT\Key; + +$key = 'example_key'; +$payload = [ + 'iss' => 'http://example.org', + 'aud' => 'http://example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +/** + * IMPORTANT: + * You must specify supported algorithms for your application. See + * https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40 + * for a list of spec-compliant algorithms. + */ +$jwt = JWT::encode($payload, $key, 'HS256'); +$decoded = JWT::decode($jwt, new Key($key, 'HS256')); +print_r($decoded); + +// Pass a stdClass in as the third parameter to get the decoded header values +$headers = new stdClass(); +$decoded = JWT::decode($jwt, new Key($key, 'HS256'), $headers); +print_r($headers); + +/* + NOTE: This will now be an object instead of an associative array. To get + an associative array, you will need to cast it as such: +*/ + +$decoded_array = (array) $decoded; + +/** + * You can add a leeway to account for when there is a clock skew times between + * the signing and verifying servers. It is recommended that this leeway should + * not be bigger than a few minutes. + * + * Source: http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html#nbfDef + */ +JWT::$leeway = 60; // $leeway in seconds +$decoded = JWT::decode($jwt, new Key($key, 'HS256')); +``` +Example encode/decode headers +------- +Decoding the JWT headers without verifying the JWT first is NOT recommended, and is not supported by +this library. This is because without verifying the JWT, the header values could have been tampered with. +Any value pulled from an unverified header should be treated as if it could be any string sent in from an +attacker. If this is something you still want to do in your application for whatever reason, it's possible to +decode the header values manually simply by calling `json_decode` and `base64_decode` on the JWT +header part: +```php +use Firebase\JWT\JWT; + +$key = 'example_key'; +$payload = [ + 'iss' => 'http://example.org', + 'aud' => 'http://example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +$headers = [ + 'x-forwarded-for' => 'www.google.com' +]; + +// Encode headers in the JWT string +$jwt = JWT::encode($payload, $key, 'HS256', null, $headers); + +// Decode headers from the JWT string WITHOUT validation +// **IMPORTANT**: This operation is vulnerable to attacks, as the JWT has not yet been verified. +// These headers could be any value sent by an attacker. +list($headersB64, $payloadB64, $sig) = explode('.', $jwt); +$decoded = json_decode(base64_decode($headersB64), true); + +print_r($decoded); +``` +Example with RS256 (openssl) +---------------------------- +```php +use Firebase\JWT\JWT; +use Firebase\JWT\Key; + +$privateKey = << 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +$jwt = JWT::encode($payload, $privateKey, 'RS256'); +echo "Encode:\n" . print_r($jwt, true) . "\n"; + +$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256')); + +/* + NOTE: This will now be an object instead of an associative array. To get + an associative array, you will need to cast it as such: +*/ + +$decoded_array = (array) $decoded; +echo "Decode:\n" . print_r($decoded_array, true) . "\n"; +``` + +Example with a passphrase +------------------------- + +```php +use Firebase\JWT\JWT; +use Firebase\JWT\Key; + +// Your passphrase +$passphrase = '[YOUR_PASSPHRASE]'; + +// Your private key file with passphrase +// Can be generated with "ssh-keygen -t rsa -m pem" +$privateKeyFile = '/path/to/key-with-passphrase.pem'; + +/** @var OpenSSLAsymmetricKey $privateKey */ +$privateKey = openssl_pkey_get_private( + file_get_contents($privateKeyFile), + $passphrase +); + +$payload = [ + 'iss' => 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +$jwt = JWT::encode($payload, $privateKey, 'RS256'); +echo "Encode:\n" . print_r($jwt, true) . "\n"; + +// Get public key from the private key, or pull from from a file. +$publicKey = openssl_pkey_get_details($privateKey)['key']; + +$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256')); +echo "Decode:\n" . print_r((array) $decoded, true) . "\n"; +``` + +Example with EdDSA (libsodium and Ed25519 signature) +---------------------------- +```php +use Firebase\JWT\JWT; +use Firebase\JWT\Key; + +// Public and private keys are expected to be Base64 encoded. The last +// non-empty line is used so that keys can be generated with +// sodium_crypto_sign_keypair(). The secret keys generated by other tools may +// need to be adjusted to match the input expected by libsodium. + +$keyPair = sodium_crypto_sign_keypair(); + +$privateKey = base64_encode(sodium_crypto_sign_secretkey($keyPair)); + +$publicKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); + +$payload = [ + 'iss' => 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +$jwt = JWT::encode($payload, $privateKey, 'EdDSA'); +echo "Encode:\n" . print_r($jwt, true) . "\n"; + +$decoded = JWT::decode($jwt, new Key($publicKey, 'EdDSA')); +echo "Decode:\n" . print_r((array) $decoded, true) . "\n"; +```` + +Example with multiple keys +-------------------------- +```php +use Firebase\JWT\JWT; +use Firebase\JWT\Key; + +// Example RSA keys from previous example +// $privateKey1 = '...'; +// $publicKey1 = '...'; + +// Example EdDSA keys from previous example +// $privateKey2 = '...'; +// $publicKey2 = '...'; + +$payload = [ + 'iss' => 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +$jwt1 = JWT::encode($payload, $privateKey1, 'RS256', 'kid1'); +$jwt2 = JWT::encode($payload, $privateKey2, 'EdDSA', 'kid2'); +echo "Encode 1:\n" . print_r($jwt1, true) . "\n"; +echo "Encode 2:\n" . print_r($jwt2, true) . "\n"; + +$keys = [ + 'kid1' => new Key($publicKey1, 'RS256'), + 'kid2' => new Key($publicKey2, 'EdDSA'), +]; + +$decoded1 = JWT::decode($jwt1, $keys); +$decoded2 = JWT::decode($jwt2, $keys); + +echo "Decode 1:\n" . print_r((array) $decoded1, true) . "\n"; +echo "Decode 2:\n" . print_r((array) $decoded2, true) . "\n"; +``` + +Using JWKs +---------- + +```php +use Firebase\JWT\JWK; +use Firebase\JWT\JWT; + +// Set of keys. The "keys" key is required. For example, the JSON response to +// this endpoint: https://www.gstatic.com/iap/verify/public_key-jwk +$jwks = ['keys' => []]; + +// JWK::parseKeySet($jwks) returns an associative array of **kid** to Firebase\JWT\Key +// objects. Pass this as the second parameter to JWT::decode. +JWT::decode($jwt, JWK::parseKeySet($jwks)); +``` + +Using Cached Key Sets +--------------------- + +The `CachedKeySet` class can be used to fetch and cache JWKS (JSON Web Key Sets) from a public URI. +This has the following advantages: + +1. The results are cached for performance. +2. If an unrecognized key is requested, the cache is refreshed, to accomodate for key rotation. +3. If rate limiting is enabled, the JWKS URI will not make more than 10 requests a second. + +```php +use Firebase\JWT\CachedKeySet; +use Firebase\JWT\JWT; + +// The URI for the JWKS you wish to cache the results from +$jwksUri = 'https://www.gstatic.com/iap/verify/public_key-jwk'; + +// Create an HTTP client (can be any PSR-7 compatible HTTP client) +$httpClient = new GuzzleHttp\Client(); + +// Create an HTTP request factory (can be any PSR-17 compatible HTTP request factory) +$httpFactory = new GuzzleHttp\Psr\HttpFactory(); + +// Create a cache item pool (can be any PSR-6 compatible cache item pool) +$cacheItemPool = Phpfastcache\CacheManager::getInstance('files'); + +$keySet = new CachedKeySet( + $jwksUri, + $httpClient, + $httpFactory, + $cacheItemPool, + null, // $expiresAfter int seconds to set the JWKS to expire + true // $rateLimit true to enable rate limit of 10 RPS on lookup of invalid keys +); + +$jwt = 'eyJhbGci...'; // Some JWT signed by a key from the $jwkUri above +$decoded = JWT::decode($jwt, $keySet); +``` + +Miscellaneous +------------- + +#### Exception Handling + +When a call to `JWT::decode` is invalid, it will throw one of the following exceptions: + +```php +use Firebase\JWT\JWT; +use Firebase\JWT\SignatureInvalidException; +use Firebase\JWT\BeforeValidException; +use Firebase\JWT\ExpiredException; +use DomainException; +use InvalidArgumentException; +use UnexpectedValueException; + +try { + $decoded = JWT::decode($jwt, $keys); +} catch (InvalidArgumentException $e) { + // provided key/key-array is empty or malformed. +} catch (DomainException $e) { + // provided algorithm is unsupported OR + // provided key is invalid OR + // unknown error thrown in openSSL or libsodium OR + // libsodium is required but not available. +} catch (SignatureInvalidException $e) { + // provided JWT signature verification failed. +} catch (BeforeValidException $e) { + // provided JWT is trying to be used before "nbf" claim OR + // provided JWT is trying to be used before "iat" claim. +} catch (ExpiredException $e) { + // provided JWT is trying to be used after "exp" claim. +} catch (UnexpectedValueException $e) { + // provided JWT is malformed OR + // provided JWT is missing an algorithm / using an unsupported algorithm OR + // provided JWT algorithm does not match provided key OR + // provided key ID in key/key-array is empty or invalid. +} +``` + +All exceptions in the `Firebase\JWT` namespace extend `UnexpectedValueException`, and can be simplified +like this: + +```php +use Firebase\JWT\JWT; +use UnexpectedValueException; +try { + $decoded = JWT::decode($jwt, $keys); +} catch (LogicException $e) { + // errors having to do with environmental setup or malformed JWT Keys +} catch (UnexpectedValueException $e) { + // errors having to do with JWT signature and claims +} +``` + +#### Casting to array + +The return value of `JWT::decode` is the generic PHP object `stdClass`. If you'd like to handle with arrays +instead, you can do the following: + +```php +// return type is stdClass +$decoded = JWT::decode($jwt, $keys); + +// cast to array +$decoded = json_decode(json_encode($decoded), true); +``` + +Tests +----- +Run the tests using phpunit: + +```bash +$ pear install PHPUnit +$ phpunit --configuration phpunit.xml.dist +PHPUnit 3.7.10 by Sebastian Bergmann. +..... +Time: 0 seconds, Memory: 2.50Mb +OK (5 tests, 5 assertions) +``` + +New Lines in private keys +----- + +If your private key contains `\n` characters, be sure to wrap it in double quotes `""` +and not single quotes `''` in order to properly interpret the escaped characters. + +License +------- +[3-Clause BSD](http://opensource.org/licenses/BSD-3-Clause). diff --git a/src/jwt/src/BeforeValidException.php b/src/jwt/src/BeforeValidException.php new file mode 100644 index 000000000..595164bf3 --- /dev/null +++ b/src/jwt/src/BeforeValidException.php @@ -0,0 +1,18 @@ +payload = $payload; + } + + public function getPayload(): object + { + return $this->payload; + } +} diff --git a/src/jwt/src/CachedKeySet.php b/src/jwt/src/CachedKeySet.php new file mode 100644 index 000000000..8e8e8d68c --- /dev/null +++ b/src/jwt/src/CachedKeySet.php @@ -0,0 +1,274 @@ + + */ +class CachedKeySet implements ArrayAccess +{ + /** + * @var string + */ + private $jwksUri; + /** + * @var ClientInterface + */ + private $httpClient; + /** + * @var RequestFactoryInterface + */ + private $httpFactory; + /** + * @var CacheItemPoolInterface + */ + private $cache; + /** + * @var ?int + */ + private $expiresAfter; + /** + * @var ?CacheItemInterface + */ + private $cacheItem; + /** + * @var array> + */ + private $keySet; + /** + * @var string + */ + private $cacheKey; + /** + * @var string + */ + private $cacheKeyPrefix = 'jwks'; + /** + * @var int + */ + private $maxKeyLength = 64; + /** + * @var bool + */ + private $rateLimit; + /** + * @var string + */ + private $rateLimitCacheKey; + /** + * @var int + */ + private $maxCallsPerMinute = 10; + /** + * @var string|null + */ + private $defaultAlg; + + public function __construct( + string $jwksUri, + ClientInterface $httpClient, + RequestFactoryInterface $httpFactory, + CacheItemPoolInterface $cache, + ?int $expiresAfter = null, + bool $rateLimit = false, + ?string $defaultAlg = null + ) { + $this->jwksUri = $jwksUri; + $this->httpClient = $httpClient; + $this->httpFactory = $httpFactory; + $this->cache = $cache; + $this->expiresAfter = $expiresAfter; + $this->rateLimit = $rateLimit; + $this->defaultAlg = $defaultAlg; + $this->setCacheKeys(); + } + + /** + * @param string $keyId + * @return Key + */ + public function offsetGet($keyId): Key + { + if (!$this->keyIdExists($keyId)) { + throw new OutOfBoundsException('Key ID not found'); + } + return JWK::parseKey($this->keySet[$keyId], $this->defaultAlg); + } + + /** + * @param string $keyId + * @return bool + */ + public function offsetExists($keyId): bool + { + return $this->keyIdExists($keyId); + } + + /** + * @param string $offset + * @param Key $value + */ + public function offsetSet($offset, $value): void + { + throw new LogicException('Method not implemented'); + } + + /** + * @param string $offset + */ + public function offsetUnset($offset): void + { + throw new LogicException('Method not implemented'); + } + + /** + * @return array + */ + private function formatJwksForCache(string $jwks): array + { + $jwks = json_decode($jwks, true); + + if (!isset($jwks['keys'])) { + throw new UnexpectedValueException('"keys" member must exist in the JWK Set'); + } + + if (empty($jwks['keys'])) { + throw new InvalidArgumentException('JWK Set did not contain any keys'); + } + + $keys = []; + foreach ($jwks['keys'] as $k => $v) { + $kid = isset($v['kid']) ? $v['kid'] : $k; + $keys[(string) $kid] = $v; + } + + return $keys; + } + + private function keyIdExists(string $keyId): bool + { + if (null === $this->keySet) { + $item = $this->getCacheItem(); + // Try to load keys from cache + if ($item->isHit()) { + // item found! retrieve it + $this->keySet = $item->get(); + // If the cached item is a string, the JWKS response was cached (previous behavior). + // Parse this into expected format array instead. + if (\is_string($this->keySet)) { + $this->keySet = $this->formatJwksForCache($this->keySet); + } + } + } + + if (!isset($this->keySet[$keyId])) { + if ($this->rateLimitExceeded()) { + return false; + } + $request = $this->httpFactory->createRequest('GET', $this->jwksUri); + $jwksResponse = $this->httpClient->sendRequest($request); + if ($jwksResponse->getStatusCode() !== 200) { + throw new UnexpectedValueException( + \sprintf('HTTP Error: %d %s for URI "%s"', + $jwksResponse->getStatusCode(), + $jwksResponse->getReasonPhrase(), + $this->jwksUri, + ), + $jwksResponse->getStatusCode() + ); + } + $this->keySet = $this->formatJwksForCache((string) $jwksResponse->getBody()); + + if (!isset($this->keySet[$keyId])) { + return false; + } + + $item = $this->getCacheItem(); + $item->set($this->keySet); + if ($this->expiresAfter) { + $item->expiresAfter($this->expiresAfter); + } + $this->cache->save($item); + } + + return true; + } + + private function rateLimitExceeded(): bool + { + if (!$this->rateLimit) { + return false; + } + + $cacheItem = $this->cache->getItem($this->rateLimitCacheKey); + + $cacheItemData = []; + if ($cacheItem->isHit() && \is_array($data = $cacheItem->get())) { + $cacheItemData = $data; + } + + $callsPerMinute = $cacheItemData['callsPerMinute'] ?? 0; + $expiry = $cacheItemData['expiry'] ?? new \DateTime('+60 seconds', new \DateTimeZone('UTC')); + + if (++$callsPerMinute > $this->maxCallsPerMinute) { + return true; + } + + $cacheItem->set(['expiry' => $expiry, 'callsPerMinute' => $callsPerMinute]); + $cacheItem->expiresAt($expiry); + $this->cache->save($cacheItem); + return false; + } + + private function getCacheItem(): CacheItemInterface + { + if (\is_null($this->cacheItem)) { + $this->cacheItem = $this->cache->getItem($this->cacheKey); + } + + return $this->cacheItem; + } + + private function setCacheKeys(): void + { + if (empty($this->jwksUri)) { + throw new RuntimeException('JWKS URI is empty'); + } + + // ensure we do not have illegal characters + $key = preg_replace('|[^a-zA-Z0-9_\.!]|', '', $this->jwksUri); + + // add prefix + $key = $this->cacheKeyPrefix . $key; + + // Hash keys if they exceed $maxKeyLength of 64 + if (\strlen($key) > $this->maxKeyLength) { + $key = substr(hash('sha256', $key), 0, $this->maxKeyLength); + } + + $this->cacheKey = $key; + + if ($this->rateLimit) { + // add prefix + $rateLimitKey = $this->cacheKeyPrefix . 'ratelimit' . $key; + + // Hash keys if they exceed $maxKeyLength of 64 + if (\strlen($rateLimitKey) > $this->maxKeyLength) { + $rateLimitKey = substr(hash('sha256', $rateLimitKey), 0, $this->maxKeyLength); + } + + $this->rateLimitCacheKey = $rateLimitKey; + } + } +} diff --git a/src/jwt/src/ExpiredException.php b/src/jwt/src/ExpiredException.php new file mode 100644 index 000000000..25f445132 --- /dev/null +++ b/src/jwt/src/ExpiredException.php @@ -0,0 +1,30 @@ +payload = $payload; + } + + public function getPayload(): object + { + return $this->payload; + } + + public function setTimestamp(int $timestamp): void + { + $this->timestamp = $timestamp; + } + + public function getTimestamp(): ?int + { + return $this->timestamp; + } +} diff --git a/src/jwt/src/JWK.php b/src/jwt/src/JWK.php new file mode 100644 index 000000000..d5175b217 --- /dev/null +++ b/src/jwt/src/JWK.php @@ -0,0 +1,355 @@ + + * @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD + * @link https://github.com/firebase/php-jwt + */ +class JWK +{ + private const OID = '1.2.840.10045.2.1'; + private const ASN1_OBJECT_IDENTIFIER = 0x06; + private const ASN1_SEQUENCE = 0x10; // also defined in JWT + private const ASN1_BIT_STRING = 0x03; + private const EC_CURVES = [ + 'P-256' => '1.2.840.10045.3.1.7', // Len: 64 + 'secp256k1' => '1.3.132.0.10', // Len: 64 + 'P-384' => '1.3.132.0.34', // Len: 96 + // 'P-521' => '1.3.132.0.35', // Len: 132 (not supported) + ]; + + // For keys with "kty" equal to "OKP" (Octet Key Pair), the "crv" parameter must contain the key subtype. + // This library supports the following subtypes: + private const OKP_SUBTYPES = [ + 'Ed25519' => true, // RFC 8037 + ]; + + /** + * Parse a set of JWK keys + * + * @param array $jwks The JSON Web Key Set as an associative array + * @param string $defaultAlg The algorithm for the Key object if "alg" is not set in the + * JSON Web Key Set + * + * @return array An associative array of key IDs (kid) to Key objects + * + * @throws InvalidArgumentException Provided JWK Set is empty + * @throws UnexpectedValueException Provided JWK Set was invalid + * @throws DomainException OpenSSL failure + * + * @uses parseKey + */ + public static function parseKeySet(#[\SensitiveParameter] array $jwks, ?string $defaultAlg = null): array + { + $keys = []; + + if (!isset($jwks['keys'])) { + throw new UnexpectedValueException('"keys" member must exist in the JWK Set'); + } + + if (empty($jwks['keys'])) { + throw new InvalidArgumentException('JWK Set did not contain any keys'); + } + + foreach ($jwks['keys'] as $k => $v) { + $kid = isset($v['kid']) ? $v['kid'] : $k; + if ($key = self::parseKey($v, $defaultAlg)) { + $keys[(string) $kid] = $key; + } + } + + if (0 === \count($keys)) { + throw new UnexpectedValueException('No supported algorithms found in JWK Set'); + } + + return $keys; + } + + /** + * Parse a JWK key + * + * @param array $jwk An individual JWK + * @param string $defaultAlg The algorithm for the Key object if "alg" is not set in the + * JSON Web Key Set + * + * @return Key The key object for the JWK + * + * @throws InvalidArgumentException Provided JWK is empty + * @throws UnexpectedValueException Provided JWK was invalid + * @throws DomainException OpenSSL failure + * + * @uses createPemFromModulusAndExponent + */ + public static function parseKey(#[\SensitiveParameter] array $jwk, ?string $defaultAlg = null): ?Key + { + if (empty($jwk)) { + throw new InvalidArgumentException('JWK must not be empty'); + } + + if (!isset($jwk['kty'])) { + throw new UnexpectedValueException('JWK must contain a "kty" parameter'); + } + + if (!isset($jwk['alg'])) { + if (\is_null($defaultAlg)) { + // The "alg" parameter is optional in a KTY, but an algorithm is required + // for parsing in this library. Use the $defaultAlg parameter when parsing the + // key set in order to prevent this error. + // @see https://datatracker.ietf.org/doc/html/rfc7517#section-4.4 + throw new UnexpectedValueException('JWK must contain an "alg" parameter'); + } + $jwk['alg'] = $defaultAlg; + } + + switch ($jwk['kty']) { + case 'RSA': + if (!empty($jwk['d'])) { + throw new UnexpectedValueException('RSA private keys are not supported'); + } + if (!isset($jwk['n']) || !isset($jwk['e'])) { + throw new UnexpectedValueException('RSA keys must contain values for both "n" and "e"'); + } + + $pem = self::createPemFromModulusAndExponent($jwk['n'], $jwk['e']); + $publicKey = \openssl_pkey_get_public($pem); + if (false === $publicKey) { + throw new DomainException( + 'OpenSSL error: ' . \openssl_error_string() + ); + } + return new Key($publicKey, $jwk['alg']); + case 'EC': + if (isset($jwk['d'])) { + // The key is actually a private key + throw new UnexpectedValueException('Key data must be for a public key'); + } + + if (empty($jwk['crv'])) { + throw new UnexpectedValueException('crv not set'); + } + + if (!isset(self::EC_CURVES[$jwk['crv']])) { + throw new DomainException('Unrecognised or unsupported EC curve'); + } + + if (empty($jwk['x']) || empty($jwk['y'])) { + throw new UnexpectedValueException('x and y not set'); + } + + $publicKey = self::createPemFromCrvAndXYCoordinates($jwk['crv'], $jwk['x'], $jwk['y']); + return new Key($publicKey, $jwk['alg']); + case 'OKP': + if (isset($jwk['d'])) { + // The key is actually a private key + throw new UnexpectedValueException('Key data must be for a public key'); + } + + if (!isset($jwk['crv'])) { + throw new UnexpectedValueException('crv not set'); + } + + if (empty(self::OKP_SUBTYPES[$jwk['crv']])) { + throw new DomainException('Unrecognised or unsupported OKP key subtype'); + } + + if (empty($jwk['x'])) { + throw new UnexpectedValueException('x not set'); + } + + // This library works internally with EdDSA keys (Ed25519) encoded in standard base64. + $publicKey = JWT::convertBase64urlToBase64($jwk['x']); + return new Key($publicKey, $jwk['alg']); + case 'oct': + if (!isset($jwk['k'])) { + throw new UnexpectedValueException('k not set'); + } + + return new Key(JWT::urlsafeB64Decode($jwk['k']), $jwk['alg']); + default: + break; + } + + return null; + } + + /** + * Converts the EC JWK values to pem format. + * + * @param string $crv The EC curve (only P-256 & P-384 is supported) + * @param string $x The EC x-coordinate + * @param string $y The EC y-coordinate + * + * @return string + */ + private static function createPemFromCrvAndXYCoordinates(string $crv, string $x, string $y): string + { + $pem = + self::encodeDER( + self::ASN1_SEQUENCE, + self::encodeDER( + self::ASN1_SEQUENCE, + self::encodeDER( + self::ASN1_OBJECT_IDENTIFIER, + self::encodeOID(self::OID) + ) + . self::encodeDER( + self::ASN1_OBJECT_IDENTIFIER, + self::encodeOID(self::EC_CURVES[$crv]) + ) + ) . + self::encodeDER( + self::ASN1_BIT_STRING, + \chr(0x00) . \chr(0x04) + . JWT::urlsafeB64Decode($x) + . JWT::urlsafeB64Decode($y) + ) + ); + + return \sprintf( + "-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n", + wordwrap(base64_encode($pem), 64, "\n", true) + ); + } + + /** + * Create a public key represented in PEM format from RSA modulus and exponent information + * + * @param string $n The RSA modulus encoded in Base64 + * @param string $e The RSA exponent encoded in Base64 + * + * @return string The RSA public key represented in PEM format + * + * @uses encodeLength + */ + private static function createPemFromModulusAndExponent( + string $n, + string $e + ): string { + $mod = JWT::urlsafeB64Decode($n); + $exp = JWT::urlsafeB64Decode($e); + + $modulus = \pack('Ca*a*', 2, self::encodeLength(\strlen($mod)), $mod); + $publicExponent = \pack('Ca*a*', 2, self::encodeLength(\strlen($exp)), $exp); + + $rsaPublicKey = \pack( + 'Ca*a*a*', + 48, + self::encodeLength(\strlen($modulus) + \strlen($publicExponent)), + $modulus, + $publicExponent + ); + + // sequence(oid(1.2.840.113549.1.1.1), null)) = rsaEncryption. + $rsaOID = \pack('H*', '300d06092a864886f70d0101010500'); // hex version of MA0GCSqGSIb3DQEBAQUA + $rsaPublicKey = \chr(0) . $rsaPublicKey; + $rsaPublicKey = \chr(3) . self::encodeLength(\strlen($rsaPublicKey)) . $rsaPublicKey; + + $rsaPublicKey = \pack( + 'Ca*a*', + 48, + self::encodeLength(\strlen($rsaOID . $rsaPublicKey)), + $rsaOID . $rsaPublicKey + ); + + return "-----BEGIN PUBLIC KEY-----\r\n" . + \chunk_split(\base64_encode($rsaPublicKey), 64) . + '-----END PUBLIC KEY-----'; + } + + /** + * DER-encode the length + * + * DER supports lengths up to (2**8)**127, however, we'll only support lengths up to (2**8)**4. See + * {@link http://itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf#p=13 X.690 paragraph 8.1.3} for more information. + * + * @param int $length + * @return string + */ + private static function encodeLength(int $length): string + { + if ($length <= 0x7F) { + return \chr($length); + } + + $temp = \ltrim(\pack('N', $length), \chr(0)); + + return \pack('Ca*', 0x80 | \strlen($temp), $temp); + } + + /** + * Encodes a value into a DER object. + * Also defined in Firebase\JWT\JWT + * + * @param int $type DER tag + * @param string $value the value to encode + * @return string the encoded object + */ + private static function encodeDER(int $type, string $value): string + { + $tag_header = 0; + if ($type === self::ASN1_SEQUENCE) { + $tag_header |= 0x20; + } + + // Type + $der = \chr($tag_header | $type); + + // Length + $der .= \chr(\strlen($value)); + + return $der . $value; + } + + /** + * Encodes a string into a DER-encoded OID. + * + * @param string $oid the OID string + * @return string the binary DER-encoded OID + */ + private static function encodeOID(string $oid): string + { + $octets = explode('.', $oid); + + // Get the first octet + $first = (int) array_shift($octets); + $second = (int) array_shift($octets); + $oid = \chr($first * 40 + $second); + + // Iterate over subsequent octets + foreach ($octets as $octet) { + if ($octet == 0) { + $oid .= \chr(0x00); + continue; + } + $bin = ''; + + while ($octet) { + $bin .= \chr(0x80 | ($octet & 0x7f)); + $octet >>= 7; + } + $bin[0] = $bin[0] & \chr(0x7f); + + // Convert to big endian if necessary + if (pack('V', 65534) == pack('L', 65534)) { + $oid .= strrev($bin); + } else { + $oid .= $bin; + } + } + + return $oid; + } +} diff --git a/src/jwt/src/JWT.php b/src/jwt/src/JWT.php new file mode 100644 index 000000000..90f62ca9d --- /dev/null +++ b/src/jwt/src/JWT.php @@ -0,0 +1,748 @@ + + * @author Anant Narayanan + * @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD + * @link https://github.com/firebase/php-jwt + */ +class JWT +{ + private const ASN1_INTEGER = 0x02; + private const ASN1_SEQUENCE = 0x10; + private const ASN1_BIT_STRING = 0x03; + + private const RSA_KEY_MIN_LENGTH=2048; + + /** + * When checking nbf, iat or expiration times, + * we want to provide some extra leeway time to + * account for clock skew. + * + * @var int + */ + public static $leeway = 0; + + /** + * Allow the current timestamp to be specified. + * Useful for fixing a value within unit testing. + * Will default to PHP time() value if null. + * + * @var ?int + */ + public static $timestamp = null; + + /** + * @var array + */ + public static $supported_algs = [ + 'ES384' => ['openssl', 'SHA384'], + 'ES256' => ['openssl', 'SHA256'], + 'ES256K' => ['openssl', 'SHA256'], + 'HS256' => ['hash_hmac', 'SHA256'], + 'HS384' => ['hash_hmac', 'SHA384'], + 'HS512' => ['hash_hmac', 'SHA512'], + 'RS256' => ['openssl', 'SHA256'], + 'RS384' => ['openssl', 'SHA384'], + 'RS512' => ['openssl', 'SHA512'], + 'EdDSA' => ['sodium_crypto', 'EdDSA'], + ]; + + /** + * Decodes a JWT string into a PHP object. + * + * @param string $jwt The JWT + * @param Key|ArrayAccess|array $keyOrKeyArray The Key or associative array of key IDs + * (kid) to Key objects. + * If the algorithm used is asymmetric, this is + * the public key. + * Each Key object contains an algorithm and + * matching key. + * Supported algorithms are 'ES384','ES256', + * 'HS256', 'HS384', 'HS512', 'RS256', 'RS384' + * and 'RS512'. + * @param stdClass $headers Optional. Populates stdClass with headers. + * + * @return stdClass The JWT's payload as a PHP object + * + * @throws InvalidArgumentException Provided key/key-array was empty or malformed + * @throws DomainException Provided JWT is malformed + * @throws UnexpectedValueException Provided JWT was invalid + * @throws SignatureInvalidException Provided JWT was invalid because the signature verification failed + * @throws BeforeValidException Provided JWT is trying to be used before it's eligible as defined by 'nbf' + * @throws BeforeValidException Provided JWT is trying to be used before it's been created as defined by 'iat' + * @throws ExpiredException Provided JWT has since expired, as defined by the 'exp' claim + * + * @uses jsonDecode + * @uses urlsafeB64Decode + */ + public static function decode( + string $jwt, + #[\SensitiveParameter] $keyOrKeyArray, + ?stdClass &$headers = null + ): stdClass { + // Validate JWT + $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; + + if (empty($keyOrKeyArray)) { + throw new InvalidArgumentException('Key may not be empty'); + } + $tks = \explode('.', $jwt); + if (\count($tks) !== 3) { + throw new UnexpectedValueException('Wrong number of segments'); + } + list($headb64, $bodyb64, $cryptob64) = $tks; + $headerRaw = static::urlsafeB64Decode($headb64); + if (null === ($header = static::jsonDecode($headerRaw))) { + throw new UnexpectedValueException('Invalid header encoding'); + } + if ($headers !== null) { + $headers = $header; + } + $payloadRaw = static::urlsafeB64Decode($bodyb64); + if (null === ($payload = static::jsonDecode($payloadRaw))) { + throw new UnexpectedValueException('Invalid claims encoding'); + } + if (\is_array($payload)) { + // prevent PHP Fatal Error in edge-cases when payload is empty array + $payload = (object) $payload; + } + if (!$payload instanceof stdClass) { + throw new UnexpectedValueException('Payload must be a JSON object'); + } + if (isset($payload->iat) && !\is_numeric($payload->iat)) { + throw new UnexpectedValueException('Payload iat must be a number'); + } + if (isset($payload->nbf) && !\is_numeric($payload->nbf)) { + throw new UnexpectedValueException('Payload nbf must be a number'); + } + if (isset($payload->exp) && !\is_numeric($payload->exp)) { + throw new UnexpectedValueException('Payload exp must be a number'); + } + + $sig = static::urlsafeB64Decode($cryptob64); + if (empty($header->alg)) { + throw new UnexpectedValueException('Empty algorithm'); + } + if (empty(static::$supported_algs[$header->alg])) { + throw new UnexpectedValueException('Algorithm not supported'); + } + + $key = self::getKey($keyOrKeyArray, property_exists($header, 'kid') ? $header->kid : null); + + // Check the algorithm + if (!self::constantTimeEquals($key->getAlgorithm(), $header->alg)) { + // See issue #351 + throw new UnexpectedValueException('Incorrect key for this algorithm'); + } + if (\in_array($header->alg, ['ES256', 'ES256K', 'ES384'], true)) { + // OpenSSL expects an ASN.1 DER sequence for ES256/ES256K/ES384 signatures + $sig = self::signatureToDER($sig); + } + if (!self::verify("{$headb64}.{$bodyb64}", $sig, $key->getKeyMaterial(), $header->alg)) { + throw new SignatureInvalidException('Signature verification failed'); + } + + // Check the nbf if it is defined. This is the time that the + // token can actually be used. If it's not yet that time, abort. + if (isset($payload->nbf) && floor($payload->nbf) > ($timestamp + static::$leeway)) { + $ex = new BeforeValidException( + 'Cannot handle token with nbf prior to ' . \date(DateTime::ATOM, (int) floor($payload->nbf)) + ); + $ex->setPayload($payload); + throw $ex; + } + + // Check that this token has been created before 'now'. This prevents + // using tokens that have been created for later use (and haven't + // correctly used the nbf claim). + if (!isset($payload->nbf) && isset($payload->iat) && floor($payload->iat) > ($timestamp + static::$leeway)) { + $ex = new BeforeValidException( + 'Cannot handle token with iat prior to ' . \date(DateTime::ATOM, (int) floor($payload->iat)) + ); + $ex->setPayload($payload); + throw $ex; + } + + // Check if this token has expired. + if (isset($payload->exp) && ($timestamp - static::$leeway) >= $payload->exp) { + $ex = new ExpiredException('Expired token'); + $ex->setPayload($payload); + $ex->setTimestamp($timestamp); + throw $ex; + } + + return $payload; + } + + /** + * Converts and signs a PHP array into a JWT string. + * + * @param array $payload PHP array + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. + * @param string $alg Supported algorithms are 'ES384','ES256', 'ES256K', 'HS256', + * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' + * @param string $keyId + * @param array $head An array with header elements to attach + * + * @return string A signed JWT + * + * @uses jsonEncode + * @uses urlsafeB64Encode + */ + public static function encode( + array $payload, + #[\SensitiveParameter] $key, + string $alg, + ?string $keyId = null, + ?array $head = null + ): string { + $header = ['typ' => 'JWT']; + if (isset($head)) { + $header = \array_merge($header, $head); + } + $header['alg'] = $alg; + if ($keyId !== null) { + $header['kid'] = $keyId; + } + $segments = []; + $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($header)); + $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($payload)); + $signing_input = \implode('.', $segments); + + $signature = static::sign($signing_input, $key, $alg); + $segments[] = static::urlsafeB64Encode($signature); + + return \implode('.', $segments); + } + + /** + * Sign a string with a given key and algorithm. + * + * @param string $msg The message to sign + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. + * @param string $alg Supported algorithms are 'EdDSA', 'ES384', 'ES256', 'ES256K', 'HS256', + * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' + * + * @return string An encrypted message + * + * @throws DomainException Unsupported algorithm or bad key was specified + */ + public static function sign( + string $msg, + #[\SensitiveParameter] $key, + string $alg + ): string { + if (empty(static::$supported_algs[$alg])) { + throw new DomainException('Algorithm not supported'); + } + list($function, $algorithm) = static::$supported_algs[$alg]; + switch ($function) { + case 'hash_hmac': + if (!\is_string($key)) { + throw new InvalidArgumentException('key must be a string when using hmac'); + } + self::validateHmacKeyLength($key, $algorithm); + return \hash_hmac($algorithm, $msg, $key, true); + case 'openssl': + $signature = ''; + if (!$key = openssl_pkey_get_private($key)) { + throw new DomainException('OpenSSL unable to validate key'); + } + if (str_starts_with($alg, 'RS')) { + self::validateRsaKeyLength($key); + } elseif (str_starts_with($alg, 'ES')) { + self::validateEcKeyLength($key, $alg); + } + $success = \openssl_sign($msg, $signature, $key, $algorithm); + if (!$success) { + throw new DomainException('OpenSSL unable to sign data'); + } + if ($alg === 'ES256' || $alg === 'ES256K') { + $signature = self::signatureFromDER($signature, 256); + } elseif ($alg === 'ES384') { + $signature = self::signatureFromDER($signature, 384); + } + return $signature; + case 'sodium_crypto': + if (!\function_exists('sodium_crypto_sign_detached')) { + throw new DomainException('libsodium is not available'); + } + if (!\is_string($key)) { + throw new InvalidArgumentException('key must be a string when using EdDSA'); + } + try { + // The last non-empty line is used as the key. + $lines = array_filter(explode("\n", $key)); + $key = base64_decode((string) end($lines)); + if (\strlen($key) === 0) { + throw new DomainException('Key cannot be empty string'); + } + return sodium_crypto_sign_detached($msg, $key); + } catch (Exception $e) { + throw new DomainException($e->getMessage(), 0, $e); + } + } + + throw new DomainException('Algorithm not supported'); + } + + /** + * Verify a signature with the message, key and method. Not all methods + * are symmetric, so we must have a separate verify and sign method. + * + * @param string $msg The original message (header and body) + * @param string $signature The original signature + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial For Ed*, ES*, HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey + * @param string $alg The algorithm + * + * @return bool + * + * @throws DomainException Invalid Algorithm, bad key, or OpenSSL failure + */ + private static function verify( + string $msg, + string $signature, + #[\SensitiveParameter] $keyMaterial, + string $alg + ): bool { + if (empty(static::$supported_algs[$alg])) { + throw new DomainException('Algorithm not supported'); + } + + list($function, $algorithm) = static::$supported_algs[$alg]; + switch ($function) { + case 'openssl': + if (!$key = openssl_pkey_get_public($keyMaterial)) { + throw new DomainException('OpenSSL unable to validate key'); + } + if (str_starts_with($alg, 'RS')) { + self::validateRsaKeyLength($key); + } elseif (str_starts_with($alg, 'ES')) { + self::validateEcKeyLength($key, $alg); + } + $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); + if ($success === 1) { + return true; + } + if ($success === 0) { + return false; + } + // returns 1 on success, 0 on failure, -1 on error. + throw new DomainException( + 'OpenSSL error: ' . \openssl_error_string() + ); + case 'sodium_crypto': + if (!\function_exists('sodium_crypto_sign_verify_detached')) { + throw new DomainException('libsodium is not available'); + } + if (!\is_string($keyMaterial)) { + throw new InvalidArgumentException('key must be a string when using EdDSA'); + } + try { + // The last non-empty line is used as the key. + $lines = array_filter(explode("\n", $keyMaterial)); + $key = base64_decode((string) end($lines)); + if (\strlen($key) === 0) { + throw new DomainException('Key cannot be empty string'); + } + if (\strlen($signature) === 0) { + throw new DomainException('Signature cannot be empty string'); + } + return sodium_crypto_sign_verify_detached($signature, $msg, $key); + } catch (Exception $e) { + throw new DomainException($e->getMessage(), 0, $e); + } + case 'hash_hmac': + default: + if (!\is_string($keyMaterial)) { + throw new InvalidArgumentException('key must be a string when using hmac'); + } + self::validateHmacKeyLength($keyMaterial, $algorithm); + $hash = \hash_hmac($algorithm, $msg, $keyMaterial, true); + return self::constantTimeEquals($hash, $signature); + } + } + + /** + * Decode a JSON string into a PHP object. + * + * @param string $input JSON string + * + * @return mixed The decoded JSON string + * + * @throws DomainException Provided string was invalid JSON + */ + public static function jsonDecode(string $input) + { + $obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING); + + if ($errno = \json_last_error()) { + self::handleJsonError($errno); + } elseif ($obj === null && $input !== 'null') { + throw new DomainException('Null result with non-null input'); + } + return $obj; + } + + /** + * Encode a PHP array into a JSON string. + * + * @param array $input A PHP array + * + * @return string JSON representation of the PHP array + * + * @throws DomainException Provided object could not be encoded to valid JSON + */ + public static function jsonEncode(array $input): string + { + $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); + if ($errno = \json_last_error()) { + self::handleJsonError($errno); + } elseif ($json === 'null') { + throw new DomainException('Null result with non-null input'); + } + if ($json === false) { + throw new DomainException('Provided object could not be encoded to valid JSON'); + } + return $json; + } + + /** + * Decode a string with URL-safe Base64. + * + * @param string $input A Base64 encoded string + * + * @return string A decoded string + * + * @throws InvalidArgumentException invalid base64 characters + */ + public static function urlsafeB64Decode(string $input): string + { + return \base64_decode(self::convertBase64UrlToBase64($input)); + } + + /** + * Convert a string in the base64url (URL-safe Base64) encoding to standard base64. + * + * @param string $input A Base64 encoded string with URL-safe characters (-_ and no padding) + * + * @return string A Base64 encoded string with standard characters (+/) and padding (=), when + * needed. + * + * @see https://www.rfc-editor.org/rfc/rfc4648 + */ + public static function convertBase64UrlToBase64(string $input): string + { + $remainder = \strlen($input) % 4; + if ($remainder) { + $padlen = 4 - $remainder; + $input .= \str_repeat('=', $padlen); + } + return \strtr($input, '-_', '+/'); + } + + /** + * Encode a string with URL-safe Base64. + * + * @param string $input The string you want encoded + * + * @return string The base64 encode of what you passed in + */ + public static function urlsafeB64Encode(string $input): string + { + return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_')); + } + + + /** + * Determine if an algorithm has been provided for each Key + * + * @param Key|ArrayAccess|array $keyOrKeyArray + * @param string|null $kid + * + * @throws UnexpectedValueException + * + * @return Key + */ + private static function getKey( + #[\SensitiveParameter] $keyOrKeyArray, + ?string $kid + ): Key { + if ($keyOrKeyArray instanceof Key) { + return $keyOrKeyArray; + } + + if (empty($kid) && $kid !== '0') { + throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); + } + + if ($keyOrKeyArray instanceof CachedKeySet) { + // Skip "isset" check, as this will automatically refresh if not set + return $keyOrKeyArray[$kid]; + } + + if (!isset($keyOrKeyArray[$kid])) { + throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); + } + + return $keyOrKeyArray[$kid]; + } + + /** + * @param string $left The string of known length to compare against + * @param string $right The user-supplied string + * @return bool + */ + public static function constantTimeEquals(string $left, string $right): bool + { + if (\function_exists('hash_equals')) { + return \hash_equals($left, $right); + } + $len = \min(self::safeStrlen($left), self::safeStrlen($right)); + + $status = 0; + for ($i = 0; $i < $len; $i++) { + $status |= (\ord($left[$i]) ^ \ord($right[$i])); + } + $status |= (self::safeStrlen($left) ^ self::safeStrlen($right)); + + return ($status === 0); + } + + /** + * Helper method to create a JSON error. + * + * @param int $errno An error number from json_last_error() + * + * @throws DomainException + * + * @return void + */ + private static function handleJsonError(int $errno): void + { + $messages = [ + JSON_ERROR_DEPTH => 'Maximum stack depth exceeded', + JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON', + JSON_ERROR_CTRL_CHAR => 'Unexpected control character found', + JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON', + JSON_ERROR_UTF8 => 'Malformed UTF-8 characters' //PHP >= 5.3.3 + ]; + throw new DomainException( + isset($messages[$errno]) + ? $messages[$errno] + : 'Unknown JSON error: ' . $errno + ); + } + + /** + * Get the number of bytes in cryptographic strings. + * + * @param string $str + * + * @return int + */ + private static function safeStrlen(string $str): int + { + if (\function_exists('mb_strlen')) { + return \mb_strlen($str, '8bit'); + } + return \strlen($str); + } + + /** + * Convert an ECDSA signature to an ASN.1 DER sequence + * + * @param string $sig The ECDSA signature to convert + * @return string The encoded DER object + */ + private static function signatureToDER(string $sig): string + { + // Separate the signature into r-value and s-value + $length = max(1, (int) (\strlen($sig) / 2)); + list($r, $s) = \str_split($sig, $length); + + // Trim leading zeros + $r = \ltrim($r, "\x00"); + $s = \ltrim($s, "\x00"); + + // Convert r-value and s-value from unsigned big-endian integers to + // signed two's complement + if (\ord($r[0]) > 0x7f) { + $r = "\x00" . $r; + } + if (\ord($s[0]) > 0x7f) { + $s = "\x00" . $s; + } + + return self::encodeDER( + self::ASN1_SEQUENCE, + self::encodeDER(self::ASN1_INTEGER, $r) . + self::encodeDER(self::ASN1_INTEGER, $s) + ); + } + + /** + * Encodes a value into a DER object. + * + * @param int $type DER tag + * @param string $value the value to encode + * + * @return string the encoded object + */ + private static function encodeDER(int $type, string $value): string + { + $tag_header = 0; + if ($type === self::ASN1_SEQUENCE) { + $tag_header |= 0x20; + } + + // Type + $der = \chr($tag_header | $type); + + // Length + $der .= \chr(\strlen($value)); + + return $der . $value; + } + + /** + * Encodes signature from a DER object. + * + * @param string $der binary signature in DER format + * @param int $keySize the number of bits in the key + * + * @return string the signature + */ + private static function signatureFromDER(string $der, int $keySize): string + { + // OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE + list($offset, $_) = self::readDER($der); + list($offset, $r) = self::readDER($der, $offset); + list($offset, $s) = self::readDER($der, $offset); + + // Convert r-value and s-value from signed two's compliment to unsigned + // big-endian integers + $r = \ltrim($r, "\x00"); + $s = \ltrim($s, "\x00"); + + // Pad out r and s so that they are $keySize bits long + $r = \str_pad($r, $keySize / 8, "\x00", STR_PAD_LEFT); + $s = \str_pad($s, $keySize / 8, "\x00", STR_PAD_LEFT); + + return $r . $s; + } + + /** + * Reads binary DER-encoded data and decodes into a single object + * + * @param string $der the binary data in DER format + * @param int $offset the offset of the data stream containing the object + * to decode + * + * @return array{int, string|null} the new offset and the decoded object + */ + private static function readDER(string $der, int $offset = 0): array + { + $pos = $offset; + $size = \strlen($der); + $constructed = (\ord($der[$pos]) >> 5) & 0x01; + $type = \ord($der[$pos++]) & 0x1f; + + // Length + $len = \ord($der[$pos++]); + if ($len & 0x80) { + $n = $len & 0x1f; + $len = 0; + while ($n-- && $pos < $size) { + $len = ($len << 8) | \ord($der[$pos++]); + } + } + + // Value + if ($type === self::ASN1_BIT_STRING) { + $pos++; // Skip the first contents octet (padding indicator) + $data = \substr($der, $pos, $len - 1); + $pos += $len - 1; + } elseif (!$constructed) { + $data = \substr($der, $pos, $len); + $pos += $len; + } else { + $data = null; + } + + return [$pos, $data]; + } + + /** + * Validate HMAC key length + * + * @param string $key HMAC key material + * @param string $algorithm The algorithm + * + * @throws DomainException Provided key is too short + */ + private static function validateHmacKeyLength(string $key, string $algorithm): void + { + $keyLength = \strlen($key) * 8; + $minKeyLength = (int) \str_replace('SHA', '', $algorithm); + if ($keyLength < $minKeyLength) { + throw new DomainException('Provided key is too short'); + } + } + + /** + * Validate RSA key length + * + * @param OpenSSLAsymmetricKey $key RSA key material + * @throws DomainException Provided key is too short + */ + private static function validateRsaKeyLength(#[\SensitiveParameter] OpenSSLAsymmetricKey $key): void + { + if (!$keyDetails = openssl_pkey_get_details($key)) { + throw new DomainException('Unable to validate key'); + } + if ($keyDetails['bits'] < self::RSA_KEY_MIN_LENGTH) { + throw new DomainException('Provided key is too short'); + } + } + + /** + * Validate RSA key length + * + * @param OpenSSLAsymmetricKey $key RSA key material + * @param string $algorithm The algorithm + * @throws DomainException Provided key is too short + */ + private static function validateEcKeyLength( + #[\SensitiveParameter] OpenSSLAsymmetricKey $key, + string $algorithm + ): void { + if (!$keyDetails = openssl_pkey_get_details($key)) { + throw new DomainException('Unable to validate key'); + } + $minKeyLength = (int) \str_replace('ES', '', $algorithm); + if ($keyDetails['bits'] < $minKeyLength) { + throw new DomainException('Provided key is too short'); + } + } +} diff --git a/src/jwt/src/JWTExceptionWithPayloadInterface.php b/src/jwt/src/JWTExceptionWithPayloadInterface.php new file mode 100644 index 000000000..7933ed68b --- /dev/null +++ b/src/jwt/src/JWTExceptionWithPayloadInterface.php @@ -0,0 +1,20 @@ +algorithm; + } + + /** + * @return string|OpenSSLAsymmetricKey|OpenSSLCertificate + */ + public function getKeyMaterial() + { + return $this->keyMaterial; + } +} diff --git a/src/jwt/src/SignatureInvalidException.php b/src/jwt/src/SignatureInvalidException.php new file mode 100644 index 000000000..d35dee9f1 --- /dev/null +++ b/src/jwt/src/SignatureInvalidException.php @@ -0,0 +1,7 @@ + Date: Wed, 18 Mar 2026 15:26:31 +0100 Subject: [PATCH 31/48] refactoring --- .gitignore | 4 +- application/config/config.sample.php | 28 +- application/config/sso.sample.php | 202 ++++++++++ application/controllers/Header_auth.php | 379 ++++++++++-------- application/controllers/User.php | 53 ++- .../migrations/273_external_account.php | 2 +- application/models/User_model.php | 16 +- application/views/user/edit.php | 94 +++-- install/config/config.php | 27 +- 9 files changed, 554 insertions(+), 251 deletions(-) create mode 100644 application/config/sso.sample.php diff --git a/.gitignore b/.gitignore index e5238ad90..dffc0f7b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ -/application/config/database.php /application/config/config.php +/application/config/database.php +/application/config/sso.php /application/config/redis.php /application/config/memcached.php /application/config/**/config.php /application/config/**/database.php +/application/config/**/sso.php /application/config/**/redis.php /application/config/**/memcached.php /application/logs/* diff --git a/application/config/config.sample.php b/application/config/config.sample.php index ee6fbd87e..9401280a1 100644 --- a/application/config/config.sample.php +++ b/application/config/config.sample.php @@ -109,37 +109,18 @@ $config['auth_mode'] = '3'; $config['auth_level'][3] = 'Operator'; $config['auth_level'][99] = 'Administrator'; - /* |-------------------------------------------------------------------------- | Third-Party Authentication (SSO) |-------------------------------------------------------------------------- | -| This section defines settings for third-party authentication over an external identity provider. -| When enabled, Wavelog will look for a specific HTTP header containing an access token, which can be used to authenticate users. -| This is particularly useful in environments where users are already authenticated through another system, such as a corporate SSO or an identity provider. -| Please refer to the documentation of Wavelog for more details on how to set up and use this feature. -| Link to documentation: https://docs.wavelog.org/admin-guide/configuration/third-party-authentication +| Enable SSO support via a trusted HTTP header containing a JWT access token. +| When enabled, a sso.php config file is required (see sso.sample.php). | -| 'auth_header_enable' = Enable or disable third-party authentication via HTTP headers. -| 'auth_header_create' = Automatically create a local user account if a valid access token is provided and no corresponding user exists. -| 'auth_allow_direct_login' = Allow users to log in directly through the Wavelog login form even when third-party authentication is enabled. This can be useful for administrators or in cases where some users do not have access to the SSO system. -| 'auth_headers_accesstoken' = The name of the HTTP header that contains the access token. This should match the header sent by your SSO or authentication system. -| 'auth_headers_callsign_claim' = The JWT claim that contains the user's Callsign. -| 'auth_header_text' = The text displayed on the login button for third-party authentication. -| 'auth_header_club_id' = Optional club ID to assign to users authenticated via the header. This can be used to group users or assign specific permissions based on their club affiliation. -| 'auth_url_logout' = The URL to redirect users to when they log out. If OAuth2 Proxy and Keycloak is used, must hit OAuth2 Proxy first then Keycloak. whitelist_domains for OAuth2 Proxy must also be set to the IdP domain. +| Documentation: https://docs.wavelog.org/admin-guide/configuration/third-party-authentication */ - -// $config['auth_header_enable'] = false; -// $config['auth_header_create'] = true; -// $config['auth_allow_direct_login'] = true; -// $config['auth_headers_accesstoken'] = "HTTP_X_FORWARDED_ACCESS_TOKEN"; -// $config['auth_headers_callsign_claim'] = "callsign"; -// $config['auth_header_text'] = "Login with SSO"; -// $config['auth_header_club_id'] = ""; -// $config['auth_url_logout'] = 'https://log.example.org/oauth2/sign_out?rd=https://auth.example.org/realms/example/protocol/openid-connect/logout'; +$config['auth_header_enable'] = false; /* |-------------------------------------------------------------------------- @@ -936,7 +917,6 @@ $config['enable_dxcluster_file_cache_worked'] = false; */ $config['dxcluster_refresh_time'] = 30; - /* |-------------------------------------------------------------------------- | Internal tools diff --git a/application/config/sso.sample.php b/application/config/sso.sample.php new file mode 100644 index 000000000..e73102ed3 --- /dev/null +++ b/application/config/sso.sample.php @@ -0,0 +1,202 @@ + The JWT claim name to read the value from. + * + * 'override_on_update' => If true, the field is updated from the JWT on + * every login. If false, the value is only written + * once when the account is created. + * Set to false for fields the user should be able + * to change independently (e.g. username). + * + * 'allow_manual_change'=> If true, the user can edit this field in their + * Wavelog profile. If false, the field is locked + * and managed exclusively by the identity provider. + * + * You can add any additional column from the users table here. Fields not + * listed will use their default values on account creation and will not be + * touched on subsequent logins. + * + * The following fields are required for account creation and must be present: + * - user_name + * - user_email + * - user_callsign + */ +$config['auth_headers_claim_config'] = [ + 'user_name' => [ + 'claim' => 'preferred_username', + 'override_on_update' => true, + 'allow_manual_change' => false + ], + 'user_email' => [ + 'claim' => 'email', + 'override_on_update' => true, + 'allow_manual_change' => false + ], + 'user_callsign' => [ + 'claim' => 'callsign', + 'override_on_update' => true, + 'allow_manual_change' => false + ], + 'user_locator' => [ + 'claim' => 'locator', + 'override_on_update' => true, + 'allow_manual_change' => false + ], + 'user_firstname' => [ + 'claim' => 'given_name', + 'override_on_update' => true, + 'allow_manual_change' => false + ], + 'user_lastname' => [ + 'claim' => 'family_name', + 'override_on_update' => true, + 'allow_manual_change' => false + ], +]; diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 83ef2d3a3..c932383ad 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -1,5 +1,12 @@ config->item('auth_header_enable')) { + $this->config->load('sso', true, true); + } else { + $this->_sso_error(__("SSO Authentication is disabled.")); + } } /** - * Authenticate using a trusted request header/JWT token. + * Authenticate using a trusted request header/JWT token. This endpoint is meant to be called by a reverse proxy that sits in front of Wavelog and handles the actual authentication (e.g. OAuth2 Proxy, Apache mod_auth_oidc, etc.). + * The reverse proxy validates the user's session and forwards a JWT access token containing the user's identity and claims in a trusted HTTP header. This method decodes the token, verifies it, extracts the user information + * and logs the user in. Depending on configuration, it can also automatically create a local user account if one does not exist. + * + * For more information check out the documentation: https://docs.wavelog.org/admin-guide/configuration/third-party-authentication */ public function login() { // Guard: feature must be enabled if (!$this->config->item('auth_header_enable')) { - $this->_sso_error(__("SSO Authentication is not enabled. Check your configuration.")); + $this->_sso_error(__("SSO Authentication is disabled. Check your configuration.")); } // Decode JWT access token forwarded by idp - $accesstoken_path = $this->config->item('auth_headers_accesstoken') ?? false; + $accesstoken_path = $this->config->item('auth_header_accesstoken', 'sso') ?? false; if (!$accesstoken_path) { log_message('error', 'SSO Authentication: Access Token Path not configured in config.php.'); $this->_sso_error(); @@ -32,39 +48,43 @@ class Header_auth extends CI_Controller { $this->_sso_error(); } - $claims = $this->_decode_jwt_payload($token); + if ($this->config->item('auth_header_debug_jwt', 'sso')) { + log_message('debug', 'Raw JWT: ' . $token); + } + + $claims = $this->_verify_jwt($token); if (empty($claims)) { - log_message('error', 'SSO Authentication: Invalid access token format. Failed to decode JWT token.'); - $this->_sso_error(); + $this->_sso_error("SSO Authentication failed. Invalid token."); } - if (!$this->_verify_jwtdata($claims)) { - log_message('error', 'SSO Authentication: Token validation failed.'); - $this->_sso_error(); + if ($this->config->item('auth_header_debug_jwt', 'sso')) { + log_message('debug', 'Decoded and validated JWT: ' . json_encode($claims, JSON_PRETTY_PRINT)); } - $callsign_claim = $this->config->item('auth_headers_callsign_claim') ?? 'callsign'; + $claim_map = $this->config->item('auth_headers_claim_config', 'sso'); - $username = $claims['preferred_username'] ?? ''; - $email = $claims['email'] ?? ''; - $callsign = $claims[$callsign_claim] ?? ''; - $firstname = $claims['given_name'] ?? ''; - $lastname = $claims['family_name'] ?? ''; - - if (empty($username)) { - log_message('error', 'SSO Authentication: Missing username claim in access token.'); - $this->_sso_error(); + // Extract all mapped claims dynamically — supports custom fields added by the admin + $mapped = []; + foreach ($claim_map as $db_field => $cfg) { + $mapped[$db_field] = $claims[$cfg['claim']] ?? null; } - // Look up user by the header value + // Build composite key: JSON {iss, sub} — uniquely identifies a user across IdP and user + $iss = $claims['iss'] ?? ''; + $sub = $claims['sub'] ?? ''; + if (empty($iss) || empty($sub)) { + log_message('error', 'SSO Authentication: Missing iss or sub claim in access token.'); + $this->_sso_error(); + } + $external_identifier = json_encode(['iss' => $iss, 'sub' => $sub]); + $this->load->model('user_model'); - $query = $this->user_model->get($username); + $query = $this->user_model->get_by_external_account($external_identifier); if (!$query || $query->num_rows() !== 1) { - // Config check if create user - if ($this->config->item('auth_header_create')) { - $this->_create_user($username, $email, $callsign, $firstname, $lastname); - $query = $this->user_model->get($username); + if ($this->config->item('auth_header_create', 'sso')) { + $this->_create_user($mapped, $external_identifier); + $query = $this->user_model->get_by_external_account($external_identifier); } else { $this->_sso_error(__("User not found.")); return; @@ -72,13 +92,16 @@ class Header_auth extends CI_Controller { } if (!$query || $query->num_rows() !== 1) { - log_message('error', 'SSO Authentication: User could not be found or created.'); + log_message('error', 'SSO Authentication: Something went terribly wrong. Check error log.'); $this->_sso_error(); return; } $user = $query->row(); + // Update fields from JWT claims where override_on_update is enabled + $this->_update_user_from_claims($user->user_id, $mapped); + // Prevent clubstation direct login via header (mirrors User::login) if (!empty($user->clubstation) && $user->clubstation == 1) { $this->_sso_error(__("You can't login to a clubstation directly. Use your personal account instead.")); @@ -107,175 +130,199 @@ class Header_auth extends CI_Controller { } /** - * Decode a JWT token - * + * Decode and verify a JWT token. Returns the claims array on success, null on failure. + * + * JWKS mode (auth_header_jwks_uri configured): + * Firebase JWT::decode() handles signature, exp, nbf and alg validation. + * + * Low Security mode (no JWKS URI): + * Payload is decoded without signature verification. exp, nbf and alg + * are checked manually. + * + * In both modes: iat age and typ are validated after decoding. + * * @param string $token - * + * * @return array|null */ - private function _decode_jwt_payload(string $token): ?array { - $parts = explode('.', $token); - if (count($parts) !== 3) { + private function _verify_jwt(string $token): ?array { + $jwksUri = $this->config->item('auth_header_jwks_uri', 'sso'); + + if (!empty($jwksUri)) { + try { + $jwksJson = file_get_contents($jwksUri); + if ($jwksJson === false) { + log_message('error', 'SSO Authentication: Failed to fetch JWKS from ' . $jwksUri); + return null; + } + $jwks = json_decode($jwksJson, true); + $keys = JWK::parseKeySet($jwks); + $claims = (array) JWT::decode($token, $keys); + } catch (\Exception $e) { + log_message('error', 'SSO Authentication: JWT decode/verify failed: ' . $e->getMessage()); + return null; + } + } else { + // Low Security mode: decode without signature verification to provide a minimal level of security + log_message('debug', 'SSO Authentication: JWKS URI not configured, skipping signature verification.'); + + $parts = explode('.', $token); + if (count($parts) !== 3) { + log_message('error', 'SSO Authentication: JWT does not have 3 parts.'); + return null; + } + + $b64decode = function (string $part): ?array { + $json = base64_decode(str_pad(strtr($part, '-_', '+/'), strlen($part) % 4, '=', STR_PAD_RIGHT)); + if ($json === false) return null; + $data = json_decode($json, true); + return is_array($data) ? $data : null; + }; + + $header = $b64decode($parts[0]); + $claims = $b64decode($parts[1]); + if ($claims === null) { + log_message('error', 'SSO Authentication: Failed to decode JWT payload.'); + return null; + } + + if (($claims['exp'] ?? 0) < time()) { + log_message('error', 'SSO Authentication: JWT Token is expired.'); + return null; + } + + if (isset($claims['nbf']) && $claims['nbf'] > time()) { + log_message('error', 'SSO Authentication: JWT Token is not valid yet.'); + return null; + } + + $alg = $header['alg'] ?? ''; + if (!in_array($alg, ['RS256', 'RS384', 'RS512', 'ES256', 'ES384'], true)) { + log_message('error', 'SSO Authentication: Algorithm "' . $alg . '" is not allowed.'); + return null; + } + } + + // Common checks (both modes) + if (isset($claims['iat']) && $claims['iat'] < (time() - 86400)) { + log_message('error', 'SSO Authentication: Token is older than 24 hours.'); return null; } - $decode = function (string $part): ?array { - $json = base64_decode(str_pad(strtr($part, '-_', '+/'), strlen($part) % 4, '=', STR_PAD_RIGHT)); - if ($json === false) return null; - $data = json_decode($json, true); - return is_array($data) ? $data : null; - }; - - $header = $decode($parts[0]); - $payload = $decode($parts[1]); - if ($payload === null) { + if (isset($claims['typ']) && $claims['typ'] !== 'Bearer') { + log_message('error', 'SSO Authentication: JWT Token is no Bearer Token.'); return null; } - // Merge alg from header into payload so _verify_jwtdata can check it - if (isset($header['alg'])) { - $payload['alg'] = $header['alg']; - } - - return $payload; + return $claims; } /** - * Helper to verify some long hangig fruits. We are not verifying the JWT token against the issuer at this point. - * Reason is the need for a crypto lib which is not necessary at this point. An administrator is responsible - * for the proper isolation of Wavelog and needs to make sure that Wavelog is not exposed directly. - * - * Additonal verificarion steps can be added at a later point. - * - * @param array claim data - * - * @return bool + * Update user fields from JWT claims where override_on_update is enabled. + * + * @param int $user_id + * @param array $claim_map + * @param array $values Associative array of field => value from the JWT + * + * @return void */ - private function _verify_jwtdata(?array $claims = null): bool { - // No claim, no verificiation - if (!$claims) { - log_message('error', 'JWT Verification: No claim data received.'); - return false; + private function _update_user_from_claims(int $user_id, array $mapped): void { + $updates = []; + $claim_map = $this->config->item('auth_headers_claim_config', 'sso'); + foreach ($claim_map as $db_field => $cfg) { + if (!empty($cfg['override_on_update']) && $mapped[$db_field] !== null) { + $updates[$db_field] = $mapped[$db_field]; + } } - // Check expire date - if (($claims['exp'] ?? 0) < time()) { - log_message('error', 'JWT Verification: JWT Token is expired.'); - return false; + if (!empty($updates)) { + $this->user_model->update_sso_claims($user_id, $updates); } - - // Is the token already valid - if (isset($claims['nbf']) && $claims['nbf'] > time()) { - log_message('error', 'JWT Verification: JWT Token is not valid yet.'); - return false; - } - - // The token should not be older then 24 hours which would be absurd old for an JWT token - if (isset($claims['iat']) && $claims['iat'] < (time() - 86400)) { - log_message('error', 'JWT Verification: Token is older then 24 hours. This is very unusual. Verification failed.'); - return false; - } - - // Is it a bearer token? - if (isset($claims['typ']) && $claims['typ'] !== 'Bearer') { - log_message('error', 'JWT Verification: JWT Token is no Bearer Token.'); - return false; - } - - // prevent alg: none attacks - if (!in_array($claims['alg'], ['RS256', 'RS384', 'RS512', 'ES256', 'ES384'], true)) { - log_message('error', 'JWT Verification: Algorithm ' . ($claims['alg'] ?? '???') . ' is not allowed. Create an issue on github https://github.com/wavelog/wavelog.'); - return false; - } - - return true; } /** * Helper to create a user if it does not exist. * - * @param string $username - * @param string $email - * @param string $callsign - * @param string $firstname - * @param string $lastname - * + * @param array $mapped All DB field => value pairs extracted from JWT claims + * @param string $external_identifier Composite key JSON {iss, sub} — stored once, never updated + * * @return void */ - private function _create_user($username, $email, $callsign, $firstname, $lastname) { - if (empty($email) || empty($callsign)) { + private function _create_user(array $mapped, string $external_identifier) { + if (empty($mapped['user_email']) || empty($mapped['user_callsign'])) { log_message('error', 'SSO Authentication: Missing email or callsign claim in access token.'); $this->_sso_error(); } - // $club_id = $this->config->item('auth_header_club_id') ?: ''; // TODO: Add support to add a user to a clubstation + // $club_id = $this->config->item('auth_header_club_id', 'sso') ?: ''; // TODO: Add support to add a user to a clubstation + $this->load->model('user_model'); $result = $this->user_model->add( - $username, - bin2hex(random_bytes(64)), // password - $email, - 3, // $data['user_type'], we don't create admins for security reasons - $firstname, - $lastname, - $callsign, - "", // locator - 24, // user_timezone is default UTC - "M", // measurement - "Y", // dashboard_map - "Y-m-d", // user_date_format - 'darkly', // user_stylesheet - '0', // user_qth_lookup - '0', // user_sota_lookup - '0', // user_wwff_lookup - '0', // user_pota_lookup - 1, // user_show_notes - 'Mode', // user_column1 - 'RSTS', // user_column2 - 'RSTR', // user_column3 - 'Band', // user_column4 - 'Country', // user_column5 - '0', // user_show_profile_image - '0', // user_previous_qsl_type - '0', // user_amsat_status_upload - '', // user_mastodon_url - 'ALL', // user_default_band - 'QL', // user_default_confirmation - '0', // user_qso_end_times - "Y", // user_qso_db_search_priority - '0', // user_quicklog - '0', // user_quicklog_enter - "english", // user_language - '', // user_hamsat_key - '', // user_hamsat_workable_only - '', // user_iota_to_qso_tab - '', // user_sota_to_qso_tab - '', // user_wwff_to_qso_tab - '', // user_pota_to_qso_tab - '', // user_sig_to_qso_tab - '', // user_dok_to_qso_tab - 0, // user_station_to_qso_tab - '', // user_lotw_name - '', // user_lotw_password - '', // user_eqsl_name - '', // user_eqsl_password - '', // user_clublog_name - '', // user_clublog_password - '0', // user_winkey - "", // on_air_widget_enabled - "", // on_air_widget_display_last_seen - "", // on_air_widget_show_only_most_recent_radio - "", // qso_widget_display_qso_time - "", // dashboard_banner - "", // dashboard_solar - "", // global_oqrs_text - "", // oqrs_grouped_search - "", // oqrs_grouped_search_show_station_name - "", // oqrs_auto_matching - "", // oqrs_direct_auto_matching - "", // user_dxwaterfall_enable - "", // user_qso_show_map - 0, // clubstation - true, // external_account + $mapped['user_name'] ?? '', + bin2hex(random_bytes(64)), // password is always random + $mapped['user_email'] ?? '', + 3, // user_type: never admin via SSO + $mapped['user_firstname'] ?? '', + $mapped['user_lastname'] ?? '', + $mapped['user_callsign'] ?? '', + $mapped['user_locator'] ?? '', + $mapped['user_timezone'] ?? 24, + $mapped['user_measurement_base'] ?? 'M', + $mapped['dashboard_map'] ?? 'Y', + $mapped['user_date_format'] ?? 'Y-m-d', + $mapped['user_stylesheet'] ?? 'darkly', + $mapped['user_qth_lookup'] ?? '0', + $mapped['user_sota_lookup'] ?? '0', + $mapped['user_wwff_lookup'] ?? '0', + $mapped['user_pota_lookup'] ?? '0', + $mapped['user_show_notes'] ?? 1, + $mapped['user_column1'] ?? 'Mode', + $mapped['user_column2'] ?? 'RSTS', + $mapped['user_column3'] ?? 'RSTR', + $mapped['user_column4'] ?? 'Band', + $mapped['user_column5'] ?? 'Country', + $mapped['user_show_profile_image'] ?? '0', + $mapped['user_previous_qsl_type'] ?? '0', + $mapped['user_amsat_status_upload'] ?? '0', + $mapped['user_mastodon_url'] ?? '', + $mapped['user_default_band'] ?? 'ALL', + $mapped['user_default_confirmation'] ?? 'QL', + $mapped['user_qso_end_times'] ?? '0', + $mapped['user_qso_db_search_priority'] ?? 'Y', + $mapped['user_quicklog'] ?? '0', + $mapped['user_quicklog_enter'] ?? '0', + $mapped['user_language'] ?? 'english', + $mapped['user_hamsat_key'] ?? '', + $mapped['user_hamsat_workable_only'] ?? '', + $mapped['user_iota_to_qso_tab'] ?? '', + $mapped['user_sota_to_qso_tab'] ?? '', + $mapped['user_wwff_to_qso_tab'] ?? '', + $mapped['user_pota_to_qso_tab'] ?? '', + $mapped['user_sig_to_qso_tab'] ?? '', + $mapped['user_dok_to_qso_tab'] ?? '', + $mapped['user_station_to_qso_tab'] ?? 0, + $mapped['user_lotw_name'] ?? '', + $mapped['user_lotw_password'] ?? '', + $mapped['user_eqsl_name'] ?? '', + $mapped['user_eqsl_password'] ?? '', + $mapped['user_clublog_name'] ?? '', + $mapped['user_clublog_password'] ?? '', + $mapped['user_winkey'] ?? '0', + $mapped['on_air_widget_enabled'] ?? '', + $mapped['on_air_widget_display_last_seen'] ?? '', + $mapped['on_air_widget_show_only_most_recent_radio'] ?? '', + $mapped['qso_widget_display_qso_time'] ?? '', + $mapped['dashboard_banner'] ?? '', + $mapped['dashboard_solar'] ?? '', + $mapped['global_oqrs_text'] ?? '', + $mapped['oqrs_grouped_search'] ?? '', + $mapped['oqrs_grouped_search_show_station_name'] ?? '', + $mapped['oqrs_auto_matching'] ?? '', + $mapped['oqrs_direct_auto_matching'] ?? '', + $mapped['user_dxwaterfall_enable'] ?? '', + $mapped['user_qso_show_map'] ?? '', + 0, // clubstation + $external_identifier, // external_account ); switch ($result) { diff --git a/application/controllers/User.php b/application/controllers/User.php index cff68b67d..a951bd26a 100644 --- a/application/controllers/User.php +++ b/application/controllers/User.php @@ -2,6 +2,8 @@ class User extends CI_Controller { + private $pwd_placeholder = '**********'; + public function index() { $this->load->model('user_model'); @@ -396,7 +398,6 @@ class User extends CI_Controller { $query = $this->user_model->get_by_id($this->uri->segment(3)); $data['existing_languages'] = $this->config->item('languages'); - $pwd_placeholder = '**********'; $this->load->model('bands'); $this->load->library('form_validation'); @@ -440,6 +441,17 @@ class User extends CI_Controller { // Max value to be present in the "QSO page last QSO count" selectbox $data['qso_page_last_qso_count_limit'] = QSO_PAGE_QSOS_COUNT_LIMIT; + // SSO / OIDC cases + $data['external_account'] = !empty($query->row()->external_account); + $data['auth_header_enable'] = $this->config->item('auth_header_enable') ?? false; + if ($data['auth_header_enable']) { + // expecting sso.php in the config folder + $this->config->load('sso', true, true); + } + $data['auth_header_allow_direct_login'] = $this->config->item('auth_header_allow_direct_login', 'sso') ?? true; + $data['auth_header_hide_password_field'] = $this->config->item('auth_header_hide_password_field', 'sso') ?? false; + $data['sso_claim_config'] = $this->config->item('auth_headers_claim_config', 'sso') ?: []; + $data['page_title'] = __("Edit User"); if ($this->form_validation->run() == FALSE) @@ -465,7 +477,7 @@ class User extends CI_Controller { $data['user_password'] = $this->input->post('user_password',true); } else { if ($q->user_password !== '' && $q->user_password !== null) { - $data['user_password'] = $pwd_placeholder; + $data['user_password'] = $this->pwd_placeholder; } else { $data['user_password'] = ''; } @@ -535,7 +547,7 @@ class User extends CI_Controller { $data['user_clublog_password'] = $this->input->post('user_clublog_password', true); } else { if ($q->user_clublog_password !== '' && $q->user_clublog_password !== null) { - $data['user_clublog_password'] = $pwd_placeholder; + $data['user_clublog_password'] = $this->pwd_placeholder; } else { $data['user_clublog_password'] = ''; } @@ -545,7 +557,7 @@ class User extends CI_Controller { $data['user_lotw_password'] = $this->input->post('user_lotw_password', true); } else { if ($q->user_lotw_password !== '' && $q->user_lotw_password !== null) { - $data['user_lotw_password'] = $pwd_placeholder; + $data['user_lotw_password'] = $this->pwd_placeholder; } else { $data['user_lotw_password'] = ''; } @@ -561,7 +573,7 @@ class User extends CI_Controller { $data['user_eqsl_password'] = $this->input->post('user_eqsl_password', true); } else { if ($q->user_eqsl_password !== '' && $q->user_eqsl_password !== null) { - $data['user_eqsl_password'] = $pwd_placeholder; + $data['user_eqsl_password'] = $this->pwd_placeholder; } else { $data['user_eqsl_password'] = ''; } @@ -953,7 +965,17 @@ class User extends CI_Controller { } unset($data); - switch($this->user_model->edit($this->input->post())) { + + // SSO / OIDC: Override submitted values for fields managed by the IdP + $post_data = $this->input->post(); + if (!empty($query->row()->external_account)) { + $post_data['user_name'] = $query->row()->user_name; + if (!($this->config->item('auth_header_allow_direct_login') ?? true)) { + $post_data['user_password'] = $this->pwd_placeholder; // placeholder → model skips password update + } + } + + switch($this->user_model->edit($post_data)) { // Check for errors case EUSERNAMEEXISTS: $data['username_error'] = 'Username '.$this->input->post('user_name', true).' already in use!'; @@ -1243,9 +1265,15 @@ class User extends CI_Controller { if ($this->form_validation->run() == FALSE) { $data['page_title'] = __("Login"); $data['https_check'] = $this->https_check(); + $data['auth_header_enable'] = $this->config->item('auth_header_enable') ?? false; - $data['auth_header_text'] = $this->config->item('auth_header_text') ?: ''; - $data['hide_login_form'] = ($data['auth_header_enable'] && !($this->config->item('auth_allow_direct_login') ?? true)); + if ($data['auth_header_enable']) { + // expecting sso.php in the config folder + $this->config->load('sso', true, true); + } + $data['auth_header_text'] = $this->config->item('auth_header_text', 'sso') ?: ''; + $data['hide_login_form'] = ($data['auth_header_enable'] && !($this->config->item('auth_header_allow_direct_login', 'sso') ?? true)); + $this->load->view('interface_assets/mini_header', $data); $this->load->view('user/login'); $this->load->view('interface_assets/footer'); @@ -1320,9 +1348,12 @@ class User extends CI_Controller { $this->input->set_cookie('tmp_msg', json_encode(['notice', sprintf(__("User %s logged out."), $user_name)]), 10, ''); } - $logout = $this->config->item('auth_url_logout'); - if ($this->config->item('auth_header_enable') && $logout !== null && $logout !== '') { - redirect($logout); + if ($this->config->item('auth_header_enable')) { + $this->config->load('sso', true, true); + $logout = $this->config->item('auth_header_url_logout', 'sso') ?: null; + if ($logout !== null) { + redirect($logout); + } } redirect('user/login'); diff --git a/application/migrations/273_external_account.php b/application/migrations/273_external_account.php index bcb468d09..e16ee227d 100644 --- a/application/migrations/273_external_account.php +++ b/application/migrations/273_external_account.php @@ -4,7 +4,7 @@ defined('BASEPATH') or exit('No direct script access allowed'); class Migration_external_account extends CI_Migration { public function up() { - $this->dbtry("ALTER TABLE users ADD COLUMN external_account tinyint DEFAULT 0 AFTER clubstation"); + $this->dbtry("ALTER TABLE users ADD COLUMN external_account TEXT DEFAULT NULL AFTER clubstation"); } public function down() { diff --git a/application/models/User_model.php b/application/models/User_model.php index 114ed3458..00c75be79 100644 --- a/application/models/User_model.php +++ b/application/models/User_model.php @@ -226,7 +226,7 @@ class User_Model extends CI_Model { $user_lotw_name, $user_lotw_password, $user_eqsl_name, $user_eqsl_password, $user_clublog_name, $user_clublog_password, $user_winkey, $on_air_widget_enabled, $on_air_widget_display_last_seen, $on_air_widget_show_only_most_recent_radio, $qso_widget_display_qso_time, $dashboard_banner, $dashboard_solar, $global_oqrs_text, $oqrs_grouped_search, - $oqrs_grouped_search_show_station_name, $oqrs_auto_matching, $oqrs_direct_auto_matching,$user_dxwaterfall_enable, $user_qso_show_map, $clubstation = 0, $external_account = false) { + $oqrs_grouped_search_show_station_name, $oqrs_auto_matching, $oqrs_direct_auto_matching,$user_dxwaterfall_enable, $user_qso_show_map, $clubstation = 0, $external_account = null) { // Check that the user isn't already used if(!$this->exists($username)) { $data = array( @@ -540,7 +540,7 @@ class User_Model extends CI_Model { // This is really just a wrapper around User_Model::authenticate function login() { - if (($this->config->item('auth_header_enable') ?? false) && !($this->config->item('auth_allow_direct_login') ?? true)) { + if (($this->config->item('auth_header_enable') ?? false) && !($this->config->item('auth_header_allow_direct_login') ?? true)) { $this->session->set_flashdata('error', 'Direct login is disabled. Please use the SSO option to log in.'); redirect('user/login'); } @@ -753,6 +753,18 @@ class User_Model extends CI_Model { return 0; } + // FUNCTION: retrieve a user by their SSO composite key (md5(iss).sub) + function get_by_external_account(string $key) { + $this->db->where('external_account', $key); + return $this->db->get($this->config->item('auth_table')); + } + + // FUNCTION: update specific user fields from SSO claims (bypass privilege check, used during login flow) + function update_sso_claims(int $user_id, array $fields): void { + $this->db->where('user_id', $user_id); + $this->db->update('users', $fields); + } + // FUNCTION: set's the last-login timestamp in user table function set_last_seen($user_id) { $data = array( diff --git a/application/views/user/edit.php b/application/views/user/edit.php index 85b69566e..400c91085 100644 --- a/application/views/user/edit.php +++ b/application/views/user/edit.php @@ -53,18 +53,41 @@
+ IdP'; + ?> +
- - session->userdata('user_type') !== '99') { echo 'disabled'; } ?> /> - ".$username_error.""; } ?> + + + + + session->userdata('user_type') !== '99') { echo 'disabled'; } ?> /> + ".$username_error.""; } ?> +
- - - ".$email_error.""; } ?> + + + + + + ".$email_error.""; } ?> +
+
@@ -79,6 +102,7 @@ } else if (!isset($user_add)) { ?>
+
@@ -98,7 +122,7 @@ config->item('auth_level'); - echo $l[$user_type]; + echo "
" . $l[$user_type] . ""; }?> @@ -114,17 +138,29 @@
- - - ".$firstname_error.""; } else { ?> - + + + + + + ".$firstname_error.""; } ?> +
- - - ".$lastname_error.""; } else { ?> - + + + + + + ".$lastname_error.""; } ?> +
@@ -135,17 +171,29 @@
- - - ".$callsign_error.""; } else { ?> - + + + + + + ".$callsign_error.""; } ?> +
- - - ".$locator_error.""; } else { ?> - + + + + + + ".$locator_error.""; } ?> +
diff --git a/install/config/config.php b/install/config/config.php index 515665463..c64e7c6d1 100644 --- a/install/config/config.php +++ b/install/config/config.php @@ -109,37 +109,18 @@ $config['auth_mode'] = '3'; $config['auth_level'][3] = 'Operator'; $config['auth_level'][99] = 'Administrator'; - /* |-------------------------------------------------------------------------- | Third-Party Authentication (SSO) |-------------------------------------------------------------------------- | -| This section defines settings for third-party authentication over an external identity provider. -| When enabled, Wavelog will look for a specific HTTP header containing an access token, which can be used to authenticate users. -| This is particularly useful in environments where users are already authenticated through another system, such as a corporate SSO or an identity provider. -| Please refer to the documentation of Wavelog for more details on how to set up and use this feature. -| Link to documentation: https://docs.wavelog.org/admin-guide/configuration/third-party-authentication +| Enable SSO support via a trusted HTTP header containing a JWT access token. +| When enabled, a sso.php config file is required (see sso.sample.php). | -| 'auth_header_enable' = Enable or disable third-party authentication via HTTP headers. -| 'auth_header_create' = Automatically create a local user account if a valid access token is provided and no corresponding user exists. -| 'auth_allow_direct_login' = Allow users to log in directly through the Wavelog login form even when third-party authentication is enabled. This can be useful for administrators or in cases where some users do not have access to the SSO system. -| 'auth_headers_accesstoken' = The name of the HTTP header that contains the access token. This should match the header sent by your SSO or authentication system. -| 'auth_headers_callsign_claim' = The JWT claim that contains the user's Callsign. -| 'auth_header_text' = The text displayed on the login button for third-party authentication. -| 'auth_header_club_id' = Optional club ID to assign to users authenticated via the header. This can be used to group users or assign specific permissions based on their club affiliation. -| 'auth_url_logout' = The URL to redirect users to when they log out. If OAuth2 Proxy and Keycloak is used, must hit OAuth2 Proxy first then Keycloak. whitelist_domains for OAuth2 Proxy must also be set to the IdP domain. +| Documentation: https://docs.wavelog.org/admin-guide/configuration/third-party-authentication */ - -// $config['auth_header_enable'] = false; -// $config['auth_header_create'] = true; -// $config['auth_allow_direct_login'] = true; -// $config['auth_headers_accesstoken'] = "HTTP_X_FORWARDED_ACCESS_TOKEN"; -// $config['auth_headers_callsign_claim'] = "callsign"; -// $config['auth_header_text'] = "Login with SSO"; -// $config['auth_header_club_id'] = ""; -// $config['auth_url_logout'] = 'https://log.example.org/oauth2/sign_out?rd=https://auth.example.org/realms/example/protocol/openid-connect/logout'; +$config['auth_header_enable'] = false; /* |-------------------------------------------------------------------------- From 926b4f47f189cd90bcb8784a2cd1102331e9c327 Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Wed, 18 Mar 2026 22:52:02 +0100 Subject: [PATCH 32/48] allow customizing the locked data batch in user edit --- application/config/sso.sample.php | 11 +++++++++++ application/controllers/User.php | 1 + application/views/user/edit.php | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/application/config/sso.sample.php b/application/config/sso.sample.php index e73102ed3..c16df4bdb 100644 --- a/application/config/sso.sample.php +++ b/application/config/sso.sample.php @@ -70,6 +70,17 @@ $config['auth_header_allow_direct_login'] = false; $config['auth_header_hide_password_field'] = true; +/** + * -------------------------------------------------------------------------- + * Locked Data Badge + * -------------------------------------------------------------------------- + * + * HTML snippet for a badge indicating that a field is locked and managed through the Identity Provider. This is shown next to fields in the user profile that are mapped to JWT claims and not allowed to be changed manually. + * You can customize the appearance and tooltip text as needed. Leave empty to use the default. + */ +$config['auth_header_locked_data_batch'] = ""; + + /** *-------------------------------------------------------------------------- * Access Token Header diff --git a/application/controllers/User.php b/application/controllers/User.php index a951bd26a..74d8977a5 100644 --- a/application/controllers/User.php +++ b/application/controllers/User.php @@ -450,6 +450,7 @@ class User extends CI_Controller { } $data['auth_header_allow_direct_login'] = $this->config->item('auth_header_allow_direct_login', 'sso') ?? true; $data['auth_header_hide_password_field'] = $this->config->item('auth_header_hide_password_field', 'sso') ?? false; + $data['auth_header_locked_data_batch'] = $this->config->item('auth_header_locked_data_batch', 'sso') ?: ' IdP'; $data['sso_claim_config'] = $this->config->item('auth_headers_claim_config', 'sso') ?: []; $data['page_title'] = __("Edit User"); diff --git a/application/views/user/edit.php b/application/views/user/edit.php index 400c91085..7544fc746 100644 --- a/application/views/user/edit.php +++ b/application/views/user/edit.php @@ -58,7 +58,7 @@ $idp_locked = function($field) use ($external_account, $sso_claim_config) { return $external_account && isset($sso_claim_config[$field]) && empty($sso_claim_config[$field]['allow_manual_change']); }; - $idp_badge = ' IdP'; + $idp_badge = $auth_header_locked_data_batch; ?>
From 012587e479bea8dc9ce5761dfb95ad164f51b1b3 Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:21:41 -0500 Subject: [PATCH 33/48] handle when password field not in UI --- application/controllers/User.php | 2 +- application/models/User_model.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/controllers/User.php b/application/controllers/User.php index 74d8977a5..279e9e558 100644 --- a/application/controllers/User.php +++ b/application/controllers/User.php @@ -971,7 +971,7 @@ class User extends CI_Controller { $post_data = $this->input->post(); if (!empty($query->row()->external_account)) { $post_data['user_name'] = $query->row()->user_name; - if (!($this->config->item('auth_header_allow_direct_login') ?? true)) { + if (!($this->config->item('auth_header_allow_direct_login', 'sso') ?? true)) { $post_data['user_password'] = $this->pwd_placeholder; // placeholder → model skips password update } } diff --git a/application/models/User_model.php b/application/models/User_model.php index 00c75be79..6f3810441 100644 --- a/application/models/User_model.php +++ b/application/models/User_model.php @@ -442,7 +442,7 @@ class User_Model extends CI_Model { $pwd_placeholder = '**********'; // Hash password - if($fields['user_password'] != NULL) + if(array_key_exists('user_password', $fields) && ($fields['user_password'] != NULL)) { if (!file_exists('.demo') || (file_exists('.demo') && $this->session->userdata('user_type') == 99)) { From 6494c3e26e1fff0b614ba2744bec9665bff5f93a Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Wed, 18 Mar 2026 23:24:22 +0100 Subject: [PATCH 34/48] json is the better format for this column --- application/migrations/273_external_account.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/migrations/273_external_account.php b/application/migrations/273_external_account.php index e16ee227d..efdbe7ba5 100644 --- a/application/migrations/273_external_account.php +++ b/application/migrations/273_external_account.php @@ -4,7 +4,7 @@ defined('BASEPATH') or exit('No direct script access allowed'); class Migration_external_account extends CI_Migration { public function up() { - $this->dbtry("ALTER TABLE users ADD COLUMN external_account TEXT DEFAULT NULL AFTER clubstation"); + $this->dbtry("ALTER TABLE users ADD COLUMN external_account JSON DEFAULT NULL AFTER clubstation"); } public function down() { From 55ec36b20f8e96e25ace9a1e5658d7b79d82cf10 Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Wed, 18 Mar 2026 23:34:08 +0100 Subject: [PATCH 35/48] fix db search for json values --- application/models/User_model.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/application/models/User_model.php b/application/models/User_model.php index 6f3810441..305276b15 100644 --- a/application/models/User_model.php +++ b/application/models/User_model.php @@ -753,10 +753,14 @@ class User_Model extends CI_Model { return 0; } - // FUNCTION: retrieve a user by their SSO composite key (md5(iss).sub) + // FUNCTION: retrieve a user by their SSO composite key {iss, sub} stored as JSON function get_by_external_account(string $key) { - $this->db->where('external_account', $key); - return $this->db->get($this->config->item('auth_table')); + $table = $this->config->item('auth_table'); + $decoded = json_decode($key, true); + return $this->db->query( + "SELECT * FROM `$table` WHERE JSON_VALUE(external_account, '$.iss') = ? AND JSON_VALUE(external_account, '$.sub') = ?", + [$decoded['iss'], $decoded['sub']] + ); } // FUNCTION: update specific user fields from SSO claims (bypass privilege check, used during login flow) From 127ed0934543cecc4bd8e8ff0212898ceb3700d5 Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:36:30 -0500 Subject: [PATCH 36/48] disable admin send password reset email when direct login disable --- application/controllers/User.php | 15 ++++++++++++++- .../views/user/modals/more_actions_modal.php | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/application/controllers/User.php b/application/controllers/User.php index 279e9e558..a73b46102 100644 --- a/application/controllers/User.php +++ b/application/controllers/User.php @@ -80,6 +80,11 @@ class User extends CI_Controller { if ($this->user_model->exists_by_id($data['user_id']) && $modal != '') { $user = $this->user_model->get_by_id($data['user_id'])->row(); $gettext = new Gettext; + + $data['auth_header_enable'] = $this->config->item('auth_header_enable') ?? false; + if ($data['auth_header_enable']) { + $this->config->load('sso', true, true); + } $data['user_name'] = $user->user_name; $data['user_callsign'] = $user->user_callsign; @@ -92,6 +97,7 @@ class User extends CI_Controller { $data['last_seen'] = $user->last_seen; $data['custom_date_format'] = $custom_date_format; $data['has_flossie'] = ($this->config->item('encryption_key') == 'flossie1234555541') ? true : false; + $data['auth_header_allow_direct_login'] = $this->config->item('auth_header_allow_direct_login', 'sso') ?? true; $this->load->view('user/modals/'.$modal.'_modal', $data); } else { @@ -1529,7 +1535,14 @@ class User extends CI_Controller { $check_email = $this->user_model->check_email_address($data->user_email); - if($check_email == TRUE) { + // Is local login allowed + $auth_header_enable = $this->config->item('auth_header_enable') ?? false; + if ($auth_header_enable) { + $this->config->load('sso', true, true); + } + $auth_header_allow_direct_login = $this->config->item('auth_header_allow_direct_login', 'sso') ?? true; + + if($check_email == TRUE && $auth_header_allow_direct_login) { // Generate password reset code 50 characters long $this->load->helper('string'); $reset_code = random_string('alnum', 50); diff --git a/application/views/user/modals/more_actions_modal.php b/application/views/user/modals/more_actions_modal.php index 9bf172cee..f634cd70b 100644 --- a/application/views/user/modals/more_actions_modal.php +++ b/application/views/user/modals/more_actions_modal.php @@ -34,7 +34,7 @@ - + From 3018b8981a2d604111978220c9b6f2a58aaaba74 Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:02:43 -0500 Subject: [PATCH 37/48] Maintain style when badge and tooltip customised --- application/config/sso.sample.php | 3 ++- application/controllers/User.php | 3 ++- application/views/user/edit.php | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/application/config/sso.sample.php b/application/config/sso.sample.php index c16df4bdb..13e67127b 100644 --- a/application/config/sso.sample.php +++ b/application/config/sso.sample.php @@ -78,7 +78,8 @@ $config['auth_header_hide_password_field'] = true; * HTML snippet for a badge indicating that a field is locked and managed through the Identity Provider. This is shown next to fields in the user profile that are mapped to JWT claims and not allowed to be changed manually. * You can customize the appearance and tooltip text as needed. Leave empty to use the default. */ -$config['auth_header_locked_data_batch'] = ""; +$config['auth_header_locked_data_badge'] = ""; +$config['auth_header_locked_data_tip'] = ""; /** diff --git a/application/controllers/User.php b/application/controllers/User.php index a73b46102..cf0c3a1e5 100644 --- a/application/controllers/User.php +++ b/application/controllers/User.php @@ -456,7 +456,8 @@ class User extends CI_Controller { } $data['auth_header_allow_direct_login'] = $this->config->item('auth_header_allow_direct_login', 'sso') ?? true; $data['auth_header_hide_password_field'] = $this->config->item('auth_header_hide_password_field', 'sso') ?? false; - $data['auth_header_locked_data_batch'] = $this->config->item('auth_header_locked_data_batch', 'sso') ?: ' IdP'; + $data['auth_header_locked_data_badge'] = $this->config->item('auth_header_locked_data_badge', 'sso') ?: 'IdP'; + $data['auth_header_locked_data_tip'] = $this->config->item('auth_header_locked_data_tip', 'sso') ?: __("Can't be changed. Manage this through your Identity Provider."); $data['sso_claim_config'] = $this->config->item('auth_headers_claim_config', 'sso') ?: []; $data['page_title'] = __("Edit User"); diff --git a/application/views/user/edit.php b/application/views/user/edit.php index 7544fc746..d490b023f 100644 --- a/application/views/user/edit.php +++ b/application/views/user/edit.php @@ -58,7 +58,7 @@ $idp_locked = function($field) use ($external_account, $sso_claim_config) { return $external_account && isset($sso_claim_config[$field]) && empty($sso_claim_config[$field]['allow_manual_change']); }; - $idp_badge = $auth_header_locked_data_batch; + $idp_badge = ' ' . $auth_header_locked_data_badge . ''; ?>
From ea517239ec9df1f5f8b4df84d907b569fe32c22d Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:19:34 -0500 Subject: [PATCH 38/48] sso fields with user and clubstation add --- application/controllers/User.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/application/controllers/User.php b/application/controllers/User.php index cf0c3a1e5..154ea7841 100644 --- a/application/controllers/User.php +++ b/application/controllers/User.php @@ -177,6 +177,18 @@ class User extends CI_Controller { $data['clubstation'] = ($this->input->get('club') ?? '') == '1' ? true : false; + $data['external_account'] = NULL; + $data['auth_header_enable'] = $this->config->item('auth_header_enable') ?? false; + if ($data['auth_header_enable']) { + // expecting sso.php in the config folder + $this->config->load('sso', true, true); + } + $data['auth_header_allow_direct_login'] = $this->config->item('auth_header_allow_direct_login', 'sso') ?? true; + $data['auth_header_hide_password_field'] = $this->config->item('auth_header_hide_password_field', 'sso') ?? false; + $data['auth_header_locked_data_badge'] = $this->config->item('auth_header_locked_data_badge', 'sso') ?: 'IdP'; + $data['auth_header_locked_data_tip'] = $this->config->item('auth_header_locked_data_tip', 'sso') ?: __("Can't be changed. Manage this through your Identity Provider."); + $data['sso_claim_config'] = $this->config->item('auth_headers_claim_config', 'sso') ?: []; + // Get themes list $data['themes'] = $this->user_model->getThemes(); From 5f68295c9105d3cda94abb3b13768e0cd4861825 Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:25:58 -0500 Subject: [PATCH 39/48] Hard code expected values for manual user add --- application/controllers/User.php | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/application/controllers/User.php b/application/controllers/User.php index 154ea7841..ba801cebd 100644 --- a/application/controllers/User.php +++ b/application/controllers/User.php @@ -179,15 +179,11 @@ class User extends CI_Controller { $data['external_account'] = NULL; $data['auth_header_enable'] = $this->config->item('auth_header_enable') ?? false; - if ($data['auth_header_enable']) { - // expecting sso.php in the config folder - $this->config->load('sso', true, true); - } - $data['auth_header_allow_direct_login'] = $this->config->item('auth_header_allow_direct_login', 'sso') ?? true; - $data['auth_header_hide_password_field'] = $this->config->item('auth_header_hide_password_field', 'sso') ?? false; - $data['auth_header_locked_data_badge'] = $this->config->item('auth_header_locked_data_badge', 'sso') ?: 'IdP'; - $data['auth_header_locked_data_tip'] = $this->config->item('auth_header_locked_data_tip', 'sso') ?: __("Can't be changed. Manage this through your Identity Provider."); - $data['sso_claim_config'] = $this->config->item('auth_headers_claim_config', 'sso') ?: []; + $data['auth_header_allow_direct_login'] = true; + $data['auth_header_hide_password_field'] = false; + $data['auth_header_locked_data_badge'] = "Not Visible In Add UI"; + $data['auth_header_locked_data_tip'] = "You should not see this message"; + $data['sso_claim_config'] = []; // Get themes list $data['themes'] = $this->user_model->getThemes(); From 35d9e0060cc4e3644494603ce46fc1d8aeb6b1d9 Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:25:40 -0500 Subject: [PATCH 40/48] reorder user JWT update and maint check --- application/controllers/Header_auth.php | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index c932383ad..032a515eb 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -24,9 +24,9 @@ class Header_auth extends CI_Controller { } /** - * Authenticate using a trusted request header/JWT token. This endpoint is meant to be called by a reverse proxy that sits in front of Wavelog and handles the actual authentication (e.g. OAuth2 Proxy, Apache mod_auth_oidc, etc.). + * Authenticate using a JWT from a trusted request header. This endpoint is meant to be called by a reverse proxy that sits in front of Wavelog and handles the actual authentication (e.g. OAuth2 Proxy, Apache mod_auth_oidc, etc.). * The reverse proxy validates the user's session and forwards a JWT access token containing the user's identity and claims in a trusted HTTP header. This method decodes the token, verifies it, extracts the user information - * and logs the user in. Depending on configuration, it can also automatically create a local user account if one does not exist. + * and logs the user in. Depending on configuration, it can also automatically create a local user account if one does not exist, and update existing user data. * * For more information check out the documentation: https://docs.wavelog.org/admin-guide/configuration/third-party-authentication */ @@ -99,9 +99,6 @@ class Header_auth extends CI_Controller { $user = $query->row(); - // Update fields from JWT claims where override_on_update is enabled - $this->_update_user_from_claims($user->user_id, $mapped); - // Prevent clubstation direct login via header (mirrors User::login) if (!empty($user->clubstation) && $user->clubstation == 1) { $this->_sso_error(__("You can't login to a clubstation directly. Use your personal account instead.")); @@ -112,6 +109,15 @@ class Header_auth extends CI_Controller { $this->_sso_error(__("Sorry. This instance is currently in maintenance mode. Only administrators are currently allowed to log in.")); } + // Check if club station before update + // Don't update fields in maintenance mode + if (ENVIRONMENT !== 'maintenance') { + // Update fields from JWT claims where override_on_update is enabled + $this->_update_user_from_claims($user->user_id, $mapped); + } + + + // Establish session $this->user_model->update_session($user->user_id); $this->user_model->set_last_seen($user->user_id); From 6ec147bc83de8b5091dbffcfd2f17e28d81d209d Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:41:00 -0500 Subject: [PATCH 41/48] require username claim in JWT --- application/controllers/Header_auth.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 032a515eb..c510a90ec 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -259,6 +259,10 @@ class Header_auth extends CI_Controller { log_message('error', 'SSO Authentication: Missing email or callsign claim in access token.'); $this->_sso_error(); } + if (empty($mapped['user_name'])) { + log_message('error', 'SSO Authentication: Missing username claim in access token.'); + $this->_sso_error(); + } // $club_id = $this->config->item('auth_header_club_id', 'sso') ?: ''; // TODO: Add support to add a user to a clubstation From 84de1bf91227cd250ec848addbef761d56e0f4e3 Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:53:31 -0500 Subject: [PATCH 42/48] prevent elevate privileges with JWT claims --- application/models/User_model.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/application/models/User_model.php b/application/models/User_model.php index 305276b15..6fa727ef7 100644 --- a/application/models/User_model.php +++ b/application/models/User_model.php @@ -765,6 +765,10 @@ class User_Model extends CI_Model { // FUNCTION: update specific user fields from SSO claims (bypass privilege check, used during login flow) function update_sso_claims(int $user_id, array $fields): void { + // Cannot modify the following + $blocked = ['user_type', 'user_password', 'clubstation', 'external_account', 'login_attempts', 'created_at', 'modified_at', 'last_modified', 'last_seen', 'reset_password_date', 'reset_password_code']; + $fields = array_diff_key($fields, array_flip($blocked)); + $this->db->where('user_id', $user_id); $this->db->update('users', $fields); } From 5eadc2a8a705470df4602ddec143a5ee33995207 Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Thu, 19 Mar 2026 01:00:50 -0500 Subject: [PATCH 43/48] Change to allow list for JWT claim updates --- application/models/User_model.php | 50 +++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/application/models/User_model.php b/application/models/User_model.php index 6fa727ef7..0c4ef5cdf 100644 --- a/application/models/User_model.php +++ b/application/models/User_model.php @@ -215,6 +215,7 @@ class User_Model extends CI_Model { // !!!!!!!!!!!!!!!! // !! IMPORTANT NOTICE: Please inform DJ7NT and/or DF2ET when adding/removing/changing parameters here. // !! Also make sure you modify Header_auth::_create_user accordingly, otherwise SSO user creation will break. + // !! Also modify User_model::update_sso_claims with attributes that can be modified by IdP // !!!!!!!!!!!!!!!! function add($username, $password, $email, $type, $firstname, $lastname, $callsign, $locator, $timezone, $measurement, $dashboard_map, $user_date_format, $user_stylesheet, $user_qth_lookup, $user_sota_lookup, $user_wwff_lookup, @@ -765,9 +766,52 @@ class User_Model extends CI_Model { // FUNCTION: update specific user fields from SSO claims (bypass privilege check, used during login flow) function update_sso_claims(int $user_id, array $fields): void { - // Cannot modify the following - $blocked = ['user_type', 'user_password', 'clubstation', 'external_account', 'login_attempts', 'created_at', 'modified_at', 'last_modified', 'last_seen', 'reset_password_date', 'reset_password_code']; - $fields = array_diff_key($fields, array_flip($blocked)); + // Only modify the following + $allowed = [ + 'user_name', + 'user_password', + 'user_email', + 'user_callsign', + 'user_locator', + 'user_firstname', + 'user_lastname', + 'user_timezone', + 'user_lotw_name', + 'user_lotw_password', + 'user_eqsl_name', + 'user_eqsl_password', + 'user_eqsl_qth_nickname', + 'active_station_logbook', + 'user_language', + 'user_clublog_name', + 'user_clublog_password', + 'user_clublog_callsign', + 'user_measurement_base', + 'user_date_format', + 'user_stylesheet', + 'user_sota_lookup', + 'user_wwff_lookup', + 'user_pota_lookup', + 'user_qth_lookup', + 'user_show_notes', + 'user_column1', + 'user_column2', + 'user_column3', + 'user_column4', + 'user_column5', + 'user_show_profile_image', + 'user_previous_qsl_type', + 'user_amsat_status_upload', + 'user_mastodon_url', + 'user_default_band', + 'user_default_confirmation', + 'user_quicklog_enter', + 'user_quicklog', + 'user_qso_end_times', + 'winkey', + 'slug' + ]; + $fields = array_intersect_key($fields, array_flip($allowed)); $this->db->where('user_id', $user_id); $this->db->update('users', $fields); From beb383e6dddfbe6a3b9346cff02d6cc543aaae5c Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Thu, 19 Mar 2026 01:03:34 -0500 Subject: [PATCH 44/48] Remove user_password from JWT allow --- application/models/User_model.php | 1 - 1 file changed, 1 deletion(-) diff --git a/application/models/User_model.php b/application/models/User_model.php index 0c4ef5cdf..d4df2c4db 100644 --- a/application/models/User_model.php +++ b/application/models/User_model.php @@ -769,7 +769,6 @@ class User_Model extends CI_Model { // Only modify the following $allowed = [ 'user_name', - 'user_password', 'user_email', 'user_callsign', 'user_locator', From f27d109718d4b198979baef2080276806fb295cd Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Thu, 19 Mar 2026 09:30:31 +0100 Subject: [PATCH 45/48] little ui correction --- application/views/user/login.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/views/user/login.php b/application/views/user/login.php index ddfbd9125..9f8ce9d1b 100644 --- a/application/views/user/login.php +++ b/application/views/user/login.php @@ -88,11 +88,11 @@
- + -
+
From 271cfd54ae3b87791db7e4d2a6f5c6db7342cf54 Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Thu, 19 Mar 2026 09:38:36 +0100 Subject: [PATCH 46/48] ignore .env files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index dffc0f7b5..ab3d577da 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ docker-compose.yml .demo .htaccess .user.ini +.env From d3f22710aaaecc8022a50ffe1281578700285abd Mon Sep 17 00:00:00 2001 From: HB9HIL Date: Thu, 19 Mar 2026 16:42:52 +0100 Subject: [PATCH 47/48] fixed documentation links --- application/config/config.sample.php | 2 +- application/config/sso.sample.php | 2 +- application/controllers/Header_auth.php | 2 +- install/config/config.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/application/config/config.sample.php b/application/config/config.sample.php index 9401280a1..35ea0bffb 100644 --- a/application/config/config.sample.php +++ b/application/config/config.sample.php @@ -117,7 +117,7 @@ $config['auth_level'][99] = 'Administrator'; | Enable SSO support via a trusted HTTP header containing a JWT access token. | When enabled, a sso.php config file is required (see sso.sample.php). | -| Documentation: https://docs.wavelog.org/admin-guide/configuration/third-party-authentication +| Documentation: https://docs.wavelog.org/admin-guide/configuration/thirdparty-authentication/ */ $config['auth_header_enable'] = false; diff --git a/application/config/sso.sample.php b/application/config/sso.sample.php index 13e67127b..7ed817537 100644 --- a/application/config/sso.sample.php +++ b/application/config/sso.sample.php @@ -8,7 +8,7 @@ * Copy this file to sso.php and adjust the values to your environment. * This file is loaded automatically when auth_header_enable = true in config.php. * - * Documentation: https://docs.wavelog.org/admin-guide/configuration/third-party-authentication + * Documentation: https://docs.wavelog.org/admin-guide/configuration/thirdparty-authentication/ */ /** diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index c510a90ec..790733efb 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -28,7 +28,7 @@ class Header_auth extends CI_Controller { * The reverse proxy validates the user's session and forwards a JWT access token containing the user's identity and claims in a trusted HTTP header. This method decodes the token, verifies it, extracts the user information * and logs the user in. Depending on configuration, it can also automatically create a local user account if one does not exist, and update existing user data. * - * For more information check out the documentation: https://docs.wavelog.org/admin-guide/configuration/third-party-authentication + * For more information check out the documentation: https://docs.wavelog.org/admin-guide/configuration/thirdparty-authentication/ */ public function login() { // Guard: feature must be enabled diff --git a/install/config/config.php b/install/config/config.php index c64e7c6d1..6eeccef22 100644 --- a/install/config/config.php +++ b/install/config/config.php @@ -117,7 +117,7 @@ $config['auth_level'][99] = 'Administrator'; | Enable SSO support via a trusted HTTP header containing a JWT access token. | When enabled, a sso.php config file is required (see sso.sample.php). | -| Documentation: https://docs.wavelog.org/admin-guide/configuration/third-party-authentication +| Documentation: https://docs.wavelog.org/admin-guide/configuration/thirdparty-authentication/ */ $config['auth_header_enable'] = false; From df355ebd7d4e7516ac076619e9bf5a6a0a6cf782 Mon Sep 17 00:00:00 2001 From: HadleySo <71105018+HadleySo@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:33:17 -0500 Subject: [PATCH 48/48] Changing JWT low-security to alg none check only. DOCS ok --- application/controllers/Header_auth.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/controllers/Header_auth.php b/application/controllers/Header_auth.php index 790733efb..7e48e1e72 100644 --- a/application/controllers/Header_auth.php +++ b/application/controllers/Header_auth.php @@ -202,8 +202,8 @@ class Header_auth extends CI_Controller { return null; } - $alg = $header['alg'] ?? ''; - if (!in_array($alg, ['RS256', 'RS384', 'RS512', 'ES256', 'ES384'], true)) { + $alg = $header['alg'] ?? 'none'; + if ($alg == "none") { log_message('error', 'SSO Authentication: Algorithm "' . $alg . '" is not allowed.'); return null; }