Install psono password manager with saml using docker-compose

For some time i have been searching for a good self-hosted password manager that saml authentication but also is not to expensive. During my search i found a reddit post from the developer of psono, after looking at his website it looks like this manager will meet my requirements.
It has a free enterprise community version that can be self-hosted and support saml authentication up to 10 users, so if i would want i could add a friend to a specific group to share passwords.

Today i started the setup by creating a new ubuntu server with docker-compose, enabled ssh and logged in using my anyconnect vpn. To host psono you need to run multiple docker containers for certain functionalities. Because of this i wanted to configure as much as possible using the docker compose to make deploying the application alot easier. But before anything can be started using compose we first need to configure a couple of things.

local folders

The first thing we need to do is create a couple local folders where we can store the configuration and log files. To do this run the following commands:

  sudo mkdir -p /opt/docker/psono/database
  sudo mkdir -p /opt/docker/psono/server
  sudo mkdir -p /opt/docker/psono/proxy/certs
  sudo mkdir -p /opt/docker/psono/web
  sudo mkdir -p /var/log/psono

Config file preparations

With the folders created we have to run 2 commands before we can start editing the configuration for psono

 docker run --rm -ti psono/psono-server:latest python3 ./psono/manage.py generateserverkeys
 openssl req -new -newkey rsa:2048 -x509 -days 3650 -nodes -sha256 -out sp_x509cert.crt -keyout sp_private_key.key

Save the output the first command and copy the certificate files to your local computer. For the certificates you need to remove all line-breaks from the file, we want a single long base64 encoded string of each file.

Settings.yaml

The first config file we need to create is the settings.yaml file that is being used by the psono server. Below i have a cleaned-up version of the configuration file that i am currently using, on the psono website you will find the original file. Because i am using saml i left this information in but i will not show the configuration from the azuread side, for this you can find a good tutorial on the psono website.

# generate the following six parameters with the following command
# docker run --rm -ti psono/psono-server:latest python3 ./psono/manage.py generateserverkeys
SECRET_KEY: 'Some key'
ACTIVATION_LINK_SECRET: 'Some key'
DB_SECRET: 'Some key'
EMAIL_SECRET_SALT: 'Some key'
PRIVATE_KEY: 'Some key'
PUBLIC_KEY: 'Some key'

# The URL of the web client (path to e.g activate.html without the trailing slash)
WEB_CLIENT_URL: '<Fill in your websites hostname eg, https:psono.stollielab.nl>'

# Switch DEBUG to false if you go into production
DEBUG: False

# Adjust this according to Django Documentation https://docs.djangoproject.com/en/2.2/ref/settings/
ALLOWED_HOSTS: ['*']

# Should be your domain without "www.". Will be the last part of the username
ALLOWED_DOMAINS: ['<Fill in your domain name eg, stollielab.nl>']

# Should be the URL of the host under which the host is reachable
# If you open the url and append /info/ to it you should have a text similar to {"info":"{\"version\": \"....}
HOST_URL: '<Fill in your websites server hostname eg, https:psono.stollielab.nl/server>'

# The email used to send emails, e.g. for activation
# ATTENTION: If executed in a docker container, then "localhost" will resolve to the docker container, so
# "localhost" will not work as host. Use the public IP or DNS record of the server.
EMAIL_FROM: ''
EMAIL_HOST: ''
EMAIL_HOST_USER: ''
EMAIL_HOST_PASSWORD : ''
EMAIL_PORT: 25
EMAIL_SUBJECT_PREFIX: ''
EMAIL_USE_TLS: false
EMAIL_USE_SSL: False
EMAIL_SSL_CERTFILE:
EMAIL_SSL_KEYFILE:
EMAIL_TIMEOUT: 10

# Enables the management API, required for the psono-admin-client / admin portal (Default is set to False)
MANAGEMENT_ENABLED: True

# You also have to comment in the line below if you want to use LDAP (default: ['AUTHKEY'])
# For SAML authentication, you also have to add 'SAML' to the array.
AUTHENTICATION_METHODS: ['AUTHKEY', 'SAML']

SAML_CONFIGURATIONS:
    1:
        idp:
            entityId: "REPLACE_WITH_AZURE_AD_IDENTIFIER"
            singleLogoutService:
                binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
                url: "https://login.microsoftonline.com/common/wsfederation?wa=wsignout1.0"
            singleSignOnService:
                binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
                url: "REPLACE_WITH_LOGIN_URL"
            x509cert: "CERT_FROM_AZURE_APPLICATION"
            groups_attribute: "groups"
            username_attribute: "username"
            email_attribute: "email"
            username_domain: "USER_DOMAIN"
            required_group: []
            is_adfs: true
            honor_multifactors: true
            max_session_lifetime: 43200
        sp:
            NameIDFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
            assertionConsumerService:
                binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
            attributeConsumingService:
                serviceName: "Psono"
                serviceDescription: "Psono password manager"
                requestedAttributes:
                    -
                        attributeValue: []
                        friendlyName: ""
                        isRequired: false
                        name: "username"
                        nameFormat: ""
            privateKey: "SP_PRIVATE_CERTIFICATE"
            singleLogoutService:
                binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
            x509cert: "SP_X509CERT"
        strict: true
        security:
            requestedAuthnContext: false

