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; /* |--------------------------------------------------------------------------