Developer's Guide (wazo-auth)
Architecture
wazo-auth contains 3 major components, an HTTP interface, authentication backends and a storage module. All operations are made through the HTTP interface, tokens are stored in postgres as well as the persistence for some of the data attached to tokens. Backends are used to test if a supplied username/password combination is valid and provide the xivo-user-uuid.
wazo-auth is made of the following modules and packages.
backend_plugins
the plugin package contains the wazo-auth backends that are packaged with wazo-auth.
http_plugins
The http module is the implementation of the HTTP interface.
- Validate parameters
- Calls the backend the check the user authentication
- Forward instructions to the token_manager
- Handle exceptions and return the appropriate status_code
controller
The controller is the plumbing of wazo-auth, it has no business logic.
- Start the HTTP application
- Load all enabled plugins
- Instantiate the token_manager
token
The token modules contains the business logic of wazo-auth.
- Creates and delete tokens
- Creates ACL for Wazo
- Schedule token expiration
Plugins
wazo-auth is meant to be easy to extend. This section describes how to add features to wazo-auth.
Backends
wazo-auth allows its administrator to configure one or many sources of authentication. Implementing a new kind of authentication is quite simple.
- Create a python module implementing the backend interface.
- Install the python module with an entry point
wazo_auth.backends
An example backend implementation is available here.
External Auth
wazo-auth allows the user to enable arbitrary external authentication, store sensible information which can be retrieved later given an appropriate ACL.
An external authentication plugin is made of the following parts.
- A setup.py adding the plugin the
wazo_auth.http
entry point - A flask_restful class implementing the route for this plugin
- A marshmallow model that can filter the stored data to be safe for unprivileged view
- A plugin_info dictionary with information that should be displayed in UI concerning this plugin
The restful class should do the following:
- POST: This is where the plugin should setup any information with the external service and usually return a validation code and a validation URL to the user.
- GET: After activating the external authentication, following the POST. The GET can be used to retrieve credentials granting access to certain resource of the external service.
- DELETE: Should remove the stored data from wazo-auth
- PUT: (optional) Could be implemented to modify the scope of the generated credentials if the external service allow that kind of modification.
OAuth2 helpers
If the external service uses OAuth2 it is possible to use some helper functions in the external_auth service.
Those helpers can be used to get notified when the user has accepted wazo-auth on the external service.
The following helpers are available:
external_auth_service.register_oauth2_callback(auth_type, user_uuid, state, callback, *args, **kwargs)
- auth_type: The name of the authentication backend
- user_uuid: The user UUID of the user creating the external auth
- state: The state returned from the authorization URL query
- callback: the callable that should be triggered when the authorization is complete
- args and kwargs: arguments that will be added to the callback arguments
When the callback function gets called, its last args will be the message sent to the redirect URL by the external service.
Note: The callback is not executed in the main thread. You should take care of thread synchronization when sharing data structures between threads.
The callback is usually used to create a first token on the external service.
external_auth_service.build_oauth2_redirect_url(auth_type)
This helper returns a URL that can be used by the OAuth2Session to trigger a redirection and receives a callback when the authorization is complete.
Example
setup.py
src/plugin.py
#!/usr/bin/env python3
from setuptools import find_packages
from setuptools import setup
setup(
name='auth_bar',
version='0.1',
packages=find_packages(),
entry_points={
'wazo_auth.external_auth': [
'bar = src.plugin:BarPlugin',
],
}
)
from marshmallow import Schema, fields, pre_load
from flask import request
from wazo_auth import http
class BarService(http.AuthResource):
auth_type = 'bar' # Should be the same as the entry point
authorization_base_url = 'https://accounts.bar.com/oauth/v2/auth'
token_url = 'https://accounts.bar.com/oauth/v2/token'
client_id = 'client_id'
client_secret = 'client_secret'
def __init__(self, external_auth_service):
self.external_auth_service = external_auth_service
self.redirect_uri = self.external_auth_service.build_oauth2_redirect_url(self.auth_type)
@http.required_acl('auth.users.{user_uuid}.external.bar.delete')
def delete(self, user_uuid):
# Remove all stored data for the BAR service for this user
self.external_auth_service.delete(user_uuid, self.auth_type)
return '', 204
@http.required_acl('auth.users.{user_uuid}.external.bar.read')
def get(self, user_uuid):
# The GET retrieves all stored data from the service and return the secret that is
# required to use the Bar service
# The GET will also need to generate a new token if the current one has expired.
return self.external_auth_service.get(user_uuid, self.auth_type), 200
@http.required_acl('auth.users.{user_uuid}.external.bar.create')
def post(self, user_uuid):
session = OAuth2Session(self.client_id, scope=self.scope, redirect_uri=self.redirect_uri)
# Should use the body of the POST and create a token with the Bar service
data = request.get_json(force=True)
authorization_url, state = session.authorization_url(
self.authorization_base_url,
access_type='offline',
)
self.external_auth_service.register_oauth2_callback(
state,
self.create_first_token,
session,
user_uuid,
)
return {'authorization_url': authorization_url}, 201
def create_first_token(self, session, user_uuid, msg):
# This callback is triggered when the user authorize wazo-auth using the authorization_url
token_data = session.fetch_token(
self.token_url,
client_secret=self.client_secret['us'],
code=msg['code'],
)
data = {
'access_token': token_data['access_token'],
'refresh_token': token_data.get('refresh_token'),
'token_expiration': get_timestamp_expiration(token_data['expires_in'])
}
self.external_auth_service.update(user_uuid, self.auth_type, data)
# When GET /users/:uuid/external is called this model will be used to filter the private data
class BarSafeData(Schema):
# Only the scope field will be returned
scope = fields.List(fields.String)
@pre_load
def ensure_dict(self, data):
return data or {}
class BarPlugin(object):
plugin_info = {'required_acl': ['view-all-contacts', 'list-email-addresses']}
def load(self, dependencies):
api = dependencies['api']
external_auth_service = dependencies['external_auth_service']
args = (external_auth_service,)
# If the plugin does not register a safe mode an empty dictionary will be used when doing
# a GET /users/:uuid/external
external_auth_service.register_safe_auth_model('bar', BarSafeData)
api.add_resource(BarService, '/users/<uuid:user_uuid>/external/bar', resource_class_args=args)
Email Notification
By default wazo-auth
implement an email notification plugin to send email through SMTP protocol.
Implementing a new kind of email notification can be done by:
- Create a python module implementing the BaseEmailNotification interface.
- Install the python module with an entry point
wazo_auth.email_notification
. - Add configuration to use the new
email_notification
plugin.
Example
setup.py
src/plugin.py
/etc/wazo-auth/conf.d/email_notification.yml
#!/usr/bin/env python3
from setuptools import find_packages, setup
setup(
name='auth_email_notification_proxy',
version='0.1',
packages=find_packages(),
entry_points={
'wazo_auth.email_notification': [
'proxy = src.plugin:ProxyEmail',
],
}
)
import requests
from wazo_auth.interfaces import BaseEmailNotification
class ProxyEmail(BaseEmailNotification):
def __init__(self, config: dict, **kwargs: dict) -> None:
self.proxy_confirmation_url = config['proxy_confirmation_url']
self.proxy_password_reset_url = config['proxy_password_reset_url']
def send_confirmation(self, context: dict) -> None:
requests.post(self.proxy_confirmation_url, json=context)
def send_password_reset(self, context: dict) -> None:
requests.post(self.proxy_password_reset_url, json=context)
email_notification_plugin: proxy
proxy_confirmation_url: confirmation.example.com
proxy_password_reset_url: password_reset.example.com
IdP plugins
Note
IdP comes from an abbreviation of "Identity Provider"
The wazo_auth.idp
entrypoint namespace is supported to register alternative authentication
mechanisms to those supported by default.
An IdP plugin can implement a specific mechanism of authentication for some login requests. This would allow, for example, checking credentials from a login request by querying a third party API.
This plugin interface is complemental to the wazo_auth.backend
plugin interface, as
the authentication mechanism of an IdP must result in the selection of an appropriate
wazo_auth.backend
implementation, along with the wazo user identity(wazo username or email
address) which is being authenticated.
- IdP plugin interface deals with authentication at a more abstract level, by leaving the plugin to
process the login request, without imposing expectations such as the presence of a
username/password credential; an IdP plugin authentication mechanism may rely on a
wazo_auth.backend
verify_password
implementation, or not; wazo_auth.backend
plugins are used both to authenticate the username/password credential of a login request, as well as to provide the ACLs & metadata of the generated access token; IdP plugins cannot affect the ACLs & metadata resulting from a successful login other than by selecting the appropriatewazo_auth.backend
implementation;- an IdP plugin can elect to handle a login request, and can choose(statically or dynamically) which
wazo_auth.backend
implementation to use in the authentication process; this enables newwazo_auth.backend
implementations to be used without modifying the core wazo-auth code.
Interface
The plugin interface is defined in the wazo-auth codebase as a python class.
class IDPPluginDependencies(TypedDict, total=False):
backends: Mapping[str, Extension]
...
class IDPPlugin(Protocol):
loaded: bool = False
"""
Indicates that the plugin has been fully loaded successfully
(load method executed completely without errors)
"""
authentication_method: str
"""
identifier for this auth method
"""
def load(self, dependencies: IDPPluginDependencies):
"""
Perform required initialization logic,
such as extracting dependencies into instance attributes,
and set `self.loaded` to `True`
"""
...
def can_authenticate(self, args: dict) -> bool:
"""
Evaluate if this plugin is applicable to a login request based on login request args
Returns true if the plugin can authenticate the login request
based on the login request information, false otherwise.
"""
...
def verify_auth(self, args: dict) -> tuple[BaseAuthenticationBackend, str]:
"""
Verify(authenticate) login request and return an authentication backend and a login string
"""
...
load(self, dependencies: IDPPluginDependencies) -> None
: this method is called on an instance of the plugin class, once at startup of the wazo-auth service;
this should perform any initialization logic necessary for future invocations of the other methods, such as acquiring resources and references to useful dependencies;
thedependencies
argument is a dictionary containing other code components that may be useful to the plugin implementation, such as thebackends
dictionary containing the availablewazo_auth.backends
plugins, and service objects to interact with core wazo-auth resources such as users, tenants, etc;can_authenticate(self, args: dict) -> bool
: this method is called from a successfully loaded plugin object, to evaluate if the plugin deems itself capable of authenticating the request described by theargs
dictionary;args
contains various request attributes, such as thelogin
andpassword
taken from "Basic" HTTP authentication(HTTP header of the formAuthorization: Basic <base64(username:password)>
); theflask.request
global proxy object may be imported and used for direct access to the http request, including any header and the raw request body, if necessary;
NOTE: it is important that the implementation of this method be efficient and performant, ideally only looking at request attributes, and not performing any additional API call or database query, as this method is called on each enabled IdP plugin for each login request, so any latency incurred by this method will affect all login requests;verify_auth(self, args: dict) -> tuple[BaseAuthenticationBackend, str]
: this method is called from a successfully loaded plugin object, for any login request for which this plugin'scan_authenticate
call was the first to evaluate toTrue
, in order to perform any authentication logic required to verify if the login request is valid and deserves a successful response; any issue found with the login request that should prevent a successful authentication should raise an appropriate exception that can be interpreted into an appropriate HTTP response, such aswazo_auth.exceptions.InvalidLoginRequest
;
on successful authentication, this method should return a tuple of two values, the first being awazo_auth.backend
instance which can be used to provide the ACLs and metadata for the access token that will be generated, and the second value being a login string that identifies the wazo user being logged in, either the wazo username or an active email address associated to the wazo user;authentication_method
: an attribute(usually a static class attribute) that defines a string identifying the authentication method implemented by this IdP; thisauthentication_method
may be associated to wazo-auth tenants and users in order to constrain tenants and users to the use of a specific authentication mechanism; theauthentication_method
of all IdP plugins appear as part of the response to aGET /0.1/idp
request, enabling a wazo-auth client to discover the available authentication methods;
If an IdP plugin successfully matches and authenticates a login request, the login string returned
as the second tuple value from the call to verify_auth
is used to verify that the corresponding
wazo-auth user is configured with the authentication_method
implemented by the plugin. Only users
or tenants configured with the authentication method of an IdP plugin can successfully authenticate
through that IdP plugin.
Warning
An IdP plugin can be made authoritative in authenticating any login request, which means that a particular IdP implementation can make or break any and all authentication to the Wazo platform deployment.
Be careful to load only trusted IdP plugins, and properly test an IdP plugin implementation before deploying it to a production system.
Example
A simple IdP plugin that authenticates requests based on a custom header(which could contain a pre-negotiated API key, or a shared secret, or a JWT token, etc):
from flask import request
class MyIDPPlugin:
loaded False
authentication_method = 'my_idp'
def load(self, dependencies: IDPPluginDependencies):
self.backend = dependencies['backends']['wazo_user']
self.user_service = dependencies['user_service']
self.loaded = True
def can_authenticate(self, args: dict) -> bool:
# any request attribute can be accessed using flask.request
# for example to check for a custom header
return bool(request.headers.get('X-My-IdP-Header'))
def verify_auth(self, args: dict) -> tuple[BaseAuthenticationBackend, str]:
auth_header = request.headers.get('X-My-IdP-Header')
assert auth_header
# validate header or raise exception
info = self._verify_my_idp_header(auth_header)
args.update(info)
# either HTTP basic auth is used with this method, and `login` comes from the basic auth username
# else the plugin must obtain a `login` value corresponding to the wazo username or registered email address
return self.backend, args['login']
def _verify_my_idp_header(self, header: str) -> dict:
# may perform a database lookup for a user matching the header
# or decode the header into something that can identify the user
# or call out an external API service to validate the header
# and identify the user
...
return user_info
Diagrams
A flow chart describing the authentication process in the presence of IdP plugins:
A high-level sequence diagram describing the authentication process in the presence of IdP plugins:
A more focused diagram on the details of the authentication mechanism