Merge branch 'issue-30-backend-genericity' into 'main'

Dummy inmemory backend

See merge request yaal/canaille!149
This commit is contained in:
Éloi Rivard 2023-08-17 10:39:53 +00:00
commit 74e7dee258
26 changed files with 1296 additions and 112 deletions

View file

@ -3,6 +3,11 @@ All notable changes to this project will be documented in this file.
The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.0.0/>`_,
and this project adheres to `Semantic Versioning <https://semver.org/spec/v2.0.0.html>`_.
Added
*****
- Additional inmemory backend :issue:`30` :pr:`149`
[0.0.31] - 2023-08-15
=====================

View file

@ -31,11 +31,21 @@ The canaille server has some default users:
and his first attempt to log-in would result in sending a password initialization
email (if a smtp server is configurated).
Backends
~~~~~~~~
Canaille comes with two backends:
- a lightweight test purpose `memory` backend
- a production-ready `LDAP` backend
Docker environment
~~~~~~~~~~~~~~~~~~
If you want to develop with docker, your browser needs to be able to reach the `canaille` container. The docker-compose file exposes the right ports, but front requests are from outside the docker network: the `canaille` url that makes sense for docker, points nowhere from your browser. As exposed ports are on `localhost`, you need to tell your computer that `canaille` url means `localhost`.
If you want to develop with docker, your browser needs to be able to reach the `canaille` container.
The docker-compose file exposes the right ports, but front requests are from outside the docker network:
the `canaille` url that makes sense for docker, points nowhere from your browser.
As exposed ports are on `localhost`, you need to tell your computer that `canaille` url means `localhost`.
To do that, you can add the following line to your `/etc/hosts`:
@ -47,16 +57,28 @@ To launch containers, use:
.. code-block:: console
cd demo && docker compose up # or docker-compose
cd demo
# To run the demo with the memory backend:
docker compose up
# To run the demo with the LDAP backend:
docker compose --file docker-compose-ldap.yml up
Local environment
~~~~~~~~~~~~~~~~~
To run canaille locally, you need to have OpenLDAP installed on your system. Then:
.. code-block:: console
# To run the demo with the memory backend:
./demo/run.sh
If you want to run the demo locally with the LDAP backend, you need to have
OpenLDAP installed on your system.
.. code-block:: console
./demo/run.sh
# To run the demo with the LDAP backend:
./demo/run.sh --backend ldap
.. warning ::
@ -78,15 +100,20 @@ users and groups with the ``populate`` command:
.. code-block:: console
# If using docker:
docker compose exec canaille env CONFIG=conf-docker/canaille.toml poetry run canaille populate --nb 100 users # or docker-compose
docker compose exec canaille env CONFIG=conf-docker/canaille-ldap.toml poetry run canaille populate --nb 100 users # or docker-compose
# If running in local environment
env CONFIG=conf/canaille.toml poetry run canaille populate --nb 100 users
env CONFIG=conf/canaille-ldap.toml poetry run canaille populate --nb 100 users
Note that this will not work with the memory backend.
Unit tests
----------
To run the tests, you just need to run `tox`. Everything must be green before patches get merged.
To run the tests, you just can run `poetry run pytest` and/or `tox` to test all the supported python environments.
Everything must be green before patches get merged.
To test a specific backend you can pass `--backend memory` or `--backend ldap` to pytest and tox.
The test coverage is 100%, patches won't be accepted if not entirely covered. You can check the
test coverage with ``tox -e coverage``.
@ -106,7 +133,7 @@ The interface is built upon the `Fomantic UI <https://fomantic-ui.com/>`_ CSS fr
The dynamical parts of the interface use `htmx <https://htmx.org/>`_.
- Using Javascript in the interface is tolerated, but the whole website MUST be accessible
for browsers without Javascript support, and without feature loss.
for browsers without Javascript support, and without any feature loss.
- Because of Fomantic UI we have a dependency to jQuery, however new contributions should
not depend on jQuery at all.
See the `related issue <https://gitlab.com/yaal/canaille/-/issues/130>`_.

View file

