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:
dakriy 2024-12-07 02:21:57 -08:00 committed by GitHub
parent f709c5026d
commit 2985d8554a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 253 additions and 31 deletions

View File

@ -113,3 +113,68 @@ general:
``` ```
You should now be able to use your FreeIPA credentials to log in to LibreTime. 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.

View File

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

View File

@ -99,6 +99,22 @@ class Schema implements ConfigurationInterface
/**/->scalarNode('filter_field')->end() /**/->scalarNode('filter_field')->end()
->end()->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 // Playout schema
->arrayNode('playout') ->arrayNode('playout')
/**/->ignoreExtraKeys() /**/->ignoreExtraKeys()

View File

@ -44,44 +44,37 @@ class LoginController extends Zend_Controller_Action
$form = new Application_Form_Login(); $form = new Application_Form_Login();
$message = _('Please enter your username and password.'); $authAdapter = Application_Model_Auth::getAuthAdapter();
if ($request->isPost()) { if ($authAdapter instanceof LibreTime_Auth_Adaptor_Header || ($request->isPost() && $form->isValid($request->getPost()))) {
// Open the session for writing, because we close it for writing by default in Bootstrap.php as an optimization. // get the username and password from the form
// session_start(); $username = $form->getValue('username');
$password = $form->getValue('password');
$locale = $form->getValue('locale');
if ($form->isValid($request->getPost())) { // pass to the adapter the submitted username and password
// get the username and password from the form $authAdapter->setIdentity($username)
$username = $form->getValue('username'); ->setCredential($password);
$password = $form->getValue('password');
$locale = $form->getValue('locale');
$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 // the default storage is a session with namespace Zend_Auth
$authAdapter->setIdentity($username) $authStorage = $auth->getStorage();
->setCredential($password); $authStorage->write($userInfo);
$result = $auth->authenticate($authAdapter); Application_Model_LoginAttempts::resetAttempts($_SERVER['REMOTE_ADDR']);
if ($result->isValid()) { Application_Model_Subjects::resetLoginAttempts($username);
Zend_Session::regenerateId();
// all info about this user from the login table omit only the password
$userInfo = $authAdapter->getResultRowObject(null, 'password');
// the default storage is a session with namespace Zend_Auth // set the user locale in case user changed it in when logging in
$authStorage = $auth->getStorage(); Application_Model_Preference::SetUserLocale($locale);
$authStorage->write($userInfo);
Application_Model_LoginAttempts::resetAttempts($_SERVER['REMOTE_ADDR']); $this->_redirect('showbuilder');
Application_Model_Subjects::resetLoginAttempts($username); } else {
$form = $this->loginError($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);
}
} }
} }