DATABASES:
    default:
        'ENGINE': 'django.db.backends.postgresql_psycopg2'
        'NAME': 'psono'
        'USER': 'psono'
        'PASSWORD': 'password'
        'HOST': '172.18.1.10'
        'PORT': '5432'

# The path to the template folder can be "shadowed" if required later
TEMPLATES: [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': ['/root/psono/templates'],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

Create a new file called "settings.yaml" and copy in the above configuration. There are a couple things that you have to configure before uploading the file to you server:

  • Fill in the security keys generated earlier
  • Decide what url you will be using and fill those in where needed
  • Configure you smtp settings
  • Configure the saml settings (manual here)
  • Configure your database password

You can now safe the file and save it in the server config folder: /opt/docker/psono/server/settings.yaml

webconfig.yaml

Create a file called webconfig.yaml and copy the contents from below. Again fill in your url and domain and save the file in: /opt/docker/psono/web/webconfig.yaml

{
  "backend_servers": [{
    "title": "<URL eg: psono.stollielab.nl>",
    "url": "<Server URL eg: https://psono.stollielab.nl/server>"
    "domain": "<Domain name eg: stollielab.nl>",
  }],
  "base_url": "<Base URL eg: https://psono.stollielab.nl>",
  "allow_custom_server": true,
  "allow_registration": true,
  "allow_lost_password": false,
  "disable_download_bar": false,
  "authentication_methods": ["AUTHKEY", "SAML"],
  "saml_provider": [{
    "title": "AzureAD",
    "provider_id": 1,
    "button_name": "SAML SSO Login"
  }]
}
nginx.conf

Lastly we need to create an nginx config to bundle the different containers into a single site. Currently i am using certificates created using my own CA and have not looked into the possibility of using letsencrypt. For this deployment i am using the official nginx docker image that does not have certbot installed in it, if you know a better nginx image that has certbot installed you can use that one aswell.

Fill in your base url in the config below and save it at: /opt/docker/psono/proxy/nginx.config. You certificates can be saved here: /opt/docker/psono/proxy/certs

server {
    listen 80;
    server_name <Base url eg, psono.stollielab.nl>;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name <Base url eg, psono.stollielab.nl>;

    ssl_protocols TLSv1.2;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_session_timeout 1d;
    resolver 8.8.8.8 8.8.4.4 valid=300s;
    resolver_timeout 5s;
    ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256';

    # Comment this in if you know what you are doing
    # add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload";

    add_header Referrer-Policy same-origin;
    add_header X-Frame-Options DENY;
    add_header X-Content-Type-Options nosniff;
    add_header X-XSS-Protection "1; mode=block";

    # If you have the admin fileserver installed too behind this reverse proxy domain, add your fileserver URL e.g. https://fs01.example.com as connect-src too:
    add_header Content-Security-Policy "default-src 'none';  manifest-src 'self'; connect-src 'self' https://static.psono.com https://api.pwnedpasswords.com https://storage.googleapis.com https://*.digitaloceanspaces.com https://*.blob.core.windows.net https://*.s3.amazonaws.com; font-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'self'";

    ssl_certificate /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    client_max_body_size 256m;

    gzip on;
    gzip_disable "msie6";

    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_min_length 256;
    gzip_types text/plain text/css application/json application/x-javascript application/javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype image/svg+xml image/x-icon;

    root /var/www/html;

    location /server {
        rewrite ^/server/(.*) /$1 break;
        proxy_set_header        Host $host;
        proxy_set_header        X-Real-IP $remote_addr;
        proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header        X-Forwarded-Proto $scheme;

        add_header Last-Modified $date_gmt;
        add_header Pragma "no-cache";
        add_header Cache-Control "private, max-age=0, no-cache, no-store";
        if_modified_since off;
        expires off;
        etag off;

        proxy_pass          http://172.18.1.20:80;
    }

    location ~* ^/portal.*\.(?:ico|css|js|gif|jpe?g|png|eot|woff|woff2|ttf|svg|otf)$ {
        expires 30d;
        add_header Pragma public;
        add_header Cache-Control "public";

        # Remove the leading # from the following lines if you have the admin webclient running in a docker container
         proxy_set_header        Host $host;
         proxy_set_header        X-Real-IP $remote_addr;
         proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
         proxy_set_header        X-Forwarded-Proto $scheme;

         proxy_pass          http://172.18.1.40;
         #proxy_redirect      http://172.18.1.40 https://psono.example.com;
    }

    location ~* \.(?:ico|css|js|gif|jpe?g|png|eot|woff|woff2|ttf|svg|otf)$ {
        expires 30d;
        add_header Pragma public;
        add_header Cache-Control "public";

        # Remove the leading # from following lines if you have the webclient running in a docker container
         proxy_set_header        Host $host;
         proxy_set_header        X-Real-IP $remote_addr;
         proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
         proxy_set_header        X-Forwarded-Proto $scheme;

         proxy_pass          http://172.18.1.30;
         #proxy_redirect      http://172.18.1.30 https://psono.example.com;
    }

    # Remove the leading # from following lines if you have the admin webclient running in a docker container
    location /portal {
        proxy_set_header        Host $host;
        proxy_set_header        X-Real-IP $remote_addr;
        proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header        X-Forwarded-Proto $scheme;

        proxy_read_timeout  90;

        proxy_pass          http://172.18.1.40;
    }

    # Remove the leading # from following lines if you have the admin webclient NOT running in a docker container
    # location /portal {
    #     index  index.html index.htm;
    #     try_files $uri /portal/index.html;  # forward all requests to index.html
    # }

    # Remove the leading # from following lines if you have the webclient running in a docker container
    location / {
        proxy_set_header        Host $host;
        proxy_set_header        X-Real-IP $remote_addr;
        proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header        X-Forwarded-Proto $scheme;

        proxy_pass          http://172.18.1.30;
        proxy_read_timeout  90;

    #   proxy_redirect      http://172.18.1.30 https://psono.example.com;
    }
}

Docker compose file

Now we can create our docker compose file. I've set this compose file up with its own internal network for the different containers to communicate with eachother more securely. They will get an internet connection but are not accessible from the outside. Only the nginx container has his ports opened so we can access the website.

Copy the contents to a file called docker-compose.yml and save it somewhere on your linux host, default is your homefolder.

version: '3.3'
networks:
 psono_internal:
  external: false
  ipam:
   driver: default
   config:
    - subnet: "172.18.1.0/24"

services:
 psono-proxy:
  image: nginx:latest
  ports:
   - "80:80"
   - "443:443"
  restart: always
  volumes:
   - /opt/docker/psono/proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
   - /opt/docker/psono/proxy/certs:/etc/nginx/certs
  networks:
   - psono_internal

 psono-db:
  image: postgres:13-alpine
  volumes:
   - /opt/docker/psono/database:/var/lib/postgresql/data
  restart: always
  networks:
   psono_internal:
    ipv4_address: 172.18.1.10
  environment:
   - POSTGRES_USER=psono
   - POSTGRES_PASSWORD=<password>

 psono-server:
  depends_on:
   - psono-db
  image: psono/psono-server-enterprise:latest
  restart: always
  volumes:
   - /opt/docker/psono/server/settings.yaml:/root/.psono_server/settings.yaml
   - /var/log/psono:/var/log/psono
  networks:
   psono_internal:
    ipv4_address: 172.18.1.20

 psono-web:
  depends_on:
   - psono-server
  image: psono/psono-client:latest
  restart: always
  volumes:
   - /opt/docker/psono/web/config.json:/usr/share/nginx/html/config.json
  networks:
   psono_internal:
    ipv4_address: 172.18.1.30

 psono-admin:
  depends_on:
   - psono-server
  image: psono/psono-admin-client:latest
  restart: always
  volumes:
   - /opt/docker/psono/web/config.json:/usr/share/nginx/html/portal/config.json
  networks:
   psono_internal:
    ipv4_address: 172.18.1.40

Prepare the database

First we need docker-compose to create and start all the configured containers, this will also setup the internal networking that we need to prepare the database. To create your containers run the following command from the folder where your docker-compose.yaml file is located:

docker-compose up -d

This will automatically pull the required images and configure the containers, you can verify that everything is running by running docker ps. Some containers might not start up succesfully because we have not configured the database. Before we can prepare the database we first have to find the name of our newly created network, to do this run the following command:

docker network ls

This will give you a list containing all of your available docker networks, select the one that contains "psono_internal".

In my case the network that we need is called "beheerder_psono_internal", fill your network in the command below and run it.

docker run --rm \
  -v /opt/docker/psono/server/settings.yaml:/root/.psono_server/settings.yaml \
  --network="<Your internal network>" \
  -ti psono/psono-server-enterprise:latest python3 ./psono/manage.py migrate

This command will grab the database config from the settings.yaml file and prepare the database.

Complete the configuration

We are almost done with the psono installation but before we can continue we need to restart all containers except the database or reboot your linux server. After a short period you should see all of your containers online in docker and you should be able to access the webclient. If you have succesfully configure saml you should be able to login using the saml sso login button.

You should now be logged into your own personal psono password manager using saml authentication! To finish the configuration we have to make your user account superuser to be able to use the admin portal(which can be found at /portal). Fill in your preffered admin account and run the following command:

 sudo docker run --rm \
  -v /opt/docker/psono/server/settings.yaml:/root/.psono_server/settings.yaml \
  --network="<Your internal network>" \
  -ti psono/psono-server:latest python3 ./psono/manage.py promoteuser Account@example.com superuser

You can now login to the admin portal and configure your psono server.

Related Articles, References, Credits or External Links

Install psono server EE | Psono Documentation