@ -6,10 +6,10 @@
as in *Can I access your data?* Canaille is a simple account manager and an OpenID Connect provider based upon a LDAP database.
It aims to be very light, simple to install and simple to maintain. Its main features are :
- Authentication and user profile edition against a LDAP directory;
- Registration, email confirmation, "I forgot my password" emails;
- Only OpenID Connect: no outdated or exotic protocol support;
- No additional database required: everything is stored in your LDAP server;
- User profile and groups management;
- Authentication, registration, email confirmation, "I forgot my password" emails;
- OpenID Connect identity provider;
- LDAP first-class citizenship;
- Customizable, themable;
- The code is easy to read and easy to edit!
@ -25,7 +25,10 @@ It aims to be very light, simple to install and simple to maintain. Its main fea
```bash
cd demo
./run.sh # or `docker-compose up` to run it with docker
# Either run the demo locally
./run.sh
# or run the demo in docker
docker compose up
```
or try our [online demo](https://demo.canaille.yaal.coop)!

View file

@ -90,7 +90,9 @@ class BaseBackend:
def setup_backend(app, backend):
if not backend:
backend_name = list(app.config.get("BACKENDS").keys())[0].lower()
backend_name = list(app.config.get("BACKENDS", {"memory": {}}).keys())[
0
].lower()
module = importlib.import_module(f"canaille.backends.{backend_name}.backend")
backend_class = getattr(module, "Backend")
backend = backend_class(app.config)

View file

View file

@ -0,0 +1,24 @@
from canaille.backends import BaseBackend
class Backend(BaseBackend):
@classmethod
def install(cls, config, debug=False):
pass
def setup(self):
pass
def teardown(self):
pass
@classmethod
def validate(cls, config):
pass
@classmethod
def login_placeholder(cls):
return ""
def has_account_lockability(self):
return True

View file

@ -0,0 +1,510 @@
import copy
import datetime
import uuid
import canaille.core.models
import canaille.oidc.models
from canaille.app import models
from flask import current_app
def listify(value):
return value if isinstance(value, list) else [value]
def serialize(value):
return value.id if isinstance(value, Model) else value
class Model:
indexes = {}
attribute_indexes = {}
def __init__(self, **kwargs):
kwargs.setdefault("id", str(uuid.uuid4()))
self.state = {}
self.cache = {}
for attribute, value in kwargs.items():
setattr(self, attribute, value)
def __repr__(self):
return f"<{self.__class__.__name__} id={self.id}>"
@classmethod
def query(cls, **kwargs):
# no filter, return all models
if not kwargs:
states = cls.index().values()
return [cls(**state) for state in states]
# read the attribute indexes
ids = {
id
for attribute, values in kwargs.items()
for value in listify(values)
for id in cls.attribute_index(attribute).get(serialize(value), [])
}
return [cls(**cls.index()[id]) for id in ids]
@classmethod
def index(cls, class_name=None):
if not class_name:
class_name = cls.__name__
return Model.indexes.setdefault(class_name, {}).setdefault("id", {})
@classmethod
def attribute_index(cls, attribute="id", class_name=None):
return Model.attribute_indexes.setdefault(
class_name or cls.__name__, {}
).setdefault(attribute, {})
@classmethod
def fuzzy(cls, query, attributes=None, **kwargs):
attributes = attributes or cls.attributes
instances = cls.query(**kwargs)
return [
instance
for instance in instances
if any(
query.lower() in value.lower()
for attribute in attributes
for value in instance.state.get(attribute, [])
if isinstance(value, str)
)
]
@classmethod
def get(cls, identifier=None, **kwargs):
if identifier:
kwargs[cls.identifier_attribute] = identifier
results = cls.query(**kwargs)
return results[0] if results else None
def save(self):
self.delete()
# update the id index
self.index()[self.id] = copy.deepcopy(self.state)
# update the index for each attribute
for attribute in self.attributes:
attribute_values = listify(getattr(self, attribute))
for value in attribute_values:
self.attribute_index(attribute).setdefault(value, set()).add(self.id)
# update the mirror attributes of the submodel instances
for attribute in self.model_attributes:
klass, mirror_attribute = self.model_attributes[attribute]
if not self.index(klass) or not mirror_attribute:
continue
mirror_attribute_index = self.attribute_index(
mirror_attribute, klass
).setdefault(self.id, set())
for subinstance_id in self.state.get(attribute, []):
if not subinstance_id or subinstance_id not in self.index(klass):
continue
subinstance_state = self.index(klass)[subinstance_id]
subinstance_state.setdefault(mirror_attribute, [])
subinstance_state[mirror_attribute].append(self.id)
mirror_attribute_index.add(subinstance_id)
def delete(self):
if self.id not in self.index():
return
old_state = self.index()[self.id]
# update the index for each attribute
for attribute in self.model_attributes:
attribute_values = listify(old_state.get(attribute, []))
for value in attribute_values:
if (
value in self.attribute_index(attribute)
and self.id in self.attribute_index(attribute)[value]
):
self.attribute_index(attribute)[value].remove(self.id)
# update the mirror attributes of the submodel instances
klass, mirror_attribute = self.model_attributes[attribute]
if not self.index(klass) or not mirror_attribute:
continue
mirror_attribute_index = self.attribute_index(
mirror_attribute, klass
).setdefault(self.id, set())
for subinstance_id in self.index()[self.id].get(attribute, []):
if subinstance_id not in self.index(klass):
continue
subinstance_state = self.index(klass)[subinstance_id]
subinstance_state[mirror_attribute].remove(self.id)
if subinstance_id in mirror_attribute_index:
mirror_attribute_index.remove(subinstance_id)
# update the index for each attribute
for attribute in self.attributes:
attribute_values = listify(old_state.get(attribute, []))
for value in attribute_values:
if (
value in self.attribute_index(attribute)
and self.id in self.attribute_index(attribute)[value]
):
self.attribute_index(attribute)[value].remove(self.id)
# update the id index
del self.index()[self.id]
def update(self, **kwargs):
for attribute, value in kwargs.items():
setattr(self, attribute, value)
def reload(self):
self.state = self.__class__.get(id=self.id).state
self.cache = {}
def __eq__(self, other):
if other is None:
return False
if not isinstance(other, Model):
return self == self.__class__.get(id=other)
return self.state == other.state
def __hash__(self):
return hash(self.id)
def __getattr__(self, name):
if name in self.attributes:
values = self.cache.get(name, self.state.get(name, []))
if name in self.model_attributes:
klass = getattr(models, self.model_attributes[name][0])
values = [
value if isinstance(value, Model) else klass.get(id=value)
for value in values
]
values = [value for value in values if value]
if name in self.unique_attributes:
return values[0] if values else None
else:
return values or []
raise AttributeError()
raise AttributeError()
def __setattr__(self, name, value):
if name in self.attributes:
values = listify(value)
self.cache[name] = [value for value in values if value]
values = [serialize(value) for value in values]
values = [value for value in values if value]
self.state[name] = values
else:
super().__setattr__(name, value)
def __delattr__(self, name):
try:
del self.state[name]
except KeyError:
pass
try:
del self.cache[name]
except KeyError:
pass
class User(canaille.core.models.User, Model):
attributes = [
"id",
"user_name",
"password",
"preferred_language",
"family_name",
"given_name",
"formatted_name",
"display_name",
"emails",
"phone_numbers",
"formatted_address",
"street",
"postal_code",
"locality",
"region",
"photo",
"profile_url",
"employee_number",
"department",
"title",
"organization",
"last_modified",
"groups",
"lock_date",
]
identifier_attribute = "user_name"
unique_attributes = [
"id",
"display_name",
"employee_number",
"preferred_language",
"last_modified",
"lock_date",
]
model_attributes = {
"groups": ("Group", "members"),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.load_permissions()
def reload(self):
super().reload()
self.load_permissions()
def load_permissions(self):
self.permissions = set()
self.read = set()
self.write = set()
for access_group_name, details in current_app.config["ACL"].items():
if self.match_filter(details.get("FILTER")):
self.permissions |= set(details.get("PERMISSIONS", []))
self.read |= set(details.get("READ", []))
self.write |= set(details.get("WRITE", []))
def match_filter(self, filter):
if filter is None:
return True
if isinstance(filter, dict):
# not super generic, but we can improve this when we have
# type checking and/or pydantic for the models
if "groups" in filter:
if models.Group.get(id=filter["groups"]):
filter["groups"] = models.Group.get(id=filter["groups"]).id
if models.Group.get(display_name=filter["groups"]):
filter["groups"] = models.Group.get(
display_name=filter["groups"]
).id
return all(
value in getattr(self, attribute, None)
for attribute, value in filter.items()
)
return any(self.match_filter(subfilter) for subfilter in filter)
@classmethod
def get_from_login(cls, login=None, **kwargs):
return User.get(user_name=login)
@property
def identifier(self):
return getattr(self, self.identifier_attribute)[0]
def has_password(self):
return bool(self.password)
def check_password(self, password):
if password not in self.password:
return (False, None)
if self.locked:
return (False, "Your account has been locked.")
return (True, None)
def set_password(self, password):
self.password = password
self.save()
def save(self):
self.last_modified = datetime.datetime.now(datetime.timezone.utc).replace(
microsecond=0
)
super().save()
class Group(canaille.core.models.Group, Model):
attributes = [
"id",
"display_name",
"members",
"description",
]
unique_attributes = ["id", "display_name"]
model_attributes = {
"members": ("User", "groups"),
}
identifier_attribute = "display_name"
@property
def identifier(self):
return getattr(self, self.identifier_attribute)
class Client(canaille.oidc.models.Client, Model):
attributes = [
"id",
"description",
"preconsent",
"post_logout_redirect_uris",
"audience",
"client_id",
"client_secret",
"client_id_issued_at",
"client_secret_expires_at",
"client_name",
"contacts",
"client_uri",
"redirect_uris",
"logo_uri",
"grant_types",
"response_types",
"scope",
"tos_uri",
"policy_uri",
"jwks_uri",
"jwk",
"token_endpoint_auth_method",
"software_id",
"software_version",
]
identifier_attribute = "client_id"
unique_attributes = [
"id",
"preconsent",
"client_id",
"client_secret",
"client_id_issued_at",
"client_secret_expires_at",
"client_name",
"client_uri",
"logo_uri",
"tos_uri",
"policy_uri",
"jwks_uri",
"jwk",
"token_endpoint_auth_method",
"software_id",
"software_version",
]
model_attributes = {
"audience": ("Client", None),
}
@property
def identifier(self):
return getattr(self, self.identifier_attribute)
class AuthorizationCode(canaille.oidc.models.AuthorizationCode, Model):
attributes = [
"id",
"authorization_code_id",
"description",
"code",
"client",
"subject",
"redirect_uri",
"response_type",
"scope",
"nonce",
"issue_date",
"lifetime",
"challenge",
"challenge_method",
"revokation_date",
]
identifier_attribute = "authorization_code_id"
unique_attributes = [
"id",
"authorization_code_id",
"code",
"client",
"subject",
"redirect_uri",
"issue_date",
"lifetime",
"challenge",
"challenge_method",
"revokation_date",
"nonce",
]
model_attributes = {
"client": ("Client", None),
"subject": ("User", None),
}
@property
def identifier(self):
return getattr(self, self.identifier_attribute)
class Token(canaille.oidc.models.Token, Model):
attributes = [
"id",
"token_id",
"access_token",
"description",
"client",
"subject",
"type",
"refresh_token",
"scope",
"issue_date",
"lifetime",
"revokation_date",
"audience",
]
identifier_attribute = "token_id"
unique_attributes = [
"id",
"token_id",
"subject",
"issue_date",
"lifetime",
"access_token",
"refresh_token",
"revokation_date",
"client",
]
model_attributes = {
"client": ("Client", None),
"subject": ("User", None),
"audience": ("Client", None),
}
@property
def identifier(self):
return getattr(self, self.identifier_attribute)
class Consent(canaille.oidc.models.Consent, Model):
attributes = [
"id",
"consent_id",
"subject",
"client",
"scope",
"issue_date",
"revokation_date",
]
identifier_attribute = "consent_id"
unique_attributes = ["id", "subject", "client", "issue_date", "revokation_date"]
model_attributes = {
"client": ("Client", None),
"subject": ("User", None),
}
@property
def identifier(self):
return getattr(self, self.identifier_attribute)[0]

View file

@ -14,4 +14,4 @@ RUN pip install poetry
WORKDIR /opt/canaille
RUN poetry install --with demo --without dev
ENTRYPOINT ["poetry", "run", "flask", "run", "--host=0.0.0.0", "--extra-files", "/opt/canaille/conf/canaille.toml"]
ENTRYPOINT ["poetry", "run", "flask", "run", "--host=0.0.0.0", "--extra-files", "/opt/canaille/conf/canaille-memory.toml", "/opt/canaille/conf/canaille-ldap.toml"]

View file

@ -1,4 +1,4 @@
slapd: env BIN=$BIN:/usr/bin:/usr/sbin python ldap-server.py
canaille: env FLASK_DEBUG=1 AUTHLIB_INSECURE_TRANSPORT=1 CONFIG=conf/canaille.toml FLASK_APP=demoapp flask run --extra-files conf/canaille.toml
canaille: env FLASK_DEBUG=1 AUTHLIB_INSECURE_TRANSPORT=1 CONFIG=conf/canaille-ldap.toml FLASK_APP=demoapp flask run --extra-files conf/canaille-ldap.toml
client1: env FLASK_DEBUG=1 CONFIG=../conf/client1.cfg FLASK_APP=client flask run --port=5001
client2: env FLASK_DEBUG=1 CONFIG=../conf/client2.cfg FLASK_APP=client flask run --port=5002

3
demo/Procfile-memory Normal file
View file

@ -0,0 +1,3 @@
canaille: env FLASK_DEBUG=1 AUTHLIB_INSECURE_TRANSPORT=1 CONFIG=conf/canaille-memory.toml FLASK_APP=demoapp flask run --extra-files conf/canaille-memory.toml
client1: env FLASK_DEBUG=1 CONFIG=../conf/client1.cfg FLASK_APP=client flask run --port=5001
client2: env FLASK_DEBUG=1 CONFIG=../conf/client2.cfg FLASK_APP=client flask run --port=5002

View file

@ -8,9 +8,9 @@ from flask import current_app
from flask import flash
from flask import Flask
from flask import redirect
from flask import render_template
from flask import session
from flask import url_for
from flask_themer import render_template
def create_app():

View file

@ -0,0 +1,260 @@
# All the Flask configuration values can be used:
# https://flask.palletsprojects.com/en/2.3.x/config/#builtin-configuration-values
# The flask secret key for cookies. You MUST change this.
SECRET_KEY = "change me before you go in production"
# Your organization name.
NAME = "Canaille"
# The interface on which canaille will be served
# SERVER_NAME = "auth.mydomain.tld"
# PREFERRED_URL_SCHEME = "https"
# You can display a logo to be recognized on login screens
LOGO = "/static/img/canaille-head.png"
# Your favicon. If unset the LOGO will be used.
FAVICON = "/static/img/canaille-c.png"
# The name of a theme in the 'theme' directory, or an absolute path
# to a theme. Defaults to 'default'. Theming is done with
# https://github.com/tktech/flask-themer
# THEME = "default"
# If unset, language is detected
# LANGUAGE = "en"
# The timezone in which datetimes will be displayed to the users.
# If unset, the server timezone will be used.
# TIMEZONE = UTC
# If you have a sentry instance, you can set its dsn here:
# SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
# Enables javascript to smooth the user experience
# JAVASCRIPT = true
# Accelerates webpages with async requests
# HTMX = true
# If EMAIL_CONFIRMATION is set to true, users will need to click on a
# confirmation link sent by email when they want to add a new email.
# By default, this is true if SMTP is configured, else this is false.
# If explicitely set to true and SMTP is disabled, the email field
# will be read-only.
# EMAIL_CONFIRMATION =
# If ENABLE_REGISTRATION is true, then users can freely create an account
# at this instance. If email verification is available, users must confirm
# their email before the account is created.
ENABLE_REGISTRATION = true
# If HIDE_INVALID_LOGINS is set to true (the default), when a user
# tries to sign in with an invalid login, a message is shown indicating
# that the password is wrong, but does not give a clue wether the login
# exists or not.
# If HIDE_INVALID_LOGINS is set to false, when a user tries to sign in with
# an invalid login, a message is shown indicating that the login does not
# exist.
# HIDE_INVALID_LOGINS = true
# If ENABLE_PASSWORD_RECOVERY is false, then users cannot ask for a password
# recovery link by email. This option is true by default.
# ENABLE_PASSWORD_RECOVERY = true
# The validity duration of registration invitations, in seconds.
# Defaults to 2 days
# INVITATION_EXPIRATION = 172800
[LOGGING]
# LEVEL can be one value among:
# DEBUG, INFO, WARNING, ERROR, CRITICAL
# Defaults to WARNING
# LEVEL = "WARNING"
LEVEL = "DEBUG"
# The path of the log file. If not set (the default) logs are
# written in the standard error output.
# PATH = ""
# [BACKENDS.LDAP]
# URI = "ldap://ldap:389"
# ROOT_DN = "dc=mydomain,dc=tld"
# BIND_DN = "cn=admin,dc=mydomain,dc=tld"
# BIND_PW = "admin"
# TIMEOUT = 10
# Where to search for users?
# USER_BASE = "ou=users,dc=mydomain,dc=tld"
# The object class to use for creating new users
# USER_CLASS = "inetOrgPerson"
# The attribute to identify an object in the User dn.
# USER_RDN = "uid"
# Filter to match users on sign in. Jinja syntax is supported
# and a `login` variable is available containing the value
# passed in the login field.
# USER_FILTER = "(|(uid={{ login }})(mail={{ login }}))"
# Where to search for groups?
# GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
# The object class to use for creating new groups
# GROUP_CLASS = "groupOfNames"
# The attribute to identify an object in the User dn.
# GROUP_RDN = "cn"
# The attribute to use to identify a group
# GROUP_NAME_ATTRIBUTE = "cn"
[ACL]
# You can define access controls that define what users can do on canaille
# An access control consists in a FILTER to match users, a list of PERMISSIONS
# matched users will be able to perform, and fields users will be able
# to READ and WRITE. Users matching several filters will cumulate permissions.
#
# 'FILTER' parameter can be:
# - absent, in which case all the users will match this access control
# - a mapping where keys are user attributes name and the values those user
# attribute values. All the values must be matched for the user to be part
# of the access control.
# - a list of those mappings. If a user values match at least one mapping,
# then the user will be part of the access control
#
# Here are some examples
# FILTER = {user_name = 'admin'}
# FILTER =
# - {groups = 'admins'}
# - {groups = 'moderators'}
#
# The 'PERMISSIONS' parameter that is an list of items the users in the access
# control will be able to manage. 'PERMISSIONS' is optionnal. Values can be:
# - "edit_self" to allow users to edit their own profile
# - "use_oidc" to allow OpenID Connect authentication
# - "manage_oidc" to allow OpenID Connect client managements
# - "manage_users" to allow other users management
# - "manage_groups" to allow group edition and creation
# - "delete_account" allows a user to delete his own account. If used with
# manage_users, the user can delete any account
# - "impersonate_users" to allow a user to take the identity of another user
#
# The 'READ' and 'WRITE' attributes are the LDAP attributes of the user
# object that users will be able to read and/or write.
[ACL.DEFAULT]
PERMISSIONS = ["edit_self", "use_oidc"]
READ = [
"user_name",
"groups",
"lock_date",
]
WRITE = [
"photo",
"given_name",
"family_name",
"display_name",
"password",
"phone_numbers",
"emails",
"profile_url",
"formatted_address",
"street",
"postal_code",
"locality",
"region",
"preferred_language",
"employee_number",
"department",
"title",
"organization",
]
[ACL.ADMIN]
FILTER = {groups = "admins"}
PERMISSIONS = [
"manage_users",
"manage_groups",
"manage_oidc",
"delete_account",
"impersonate_users",
]
WRITE = [
"groups",
"lock_date",
]
[ACL.HALF_ADMIN]
FILTER = {groups = "moderators"}
PERMISSIONS = ["manage_users", "manage_groups", "delete_account"]
WRITE = ["groups"]
[OIDC]
# Wether a token is needed for the RFC7591 dynamical client registration.
# If true, no token is needed to register a client.
# If false, dynamical client registration needs a token defined
# in DYNAMIC_CLIENT_REGISTRATION_TOKENS
DYNAMIC_CLIENT_REGISTRATION_OPEN = true
# A list of tokens that can be used for dynamic client registration
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
"xxxxxxx-yyyyyyy-zzzzzz",
]
# REQUIRE_NONCE force the nonce exchange during the authentication flows.
# This adds security but may not be supported by all clients.
# REQUIRE_NONCE = true
[OIDC.JWT]
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
# the public key. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
# If the variables are unset, and debug mode is enabled,
# a in-memory keypair will be used.
# PRIVATE_KEY_FILE = "/path/to/private.pem"
# PUBLIC_KEY_FILE = "/path/to/public.pem"
# The URI of the identity provider
# ISS = "https://auth.mydomain.tld"
# The key type parameter
# KTY = "RSA"
# The key algorithm
# ALG = "RS256"
# The time the JWT will be valid, in seconds
# EXP = 3600
[OIDC.JWT.MAPPING]
# Mapping between JWT fields and LDAP attributes from your
# User objectClass.
# {attribute} will be replaced by the user ldap attribute value.
# Default values fits inetOrgPerson.
# SUB = "{{ user.user_name[0] }}"
# NAME = "{{ user.formatted_name[0] }}"
# PHONE_NUMBER = "{{ user.phone_numbers[0] }}"
# EMAIL = "{{ user.preferred_email }}"
# GIVEN_NAME = "{{ user.given_name[0] }}"
# FAMILY_NAME = "{{ user.family_name[0] }}"
# PREFERRED_USERNAME = "{{ user.display_name }}"
# LOCALE = "{{ user.preferred_language }}"
# ADDRESS = "{{ user.formatted_address[0] }}"
# PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}"
# WEBSITE = "{{ user.profile_url[0] }}"
# The SMTP server options. If not set, mail related features such as
# user invitations, and password reset emails, will be disabled.
[SMTP]
# HOST = "localhost"
# PORT = 25
# TLS = false
# SSL = false
# LOGIN = ""
# PASSWORD = ""
# FROM_ADDR = "admin@mydomain.tld"
# The registration options. If not set, registration will be disabled. Requires SMTP to work.
# Groups should be formatted like this: ["<GROUP_NAME_ATTRIBUTE>=group_name,<GROUP_BASE>", ...]
# [REGISTRATION]
# GROUPS=[]
# CAN_EDIT_USERNAME = false

View file

@ -0,0 +1,258 @@
# All the Flask configuration values can be used:
# https://flask.palletsprojects.com/en/2.3.x/config/#builtin-configuration-values
# The flask secret key for cookies. You MUST change this.
SECRET_KEY = "change me before you go in production"
# Your organization name.
# NAME = "Canaille"
# The interface on which canaille will be served
# SERVER_NAME = "auth.mydomain.tld"
# PREFERRED_URL_SCHEME = "https"
# You can display a logo to be recognized on login screens
LOGO = "/static/img/canaille-head.png"
# Your favicon. If unset the LOGO will be used.
FAVICON = "/static/img/canaille-c.png"
# The name of a theme in the 'theme' directory, or an absolute path
# to a theme. Defaults to 'default'. Theming is done with
# https://github.com/tktech/flask-themer
# THEME = "default"
# If unset, language is detected
# LANGUAGE = "en"
# The timezone in which datetimes will be displayed to the users.
# If unset, the server timezone will be used.
# TIMEZONE = UTC
# If you have a sentry instance, you can set its dsn here:
# SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
# Enables javascript to smooth the user experience
# JAVASCRIPT = true
# Accelerates webpages with async requests
# HTMX = true
# If EMAIL_CONFIRMATION is set to true, users will need to click on a
# confirmation link sent by email when they want to add a new email.
# By default, this is true if SMTP is configured, else this is false.
# If explicitely set to true and SMTP is disabled, the email field
# will be read-only.
# EMAIL_CONFIRMATION =
# If ENABLE_REGISTRATION is true, then users can freely create an account
# at this instance. If email verification is available, users must confirm
# their email before the account is created.
ENABLE_REGISTRATION = true
# If HIDE_INVALID_LOGINS is set to true (the default), when a user
# tries to sign in with an invalid login, a message is shown indicating
# that the password is wrong, but does not give a clue wether the login
# exists or not.
# If HIDE_INVALID_LOGINS is set to false, when a user tries to sign in with
# an invalid login, a message is shown indicating that the login does not
# exist.
# HIDE_INVALID_LOGINS = true
# If ENABLE_PASSWORD_RECOVERY is false, then users cannot ask for a password
# recovery link by email. This option is true by default.
# ENABLE_PASSWORD_RECOVERY = true
# The validity duration of registration invitations, in seconds.
# Defaults to 2 days
# INVITATION_EXPIRATION = 172800
[LOGGING]
# LEVEL can be one value among:
# DEBUG, INFO, WARNING, ERROR, CRITICAL
# Defaults to WARNING
# LEVEL = "WARNING"
LEVEL = "DEBUG"
# The path of the log file. If not set (the default) logs are
# written in the standard error output.
# PATH = ""
# [BACKENDS.LDAP]
# URI = "ldap://localhost"
# ROOT_DN = "dc=mydomain,dc=tld"
# BIND_DN = "cn=admin,dc=mydomain,dc=tld"
# BIND_PW = "admin"
# TIMEOUT = 10
# Where to search for users?
# USER_BASE = "ou=users,dc=mydomain,dc=tld"
# The object class to use for creating new users
# USER_CLASS = "inetOrgPerson"
# The attribute to identify an object in the User dn.
# USER_RDN = "uid"
# Filter to match users on sign in. Jinja syntax is supported
# and a `login` variable is available containing the value
# passed in the login field.
# USER_FILTER = "(|(uid={{ login }})(mail={{ login }}))"
# Where to search for groups?
# GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
# The object class to use for creating new groups
# GROUP_CLASS = "groupOfNames"
# The attribute to identify an object in the User dn.
# GROUP_RDN = "cn"
# The attribute to use to identify a group
# GROUP_NAME_ATTRIBUTE = "cn"
[ACL]
# You can define access controls that define what users can do on canaille
# An access control consists in a FILTER to match users, a list of PERMISSIONS
# matched users will be able to perform, and fields users will be able
# to READ and WRITE. Users matching several filters will cumulate permissions.
#
# 'FILTER' parameter can be:
# - absent, in which case all the users will match this access control
# - a mapping where keys are user attributes name and the values those user
# attribute values. All the values must be matched for the user to be part
# of the access control.
# - a list of those mappings. If a user values match at least one mapping,
# then the user will be part of the access control
#
# Here are some examples
# FILTER = {user_name = 'admin'}
# FILTER =
# - {groups = 'admins'}
# - {groups = 'moderators'}
#
# The 'PERMISSIONS' parameter that is an list of items the users in the access
# control will be able to manage. 'PERMISSIONS' is optionnal. Values can be:
# - "edit_self" to allow users to edit their own profile
# - "use_oidc" to allow OpenID Connect authentication
# - "manage_oidc" to allow OpenID Connect client managements
# - "manage_users" to allow other users management
# - "manage_groups" to allow group edition and creation
# - "delete_account" allows a user to delete his own account. If used with
# manage_users, the user can delete any account
# - "impersonate_users" to allow a user to take the identity of another user
#
# The 'READ' and 'WRITE' attributes are the LDAP attributes of the user
# object that users will be able to read and/or write.
[ACL.DEFAULT]
PERMISSIONS = ["edit_self", "use_oidc"]
READ = [
"user_name",
"groups",
"lock_date",
]
WRITE = [
"photo",
"given_name",
"family_name",
"display_name",
"password",
"phone_numbers",
"emails",
"profile_url",
"formatted_address",
"street",
"postal_code",
"locality",
"region",
"preferred_language",
"employee_number",
"department",
"title",
"organization",
]
[ACL.ADMIN]
FILTER = {groups = "admins"}
PERMISSIONS = [
"manage_users",
"manage_groups",
"manage_oidc",
"delete_account",
"impersonate_users",
]
WRITE = [
"groups",
"lock_date",
]
[ACL.HALF_ADMIN]
FILTER = {groups = "moderators"}
PERMISSIONS = ["manage_users", "manage_groups", "delete_account"]
WRITE = ["groups"]
# The jwt configuration. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
[OIDC]
# Wether a token is needed for the RFC7591 dynamical client registration.
# If true, no token is needed to register a client.
# If false, dynamical client registration needs a token defined
# in DYNAMIC_CLIENT_REGISTRATION_TOKENS
DYNAMIC_CLIENT_REGISTRATION_OPEN = true
# A list of tokens that can be used for dynamic client registration
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
"xxxxxxx-yyyyyyy-zzzzzz",
]
# REQUIRE_NONCE force the nonce exchange during the authentication flows.
# This adds security but may not be supported by all clients.
# REQUIRE_NONCE = true
[OIDC.JWT]
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
# the public key. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
# If the variables are unset, and debug mode is enabled,
# a in-memory keypair will be used.
# PRIVATE_KEY_FILE = "/path/to/private.pem"
# PUBLIC_KEY_FILE = "/path/to/public.pem"
# The URI of the identity provider
# ISS = "https://auth.mydomain.tld"
# The key type parameter
# KTY = "RSA"
# The key algorithm
# ALG = "RS256"
# The time the JWT will be valid, in seconds
# EXP = 3600
[OIDC.JWT.MAPPING]
# Mapping between JWT fields and LDAP attributes from your
# User objectClass.
# {attribute} will be replaced by the user ldap attribute value.
# Default values fits inetOrgPerson.
# SUB = "{{ user.user_name[0] }}"
# NAME = "{{ user.formatted_name[0] }}"
# PHONE_NUMBER = "{{ user.phone_numbers[0] }}"
# EMAIL = "{{ user.preferred_email }}"
# GIVEN_NAME = "{{ user.given_name[0] }}"
# FAMILY_NAME = "{{ user.family_name[0] }}"
# PREFERRED_USERNAME = "{{ user.display_name }}"
# LOCALE = "{{ user.preferred_language }}"
# ADDRESS = "{{ user.formatted_address[0] }}"
# PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}"
# WEBSITE = "{{ user.profile_url[0] }}"
# The SMTP server options. If not set, mail related features such as
# user invitations, and password reset emails, will be disabled.
[SMTP]
# HOST = "localhost"
# PORT = 25
# TLS = false
# SSL = false
# LOGIN = ""
# PASSWORD = ""
# FROM_ADDR = "admin@mydomain.tld"

