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 <paddatrapper@users.noreply.github.com>
This commit is contained in:
parent
f709c5026d
commit
2985d8554a
|
@ -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.
|
||||
|
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Auth adaptor for basic header authentication.
|
||||
*/
|
||||
class LibreTime_Auth_Adaptor_Header implements Zend_Auth_Adapter_Interface
|
||||
{
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function authenticate(): Zend_Auth_Result
|
||||
{
|
||||
$trustedIp = Config::get('header_auth.proxy_ip');
|
||||
if ($trustedIp != null && $_SERVER['REMOTE_ADDR'] != trim($trustedIp)) {
|
||||
return new Zend_Auth_Result(Zend_Auth_Result::FAILURE, null);
|
||||
}
|
||||
|
||||
$userHeader = Config::get('header_auth.user_header');
|
||||
$groupsHeader = Config::get('header_auth.groups_header');
|
||||
$emailHeader = Config::get('header_auth.email_header');
|
||||
$nameHeader = Config::get('header_auth.name_header');
|
||||
|
||||
$userLogin = $this->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;
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -44,20 +44,14 @@ 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 ($form->isValid($request->getPost())) {
|
||||
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');
|
||||
|
||||
$authAdapter = Application_Model_Auth::getAuthAdapter();
|
||||
|
||||
// pass to the adapter the submitted username and password
|
||||
$authAdapter->setIdentity($username)
|
||||
->setCredential($password);
|
||||
|
@ -83,7 +77,6 @@ class LoginController extends Zend_Controller_Action
|
|||
$form = $this->loginError($username);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->view->form = $form;
|
||||
$this->view->airtimeVersion = $CC_CONFIG['airtime_version'];
|
||||
|
|
Loading…
Reference in New Issue