refactoring

This commit is contained in:
HB9HIL
2026-03-18 15:26:31 +01:00
parent 2e4594cf5f
commit 26340f3ca1
9 changed files with 554 additions and 251 deletions

4
.gitignore vendored
View File

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

View File

@@ -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

View File

@@ -0,0 +1,202 @@
<?php defined('BASEPATH') or exit('No direct script access allowed');
/**
*--------------------------------------------------------------------------
* SSO / Header Authentication Settings
*--------------------------------------------------------------------------
*
* 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
*/
/**
* --------------------------------------------------------------------------
* Debug Mode
* --------------------------------------------------------------------------
*
* When enabled, the decoded JWT token will be logged on every login attempt.
* This is useful for troubleshooting claim mapping and token forwarding issues.
* Requires 'log_threshold' to be set to 2 (debug) or higher in config.php.
*
* DON'T FORGET TO DISABLE THIS IN PRODUCTION ENVIRONMENTS, AS JWT TOKENS MAY CONTAIN SENSITIVE INFORMATION!
*/
$config['auth_header_debug_jwt'] = false;
/**
*--------------------------------------------------------------------------
* Automatic User Creation
*--------------------------------------------------------------------------
*
* When enabled, Wavelog will automatically create a local user account the
* first time a valid JWT token is received for an unknown user. The account
* is created with a random password.
*
* Disable this if you want to provision users manually before they can log in.
* It's recommended to keep this enabled in most cases and do access control
* management through your identity provider.
*
* Recommendation: true
*/
$config['auth_header_create'] = true;
/**
*--------------------------------------------------------------------------
* Allow Direct Login
*--------------------------------------------------------------------------
*
* When SSO is enabled, the standard Wavelog login form can be hidden. Default
* behavior is to show the login form alongside the SSO button, allowing users to choose.
* Set this to false to hide the login form and force users to authenticate exclusively through SSO.
*
* Recommendation: depends on your needs. You can disable direct login for a more seamless SSO experience, but enable
* it if you want to allow admins or users without SSO access to log in through the standard form.
*/
$config['auth_header_allow_direct_login'] = false;
/**
* --------------------------------------------------------------------------
* Hide Password Field in User Profile
* --------------------------------------------------------------------------
*
* When enabled, the password field in the user profile edit page is hidden for users authenticated through SSO.
* While there are legit use cases for allowing users to set a local password (e.g. as a backup login method),
* hiding the password field can help avoid confusion and reinforce the idea that the account is managed through the identity provider.
*/
$config['auth_header_hide_password_field'] = true;
/**
*--------------------------------------------------------------------------
* Access Token Header
*--------------------------------------------------------------------------
*
* The name of the HTTP header that contains the JWT access token forwarded
* by your reverse proxy or identity provider (e.g. OAuth2 Proxy, mod_auth_oidc).
*
* Note: PHP converts HTTP headers to uppercase and replaces hyphens with
* underscores, prefixed with HTTP_. For example, the header
* "X-Forwarded-Access-Token" becomes "HTTP_X_FORWARDED_ACCESS_TOKEN".
*/
$config['auth_header_accesstoken'] = "HTTP_X_FORWARDED_ACCESS_TOKEN";
/**
*--------------------------------------------------------------------------
* SSO Login Button Text
*--------------------------------------------------------------------------
*
* The label shown on the SSO login button on the Wavelog login page.
*/
$config['auth_header_text'] = "Login with SSO";
/**
*--------------------------------------------------------------------------
* Logout URL
*--------------------------------------------------------------------------
*
* URL to redirect the user to after logging out of Wavelog. Leave empty
* to redirect to the standard Wavelog login page while keeping the SSO session.
* This is default since logging out of Wavelog does not necessarily mean logging out of the identity provider.
*
* When using OAuth2 Proxy in front of Keycloak, the URL must first hit the
* OAuth2 Proxy sign-out endpoint, which then redirects to the Keycloak
* end-session endpoint. The whitelist_domains of OAuth2 Proxy must include
* the Keycloak domain.
*
* Example (OAuth2 Proxy + Keycloak):
* $config['auth_header_url_logout'] = 'https://log.example.org/oauth2/sign_out'
* . '?rd=https://auth.example.org/realms/example/protocol/openid-connect/logout';
*
* Recommendation: Keep it empty
*/
$config['auth_header_url_logout'] = "";
/**
*--------------------------------------------------------------------------
* JWKS URI (Signature Verification)
*--------------------------------------------------------------------------
*
* URL of the JWKS endpoint of your identity provider. Wavelog uses this to
* fetch the public keys and cryptographically verify the JWT signature on
* every login. This is strongly recommended in production.
*
* Leave empty to skip signature verification (legacy / trusted-proxy mode).
* Only disable verification if your reverse proxy fully manages authentication
* and you fully trust the forwarded token without additional validation.
*
* Example (Keycloak):
* $config['auth_header_jwks_uri'] = 'https://auth.example.org/realms/example/protocol/openid-connect/certs';
*
* Recommendation: Set this to the JWKS endpoint of your identity provider for enhanced security.
*/
$config['auth_header_jwks_uri'] = "";
/**
*--------------------------------------------------------------------------
* JWT Claim Mapping
*--------------------------------------------------------------------------
*
* Maps Wavelog database fields to JWT claim names. Each key is a column
* name in the users table. The value is an array with the following options:
*
* 'claim' => 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
],
];

View File

@@ -1,5 +1,12 @@
<?php if (!defined('BASEPATH')) exit('No direct script access allowed');
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
require_once APPPATH . '../src/jwt/src/JWT.php';
require_once APPPATH . '../src/jwt/src/Key.php';
require_once APPPATH . '../src/jwt/src/JWK.php';
/*
Handles header based authentication
@@ -9,19 +16,28 @@ class Header_auth extends CI_Controller {
public function __construct() {
parent::__construct();
if ($this->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) {

View File

@@ -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 <b>'.$this->input->post('user_name', true).'</b> 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');

View File

@@ -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() {

View File

@@ -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(

View File

@@ -53,18 +53,41 @@
<div class="card">
<div class="card-header"><?= __("Account"); ?></div>
<div class="card-body">
<?php
// Returns true if the field is managed by the IdP and the user cannot change it
$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 = '<span class="badge bg-secondary ms-1" data-bs-toggle="tooltip" title="' . __("Can't be changed. Manage this through your Identity Provider.") . '"><i class="fa fa-lock"></i> IdP</span>';
?>
<div class="mb-3">
<label><?= __("Username"); ?></label>
<input class="form-control" type="text" name="user_name" value="<?php if(isset($user_name)) { echo $user_name; } ?>" <?php if (isset($user_name) && $user_name == 'demo' && file_exists('.demo') && $this->session->userdata('user_type') !== '99') { echo 'disabled'; } ?> />
<?php if(isset($username_error)) { echo "<small class=\"badge bg-danger\">".$username_error."</small>"; } ?>
<label>
<?= __("Username"); ?>
<?php if ($idp_locked('user_name')) { echo $idp_badge; } ?>
</label>
<?php if ($idp_locked('user_name')) { ?>
<input class="form-control-plaintext fw-bold" name="user_name" value="<?= htmlspecialchars($user_name ?? '') ?>" readonly/>
<?php } else { ?>
<input class="form-control" type="text" name="user_name" value="<?php if(isset($user_name)) { echo $user_name; } ?>" <?php if (isset($user_name) && $user_name == 'demo' && file_exists('.demo') && $this->session->userdata('user_type') !== '99') { echo 'disabled'; } ?> />
<?php if(isset($username_error)) { echo "<small class=\"badge bg-danger\">".$username_error."</small>"; } ?>
<?php } ?>
</div>
<div class="mb-3">
<label><?= __("Email Address"); ?></label>
<input class="form-control" type="text" name="user_email" value="<?php if(isset($user_email)) { echo $user_email; } ?>" />
<?php if(isset($email_error)) { echo "<small class=\"badge bg-danger\">".$email_error."</small>"; } ?>
<label>
<?= __("Email Address"); ?>
<?php if ($idp_locked('user_email')) { echo $idp_badge; } ?>
</label>
<?php if ($idp_locked('user_email')) { ?>
<input class="form-control-plaintext fw-bold" name="user_email" value="<?= htmlspecialchars($user_email ?? '') ?>" readonly/>
<?php } else { ?>
<input class="form-control" type="text" name="user_email" value="<?php if(isset($user_email)) { echo $user_email; } ?>" />
<?php if(isset($email_error)) { echo "<small class=\"badge bg-danger\">".$email_error."</small>"; } ?>
<?php } ?>
</div>
<?php if (!$auth_header_enable || ($auth_header_allow_direct_login && (!$external_account || !$auth_header_hide_password_field))){ ?>
<div class="mb-3">
<label><?= __("Password"); ?></label>
<div class="input-group">
@@ -79,6 +102,7 @@
} else if (!isset($user_add)) { ?>
<?php } ?>
</div>
<?php } ?>
<hr/>
<div class="mb-3">
@@ -98,7 +122,7 @@
</select>
<?php } else {
$l = $this->config->item('auth_level');
echo $l[$user_type];
echo "<br><b>" . $l[$user_type] . "</b>";
}?>
<?php if ($clubstation) { ?>
<input type="hidden" name="clubstation" value="1" />
@@ -114,17 +138,29 @@
<div class="card-header"><?php if ($clubstation) { echo __("Callsign Owner"); } else { echo __("Personal");} ?></div>
<div class="card-body">
<div class="mb-3">
<label><?= __("First Name"); ?></label>
<input class="form-control" type="text" name="user_firstname" value="<?php if(isset($user_firstname)) { echo $user_firstname; } ?>" />
<?php if(isset($firstname_error)) { echo "<small class=\"badge bg-danger\">".$firstname_error."</small>"; } else { ?>
<?php } ?>
<label>
<?= __("First Name"); ?>
<?php if ($idp_locked('user_firstname')) { echo $idp_badge; } ?>
</label>
<?php if ($idp_locked('user_firstname')) { ?>
<input class="form-control-plaintext fw-bold" name="user_firstname" value="<?= htmlspecialchars($user_firstname ?? '') ?>" readonly/>
<?php } else { ?>
<input class="form-control" type="text" name="user_firstname" value="<?php if(isset($user_firstname)) { echo $user_firstname; } ?>" />
<?php if(isset($firstname_error)) { echo "<small class=\"badge bg-danger\">".$firstname_error."</small>"; } ?>
<?php } ?>
</div>
<div class="mb-3">
<label><?= __("Last Name"); ?></label>
<input class="form-control" type="text" name="user_lastname" value="<?php if(isset($user_lastname)) { echo $user_lastname; } ?>" />
<?php if(isset($lastname_error)) { echo "<small class=\"badge bg-danger\">".$lastname_error."</small>"; } else { ?>
<?php } ?>
<label>
<?= __("Last Name"); ?>
<?php if ($idp_locked('user_lastname')) { echo $idp_badge; } ?>
</label>
<?php if ($idp_locked('user_lastname')) { ?>
<input class="form-control-plaintext fw-bold" name="user_lastname" value="<?= htmlspecialchars($user_lastname ?? '') ?>" readonly/>
<?php } else { ?>
<input class="form-control" type="text" name="user_lastname" value="<?php if(isset($user_lastname)) { echo $user_lastname; } ?>" />
<?php if(isset($lastname_error)) { echo "<small class=\"badge bg-danger\">".$lastname_error."</small>"; } ?>
<?php } ?>
</div>
</div>
</div>
@@ -135,17 +171,29 @@
<div class="card-header"><?= __("Ham Radio"); ?></div>
<div class="card-body">
<div class="mb-3">
<label><?php if ($clubstation) { echo __("Special/Club Callsign"); } else { echo __("Callsign"); } ?></label>
<input class="form-control uppercase" type="text" name="user_callsign" pattern="^\S+$" value="<?php if(isset($user_callsign)) { echo $user_callsign; } ?>" />
<?php if(isset($callsign_error)) { echo "<small class=\"badge bg-danger\">".$callsign_error."</small>"; } else { ?>
<?php } ?>
<label>
<?php if ($clubstation) { echo __("Special/Club Callsign"); } else { echo __("Callsign"); } ?>
<?php if ($idp_locked('user_callsign')) { echo $idp_badge; } ?>
</label>
<?php if ($idp_locked('user_callsign')) { ?>
<input class="form-control-plaintext fw-bold uppercase" name="user_callsign" value="<?= htmlspecialchars($user_callsign ?? '') ?>" readonly/>
<?php } else { ?>
<input class="form-control uppercase" type="text" name="user_callsign" pattern="^\S+$" value="<?php if(isset($user_callsign)) { echo $user_callsign; } ?>" />
<?php if(isset($callsign_error)) { echo "<small class=\"badge bg-danger\">".$callsign_error."</small>"; } ?>
<?php } ?>
</div>
<div class="mb-3">
<label><?= __("Gridsquare"); ?></label>
<input class="form-control uppercase" type="text" name="user_locator" value="<?php if(isset($user_locator)) { echo $user_locator; } ?>" />
<?php if(isset($locator_error)) { echo "<small class=\"badge bg-danger\">".$locator_error."</small>"; } else { ?>
<?php } ?>
<label>
<?= __("Gridsquare"); ?>
<?php if ($idp_locked('user_locator')) { echo $idp_badge; } ?>
</label>
<?php if ($idp_locked('user_locator')) { ?>
<input class="form-control-plaintext fw-bold uppercase" name="user_locator" value="<?= htmlspecialchars($user_locator ?? '') ?>" readonly/>
<?php } else { ?>
<input class="form-control uppercase" type="text" name="user_locator" value="<?php if(isset($user_locator)) { echo $user_locator; } ?>" />
<?php if(isset($locator_error)) { echo "<small class=\"badge bg-danger\">".$locator_error."</small>"; } ?>
<?php } ?>
</div>
</div>
</div>

View File

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