View file

@ -0,0 +1,75 @@
---
version: "3"
services:
ldap:
image: osixia/openldap
environment:
- LDAP_DOMAIN=mydomain.tld
volumes:
# memberof overlay is already present in openldap docker image but only for groupOfUniqueNames. We need to overwrite it (until canaille can handle groupOfUniqueNames).
# https://github.com/osixia/docker-openldap/blob/master/image/service/slapd/assets/config/bootstrap/ldif/03-memberOf.ldif
- ./ldif/memberof-config.ldif:/container/service/slapd/assets/config/bootstrap/ldif/03-memberOf.ldif:ro
- ./ldif/refint-config.ldif:/container/service/slapd/assets/config/bootstrap/ldif/04-refint.ldif:ro
- ../canaille/backends/ldap/schemas/oauth2-openldap.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/40-oauth2.ldif:ro
- ./ldif/ppolicy-config.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/30-ppolicy.ldif:ro
- ./ldif/ppolicy.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/40-ppolicy.ldif:ro
- ./ldif/bootstrap-users-tree.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/50-bootstrap-users-tree.ldif:ro
- ./ldif/bootstrap-oidc-tree.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/50-bootstrap-oidc-tree.ldif:ro
command: --copy-service --loglevel debug
ports:
- 5389:389
- 5636:636
canaille:
depends_on:
- ldap
build:
context: ..
dockerfile: demo/Dockerfile-canaille
environment:
- AUTHLIB_INSECURE_TRANSPORT=1
- FLASK_DEBUG=1
- CONFIG=/opt/canaille/conf/canaille-ldap.toml
- FLASK_APP=demoapp
volumes:
- ../canaille:/opt/canaille/canaille
- ./conf-docker:/opt/canaille/conf
ports:
- 5000:5000
client1:
depends_on:
- canaille
build:
context: .
dockerfile: Dockerfile-client
environment:
- FLASK_DEBUG=1
- CONFIG=/opt/client/conf/client1.cfg
- FLASK_APP=client
volumes:
- ./client:/opt/client/client
- ./conf-docker:/opt/client/conf
- ../canaille/static:/opt/canaille/static
command: --port=5001
ports:
- 5001:5001
client2:
depends_on:
- canaille
build:
context: .
dockerfile: Dockerfile-client
environment:
- FLASK_DEBUG=1
- CONFIG=/opt/client/conf/client2.cfg
- FLASK_APP=client
volumes:
- ./client:/opt/client/client
- ./conf-docker:/opt/client/conf
- ../canaille/static:/opt/canaille/static
command: --port=5002
ports:
- 5002:5002

