From 2985d8554aca8d8c81f8a8ca214e483a4ba1de9c Mon Sep 17 00:00:00 2001 From: dakriy Date: Sat, 7 Dec 2024 02:21:57 -0800 Subject: [PATCH] feat(legacy): trused header sso auth (#3095) ### Description Allows LibreTime to support Trusted Header SSO Authentication. **This is a new feature**: Yes **I have updated the documentation to reflect these changes**: Yes ### Testing Notes **What I did:** I spun up an Authelia/Traefik pair and configured them to protect LibreTime according to Authelia's documentation, I then tested that you could log in via the trusted headers, and tested that old methods of authentication were not affected. **How you can replicate my testing:** Using the following `docker-compose.yml` file ```yml services: postgres: image: postgres:15 networks: - internal volumes: - postgres_data:/var/lib/postgresql/data environment: POSTGRES_USER: ${POSTGRES_USER:-libretime} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-libretime} # Change me ! healthcheck: test: pg_isready -U libretime rabbitmq: image: rabbitmq:3.13-alpine networks: - internal environment: RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_DEFAULT_VHOST:-/libretime} RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER:-libretime} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS:-libretime} # Change me ! healthcheck: test: nc -z 127.0.0.1 5672 playout: image: ghcr.io/libretime/libretime-playout:${LIBRETIME_VERSION:-latest} networks: - internal init: true ulimits: nofile: 1024 depends_on: - rabbitmq volumes: - ${LIBRETIME_CONFIG_FILEPATH:-./config.yml}:/etc/libretime/config.yml:ro - libretime_playout:/app environment: LIBRETIME_GENERAL_PUBLIC_URL: http://nginx:8080 liquidsoap: image: ghcr.io/libretime/libretime-playout:${LIBRETIME_VERSION:-latest} networks: - internal command: /usr/local/bin/libretime-liquidsoap init: true ulimits: nofile: 1024 ports: - 8001:8001 - 8002:8002 depends_on: - rabbitmq volumes: - ${LIBRETIME_CONFIG_FILEPATH:-./config.yml}:/etc/libretime/config.yml:ro - libretime_playout:/app environment: LIBRETIME_GENERAL_PUBLIC_URL: http://nginx:8080 analyzer: image: ghcr.io/libretime/libretime-analyzer:${LIBRETIME_VERSION:-latest} networks: - internal init: true ulimits: nofile: 1024 depends_on: - rabbitmq volumes: - ${LIBRETIME_CONFIG_FILEPATH:-./config.yml}:/etc/libretime/config.yml:ro - libretime_storage:/srv/libretime environment: LIBRETIME_GENERAL_PUBLIC_URL: http://nginx:8080 worker: image: ghcr.io/libretime/libretime-worker:${LIBRETIME_VERSION:-latest} networks: - internal init: true ulimits: nofile: 1024 depends_on: - rabbitmq volumes: - ${LIBRETIME_CONFIG_FILEPATH:-./config.yml}:/etc/libretime/config.yml:ro environment: LIBRETIME_GENERAL_PUBLIC_URL: http://nginx:8080 api: image: ghcr.io/libretime/libretime-api:${LIBRETIME_VERSION:-latest} networks: - internal init: true ulimits: nofile: 1024 depends_on: - postgres - rabbitmq volumes: - ${LIBRETIME_CONFIG_FILEPATH:-./config.yml}:/etc/libretime/config.yml:ro - libretime_storage:/srv/libretime legacy: image: ghcr.io/libretime/libretime-legacy:${LIBRETIME_VERSION:-latest} networks: - internal init: true ulimits: nofile: 1024 depends_on: - postgres - rabbitmq volumes: - ${LIBRETIME_CONFIG_FILEPATH:-./config.yml}:/etc/libretime/config.yml:ro - libretime_assets:/var/www/html - libretime_storage:/srv/libretime nginx: image: nginx networks: - internal - net ports: - 8080:8080 depends_on: - legacy volumes: - libretime_assets:/var/www/html:ro - libretime_storage:/srv/libretime:ro - ${NGINX_CONFIG_FILEPATH:-./nginx.conf}:/etc/nginx/conf.d/default.conf:ro labels: - 'traefik.enable=true' - 'traefik.docker.network=libretime_net' - 'traefik.http.routers.libretime.rule=Host(`libretime.example.com`)' - 'traefik.http.routers.libretime.entrypoints=https' - 'traefik.http.routers.libretime.tls=true' - 'traefik.http.routers.libretime.tls.options=default' - 'traefik.http.routers.libretime.middlewares=authelia@docker' - 'traefik.http.services.libretime.loadbalancer.server.port=8080' icecast: image: ghcr.io/libretime/icecast:2.4.4 networks: - internal ports: - 8000:8000 environment: ICECAST_SOURCE_PASSWORD: ${ICECAST_SOURCE_PASSWORD:-hackme} # Change me ! ICECAST_ADMIN_PASSWORD: ${ICECAST_ADMIN_PASSWORD:-hackme} # Change me ! ICECAST_RELAY_PASSWORD: ${ICECAST_RELAY_PASSWORD:-hackme} # Change me ! traefik: image: traefik:v2.11.12 container_name: traefik volumes: - /var/run/docker.sock:/var/run/docker.sock networks: - net labels: - 'traefik.enable=true' - 'traefik.http.routers.api.rule=Host(`traefik.example.com`)' - 'traefik.http.routers.api.entrypoints=https' - 'traefik.http.routers.api.service=api@internal' - 'traefik.http.routers.api.tls=true' - 'traefik.http.routers.api.tls.options=default' - 'traefik.http.routers.api.middlewares=authelia@docker' ports: - '80:80' - '443:443' command: - '--api' - '--providers.docker=true' - '--providers.docker.exposedByDefault=false' - '--entrypoints.http=true' - '--entrypoints.http.address=:80' - '--entrypoints.http.http.redirections.entrypoint.to=https' - '--entrypoints.http.http.redirections.entrypoint.scheme=https' - '--entrypoints.https=true' - '--entrypoints.https.address=:443' - '--log=true' - '--log.level=DEBUG' authelia: image: authelia/authelia container_name: authelia networks: - net volumes: - ./authelia:/config labels: - 'traefik.enable=true' - 'traefik.http.routers.authelia.rule=Host(`auth.example.com`)' - 'traefik.http.routers.authelia.entrypoints=https' - 'traefik.http.routers.authelia.tls=true' - 'traefik.http.routers.authelia.tls.options=default' - 'traefik.http.middlewares.authelia.forwardauth.address=http://authelia:9091/api/authz/forward-auth' # yamllint disable-line rule:line-length - 'traefik.http.middlewares.authelia.forwardauth.trustForwardHeader=true' - 'traefik.http.middlewares.authelia.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email' # yamllint disable-line rule:line-length - 'traefik.http.services.authelia.loadbalancer.server.port=9091' restart: unless-stopped environment: - TZ=America/Los_Angeles volumes: postgres_data: {} libretime_storage: {} libretime_assets: {} libretime_playout: {} networks: internal: net: ``` The following libretime dev config modification: ```yml general: public_url: https://libretime.example.com auth: LibreTime_Auth_Adaptor_Header header_auth: group_map: host: lt-host program_manager: lt-pm admin: lt-admin superadmin: lt-superadmin ``` And the following authelia config file: ```yml --- ############################################################### # Authelia configuration # ############################################################### server: address: 'tcp://:9091' buffers: read: 16384 write: 16384 log: level: 'debug' totp: issuer: 'authelia.com' identity_validation: reset_password: jwt_secret: 'a_very_important_secret' authentication_backend: file: path: '/config/users_database.yml' access_control: default_policy: 'deny' rules: - domain: 'traefik.example.com' policy: 'one_factor' - domain: 'libretime.example.com' policy: 'one_factor' session: secret: 'insecure_session_secret' cookies: - name: 'authelia_session' domain: 'example.com' # Should match whatever your root protected domain is authelia_url: 'https://auth.example.com' expiration: '1 hour' # 1 hour inactivity: '5 minutes' # 5 minutes regulation: max_retries: 3 find_time: '2 minutes' ban_time: '5 minutes' storage: encryption_key: 'you_must_generate_a_random_string_of_more_than_twenty_chars_and_configure_this' local: path: '/config/db.sqlite3' notifier: filesystem: filename: '/config/notification.txt' ... ``` And the following authelia users database: ```yml --- ############################################################### # Users Database # ############################################################### # This file can be used if you do not have an LDAP set up. # List of users users: test: disabled: false displayname: "First Last" password: "$argon2id$v=19$m=16,t=2,p=1$SWVVVzcySlRLUEFkWWh2eA$qPs1ZmzmDXR/9WckDzIN9Q" email: test@example.com groups: - admins - dev - lt-admin ... ``` add the following entries to your `hosts` file: ``` 127.0.0.1 traefik.example.com 127.0.0.1 auth.example.com 127.0.0.1 libretime.example.com ``` Then visit `libretime.example.com` in your browser, and login as the user `test` with password of `password`. You should then be taken to the LibreTime homepage, and when you click on login, you should be automatically logged in. ### **Links** https://www.authelia.com/integration/trusted-header-sso/introduction/ https://doc.traefik.io/traefik/middlewares/http/forwardauth/ --------- Co-authored-by: Kyle Robbertze --- docs/admin-manual/custom-authentication.md | 65 ++++++++ .../application/auth/adapters/HeaderAuth.php | 148 ++++++++++++++++++ legacy/application/configs/conf.php | 16 ++ .../controllers/LoginController.php | 55 +++---- 4 files changed, 253 insertions(+), 31 deletions(-) create mode 100644 legacy/application/auth/adapters/HeaderAuth.php diff --git a/docs/admin-manual/custom-authentication.md b/docs/admin-manual/custom-authentication.md index cadd9c6f8..a09f6b389 100644 --- a/docs/admin-manual/custom-authentication.md +++ b/docs/admin-manual/custom-authentication.md @@ -113,3 +113,68 @@ general: ``` You should now be able to use your FreeIPA credentials to log in to LibreTime. + +## Setup Header Authentication + +If you have an SSO system that supports trusted SSO header authentication such as [Authelia](https://www.authelia.com/), +you can configure LibreTime to login users based on those trusted headers. + +This allows users to only need to log in once on the SSO system and not need to log in again. It also allows LibreTime +to indirectly support other authentication mechanisms such as OAuth2. + +This ONLY affects Legacy/Legacy API auth and does NOT affect API V2 auth. + +### Configure Headers + +LibreTime needs to know what headers are sent, and what information is available to it. You can also +setup a predefined group mapping so users are automatically granted the desired permissions. + +This configuration is in `/etc/libretime/config.yml`. The following is an example configuration for an SSO service +that does the following: + +- Sends the username in the `Remote-User` HTTP header. +- Sends the email in the `Remote-Email` HTTP header. +- Sends the name in the `Remote-Name` HTTP header. Example `John Doe` +- Sends the comma delimited groups in the `Remote-Groups` HTTP header. Example `group 1,lt-admin,group2` +- Has an IP of `10.0.0.34` (not required). When not provided it is not checked. +- Users with the `lt-host` group should get host privileges. +- Users with the `lt-admin` group should get admin privileges. +- Users with the `lt-pm` group should get program manager privileges. +- Users with the `lt-superadmin` group should get super admin privileges. +- All other users should get guest privileges. + +```yml +header_auth: + user_header: Remote-User # This is the default and could be omitted + groups_header: Remote-Groups # This is the default and could be omitted + email_header: Remote-Email # This is the default and could be omitted + name_header: Remote-Name # This is the default and could be omitted + proxy_ip: 10.0.0.34 + group_map: + host: lt-host + program_manager: lt-pm + admin: lt-admin + superadmin: lt-superadmin +``` + +If the `user_header` is not found in the request, users will be kicked to the login page +with a message that their username/password is invalid and will not be able to log in. When `proxy_ip` is provided +it will check that the request is coming from the correct proxy before doing the login. This prevents users who have +internal network access from being able to login as whoever they want in LibreTime. + +::: warning + +If `proxy_ip` is not provided any user on the internal network can log in as any user in LibreTime. + +::: + +### Enable Header authentication + +After everything is set up properly you can enable header auth in `config.yml`: + +```yml +general: + auth: LibreTime_Auth_Adaptor_Header +``` + +You should now be automatically logged into LibreTime when you click the `Login` button. diff --git a/legacy/application/auth/adapters/HeaderAuth.php b/legacy/application/auth/adapters/HeaderAuth.php new file mode 100644 index 000000000..7bf2a0b5b --- /dev/null +++ b/legacy/application/auth/adapters/HeaderAuth.php @@ -0,0 +1,148 @@ +getHeaderValueOf($userHeader); + + if ($userLogin == null) { + return new Zend_Auth_Result(Zend_Auth_Result::FAILURE, null); + } + + $subj = CcSubjsQuery::create()->findOneByDbLogin($userLogin); + + if ($subj == null) { + $user = new Application_Model_User(''); + $user->setPassword(''); + $user->setLogin($userLogin); + } else { + $user = new Application_Model_User($subj->getDbId()); + } + + $name = $this->getHeaderValueOf($nameHeader); + + $user->setEmail($this->getHeaderValueOf($emailHeader)); + $user->setFirstName($this->getFirstName($name) ?? ''); + $user->setLastName($this->getLastName($name) ?? ''); + $user->setType($this->getUserType($this->getHeaderValueOf($groupsHeader))); + $user->save(); + $this->user = $user; + + return new Zend_Auth_Result(Zend_Auth_Result::SUCCESS, $user); + } + + private function getUserType(?string $groups): string + { + if ($groups == null) { + return UTYPE_GUEST; + } + + $groups = array_map(fn ($group) => trim($group), explode(',', $groups)); + + $superAdminGroup = Config::get('header_auth.group_map.superadmin'); + if (in_array($superAdminGroup, $groups)) { + return UTYPE_SUPERADMIN; + } + + $adminGroup = Config::get('header_auth.group_map.admin'); + if (in_array($adminGroup, $groups)) { + return UTYPE_ADMIN; + } + + $programManagerGroup = Config::get('header_auth.group_map.program_manager'); + if (in_array($programManagerGroup, $groups)) { + return UTYPE_PROGRAM_MANAGER; + } + + $hostGroup = Config::get('header_auth.group_map.host'); + if (in_array($hostGroup, $groups)) { + return UTYPE_HOST; + } + + return UTYPE_GUEST; + } + + private function getFirstName(?string $name): ?string + { + if ($name == null) { + return null; + } + + $result = explode(' ', $name, 2); + + return $result[0]; + } + + private function getLastName(?string $name): ?string + { + if ($name == null) { + return null; + } + + $result = explode(' ', $name, 2); + + return end($result); + } + + private function getHeaderValueOf(string $httpHeader): ?string + { + // Normalize the header name to match server's format + $normalizedHeader = 'HTTP_' . strtoupper(str_replace('-', '_', $httpHeader)); + + return $_SERVER[$normalizedHeader] ?? null; + } + + // Needed for zend auth adapter + + private Application_Model_User $user; + + public function setIdentity($username) + { + return $this; + } + + public function setCredential($password) + { + return $this; + } + + /** + * return dummy object for internal auth handling. + * + * we need to build a dummpy object since the auth layer knows nothing about the db + * + * @param null $returnColumns + * @param null $omitColumns + * + * @return stdClass + */ + public function getResultRowObject($returnColumns = null, $omitColumns = null) + { + $o = new stdClass(); + $o->id = $this->user->getId(); + $o->username = $this->user->getLogin(); + $o->password = $this->user->getPassword(); + $o->real_name = implode(' ', [$this->user->getFirstName(), $this->user->getLastName()]); + $o->type = $this->user->getType(); + $o->login = $this->user->getLogin(); + + return $o; + } +} diff --git a/legacy/application/configs/conf.php b/legacy/application/configs/conf.php index 229b85d08..e429ad609 100644 --- a/legacy/application/configs/conf.php +++ b/legacy/application/configs/conf.php @@ -99,6 +99,22 @@ class Schema implements ConfigurationInterface /**/->scalarNode('filter_field')->end() ->end()->end() + // Header Auth Schema + ->arrayNode('header_auth')->children() + /**/->scalarNode('user_header')->defaultValue('Remote-User')->end() + /**/->scalarNode('groups_header')->defaultValue('Remote-Groups')->end() + /**/->scalarNode('email_header')->defaultValue('Remote-Email')->end() + /**/->scalarNode('name_header')->defaultValue('Remote-Name')->end() + /**/->scalarNode('proxy_ip')->end() + /**/->scalarNode('locale')->defaultValue('en-US')->end() + /**/->arrayNode('group_map')->children() + /* */->scalarNode('host')->end() + /* */->scalarNode('program_manager')->end() + /* */->scalarNode('admin')->end() + /* */->scalarNode('superadmin')->end() + /**/->end()->end() + ->end()->end() + // Playout schema ->arrayNode('playout') /**/->ignoreExtraKeys() diff --git a/legacy/application/controllers/LoginController.php b/legacy/application/controllers/LoginController.php index 387f34e02..2d7469292 100644 --- a/legacy/application/controllers/LoginController.php +++ b/legacy/application/controllers/LoginController.php @@ -44,44 +44,37 @@ class LoginController extends Zend_Controller_Action $form = new Application_Form_Login(); - $message = _('Please enter your username and password.'); + $authAdapter = Application_Model_Auth::getAuthAdapter(); - if ($request->isPost()) { - // Open the session for writing, because we close it for writing by default in Bootstrap.php as an optimization. - // session_start(); + if ($authAdapter instanceof LibreTime_Auth_Adaptor_Header || ($request->isPost() && $form->isValid($request->getPost()))) { + // get the username and password from the form + $username = $form->getValue('username'); + $password = $form->getValue('password'); + $locale = $form->getValue('locale'); - if ($form->isValid($request->getPost())) { - // get the username and password from the form - $username = $form->getValue('username'); - $password = $form->getValue('password'); - $locale = $form->getValue('locale'); + // pass to the adapter the submitted username and password + $authAdapter->setIdentity($username) + ->setCredential($password); - $authAdapter = Application_Model_Auth::getAuthAdapter(); + $result = $auth->authenticate($authAdapter); + if ($result->isValid()) { + Zend_Session::regenerateId(); + // all info about this user from the login table omit only the password + $userInfo = $authAdapter->getResultRowObject(null, 'password'); - // pass to the adapter the submitted username and password - $authAdapter->setIdentity($username) - ->setCredential($password); + // the default storage is a session with namespace Zend_Auth + $authStorage = $auth->getStorage(); + $authStorage->write($userInfo); - $result = $auth->authenticate($authAdapter); - if ($result->isValid()) { - Zend_Session::regenerateId(); - // all info about this user from the login table omit only the password - $userInfo = $authAdapter->getResultRowObject(null, 'password'); + Application_Model_LoginAttempts::resetAttempts($_SERVER['REMOTE_ADDR']); + Application_Model_Subjects::resetLoginAttempts($username); - // the default storage is a session with namespace Zend_Auth - $authStorage = $auth->getStorage(); - $authStorage->write($userInfo); + // set the user locale in case user changed it in when logging in + Application_Model_Preference::SetUserLocale($locale); - Application_Model_LoginAttempts::resetAttempts($_SERVER['REMOTE_ADDR']); - Application_Model_Subjects::resetLoginAttempts($username); - - // set the user locale in case user changed it in when logging in - Application_Model_Preference::SetUserLocale($locale); - - $this->_redirect('showbuilder'); - } else { - $form = $this->loginError($username); - } + $this->_redirect('showbuilder'); + } else { + $form = $this->loginError($username); } }