View file

@ -0,0 +1,54 @@
---
version: "3"
services:
canaille:
build:
context: ..
dockerfile: demo/Dockerfile-canaille
environment:
- AUTHLIB_INSECURE_TRANSPORT=1
- FLASK_DEBUG=1
- CONFIG=/opt/canaille/conf/canaille-memory.toml
- FLASK_APP=demoapp
volumes:
- ../canaille:/opt/canaille/canaille
- ./conf-docker:/opt/canaille/conf
ports:
- 5000:5000
client1:
depends_on:
- canaille
build:
context: .
dockerfile: Dockerfile-client
environment:
- FLASK_DEBUG=1
- CONFIG=/opt/client/conf/client1.cfg
- FLASK_APP=client
volumes:
- ./client:/opt/client/client
- ./conf-docker:/opt/client/conf
- ../canaille/static:/opt/canaille/static
command: --port=5001
ports:
- 5001:5001
client2:
depends_on:
- canaille
build:
context: .
dockerfile: Dockerfile-client
environment:
- FLASK_DEBUG=1
- CONFIG=/opt/client/conf/client2.cfg
- FLASK_APP=client
volumes:
- ./client:/opt/client/client
- ./conf-docker:/opt/client/conf
- ../canaille/static:/opt/canaille/static
command: --port=5002
ports:
- 5002:5002

View file

@ -1,75 +0,0 @@
---
version: "3"
services:
ldap:
image: osixia/openldap
environment:
- LDAP_DOMAIN=mydomain.tld
volumes:
# memberof overlay is already present in openldap docker image but only for groupOfUniqueNames. We need to overwrite it (until canaille can handle groupOfUniqueNames).
# https://github.com/osixia/docker-openldap/blob/master/image/service/slapd/assets/config/bootstrap/ldif/03-memberOf.ldif
- ./ldif/memberof-config.ldif:/container/service/slapd/assets/config/bootstrap/ldif/03-memberOf.ldif:ro
- ./ldif/refint-config.ldif:/container/service/slapd/assets/config/bootstrap/ldif/04-refint.ldif:ro
- ../canaille/backends/ldap/schemas/oauth2-openldap.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/40-oauth2.ldif:ro
- ./ldif/ppolicy-config.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/30-ppolicy.ldif:ro
- ./ldif/ppolicy.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/40-ppolicy.ldif:ro
- ./ldif/bootstrap-users-tree.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/50-bootstrap-users-tree.ldif:ro
- ./ldif/bootstrap-oidc-tree.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/50-bootstrap-oidc-tree.ldif:ro
command: --copy-service --loglevel debug
ports:
- 5389:389
- 5636:636
canaille:
depends_on:
- ldap
build:
context: ..
dockerfile: demo/Dockerfile-canaille
environment:
- AUTHLIB_INSECURE_TRANSPORT=1
- FLASK_DEBUG=1
- CONFIG=/opt/canaille/conf/canaille.toml
- FLASK_APP=demoapp
volumes:
- ../canaille:/opt/canaille/canaille
- ./conf-docker:/opt/canaille/conf
ports:
- 5000:5000
client1:
depends_on:
- canaille
build:
context: .
dockerfile: Dockerfile-client
environment:
- FLASK_DEBUG=1
- CONFIG=/opt/client/conf/client1.cfg
- FLASK_APP=client
volumes:
- ./client:/opt/client/client
- ./conf-docker:/opt/client/conf
- ../canaille/static:/opt/canaille/static
command: --port=5001
ports:
- 5001:5001
client2:
depends_on:
- canaille
build:
context: .
dockerfile: Dockerfile-client
environment:
- FLASK_DEBUG=1
- CONFIG=/opt/client/conf/client2.cfg
- FLASK_APP=client
volumes:
- ./client:/opt/client/client
- ./conf-docker:/opt/client/conf
- ../canaille/static:/opt/canaille/static
command: --port=5002
ports:
- 5002:5002

1
demo/docker-compose.yml Symbolic link
View file

@ -0,0 +1 @@
docker-compose-memory.yml

View file

@ -1,20 +1,20 @@
#!/bin/bash
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
if ! type slapd > /dev/null 2>&1; then
echo "Cannot start the LDAP server. Please install OpenLDAP on your system"
echo "or run the demo with docker-compose."
exit 1
if [ "$1" = "--backend" -a -n "$2" ]; then
BACKEND="$2"
else
BACKEND="memory"
fi
if ! type python > /dev/null 2>&1 && ! type python3 > /dev/null 2>&1; then
echo "Cannot start the LDAP server. Please install python on your system"
echo "Cannot start the canaille demo server. Please install python on your system"
echo "or run the demo with docker-compose."
exit 1
fi
if ! type poetry > /dev/null 2>&1; then
echo "Cannot start the LDAP server. Please install poetry on your system"
echo "Cannot start the canaille demo server. Please install poetry on your system"
echo "or run the demo with docker-compose."
echo "https://python-poetry.org/docs/#installation"
exit 1
@ -23,5 +23,25 @@ fi
poetry install --with demo --without dev
pushd "$DIR" > /dev/null 2>&1 || exit
env poetry run honcho start
if [ "$BACKEND" = "memory" ]; then
env poetry run honcho --procfile Procfile-memory start
elif [ "$BACKEND" = "ldap" ]; then
if ! type slapd > /dev/null 2>&1; then
echo "Cannot start the canaille demo server. Please install OpenLDAP on your system"
echo "or run the demo with docker-compose."
exit 1
fi
env poetry run honcho --procfile Procfile-ldap start
else
echo "Usage: run.sh --backend [memory|ldap]"
fi
popd || exit

View file

@ -4,6 +4,12 @@ Backends
.. contents::
:local:
Memory
======
Canaille comes with a lightweight inmemory backend by default.
This backend is only for test purpose and should not be used in production environments.
LDAP
====

View file

@ -6,10 +6,10 @@ as in *Can I access your data?* Canaille is a simple OpenID Connect provider bas
It aims to be very light, simple to install and simple to maintain. Its main features are :
- Authentication and user profile edition against a LDAP directory;
- Registration, email confirmation, "I forgot my password" emails;
- Only OpenID Connect: no outdated or exotic protocol support;
- No additional database required: everything is stored in your LDAP server;
- User profile and groups management;
- Authentication, registration, email confirmation, "I forgot my password" emails;
- OpenID Connect identity provider;
- LDAP first-class citizenship;
- Customizable, themable;
- The code is easy to read and easy to edit!

View file

@ -7,11 +7,6 @@ from canaille.app.flask import set_parameter_in_url_query
from flask_webtest import TestApp
@pytest.fixture
def configuration(ldap_configuration):
yield ldap_configuration
def test_set_parameter_in_url_query():
assert (
set_parameter_in_url_query("https://auth.mydomain.tld", foo="bar")

View file

View file

@ -0,0 +1,9 @@
import pytest
from canaille.backends.memory.backend import Backend
@pytest.fixture
def memory_backend(configuration):
backend = Backend(configuration)
with backend.session():
yield backend

View file

@ -0,0 +1,7 @@
from canaille.commands import cli
def test_install_does_nothing(testclient):
runner = testclient.app.test_cli_runner()
res = runner.invoke(cli, ["install"])
assert res.exit_code == 0, res.stdout

View file

@ -151,9 +151,9 @@ def test_fuzzy(user, moderator, admin, backend):
# def test_model_references(user, admin, foo_group, bar_group):
def test_model_references(testclient, user, foo_group, admin, bar_group, backend):
assert foo_group.members == [user]
assert user.groups == [foo_group]
assert foo_group in models.Group.query(members=user)
# assert foo_group.members == [user]
# assert user.groups == [foo_group]
# assert foo_group in models.Group.query(members=user)
assert user in models.User.query(groups=foo_group)
assert user not in bar_group.members
@ -178,7 +178,7 @@ def test_model_references_set_unsaved_object(
):
group = models.Group(members=[user], display_name="foo")
group.save()
user.reload() # an LDAP group can be inconsistent by containing members which doesn't exist
user.reload() # LDAP groups can be inconsistent by containing members which doesn't exist
non_existent_user = models.User(
formatted_name="foo", family_name="bar", user_name="baz"