Authentication and Access control
Warning: the API described in this chapter is new and subject to changes. Make sure you keep your code up to date
py4web comes with a an object Auth and a system of plugins for user authentication and access control. It has the same name as the corresponding web2py one and serves the same purpose but the API and internal design is very different.
To use it, first of all you need to import it, instantiate it, configure it, and enable it.
from py4web.utils.auth import Auth
auth = Auth(session, db)
# (configure here)
auth.enable()
The import step is obvious. The second step does not perform any
operation other than telling the Auth object which session object to use
and which database to use. Auth data is stored in session['user']
and, if a user is logged in, the user id is stored in
session[‘user’][‘id’]. The db object is used to store persistent info
about the user in a table auth_user
with the following fields:
username
email
password
first_name
last_name
sso_id (used for single sign on, see later)
action_token (used to verify email, block users, and other tasks, also see later).
If the auth_user
table does not exist it is created.
The configuration step is optional and discussed later.
The auth.enable()
step creates and exposes the following RESTful
APIs:
{appname}/auth/api/register (POST)
{appname}/auth/api/login (POST)
{appname}/auth/api/request_reset_password (POST)
{appname}/auth/api/reset_password (POST)
{appname}/auth/api/verify_email (GET, POST)
{appname}/auth/api/logout (GET, POST) (+)
{appname}/auth/api/profile (GET, POST) (+)
{appname}/auth/api/change_password (POST) (+)
{appname}/auth/api/change_email (POST) (+)
Those marked with a (+) require a logged in user.
Auth UI
You can create your own web UI to login users using the above APIs but py4web provides one as an example, implemented in the following files:
_scaffold/templates/auth.html
_scaffold/static/components/auth.js
_scaffold/static/components/auth.html
The component files (js/html) define a Vue component <auth/>
which
is used in the template file auth.html as follows:
[[extend "layout.html"]]
<div id="vue">
<div class="columns">
<div class="column is-half is-offset-one-quarter" style="border : 1px solid #e1e1e1; border-radius: 10px">
<auth plugins="local,oauth2google,oauth2facebook"></auth>
</div>
</div>
</div>
[[block page_scripts]]
<script src="js/utils.js"></script>
<script src="components/auth.js"></script>
<script>utils.app().start();</script>
[[end]]
You can pretty much use this file un-modified. It extends the current
layout and embeds the <auth/>
component into the page. It then uses
utils.app().start();
(py4web magic) to render the content of
<div id="vue">...</div>
using Vue.js. components/auth.js
also
automatically loads components/auth.html
into the component
placeholder (more py4web magic). The component is responsible for
rendering the login/register/etc forms using reactive html and
GETing/POSTing data to the Auth service APIs.
If you need to change the style of the component you can edit
“components/auth.html” to suit your needs. It is mostly HTML with some
special Vue v-*
tags.
Using Auth
There two ways to use the Auth object in an action:
@action('index')
@action.uses(auth)
def index():
user = auth.get_user()
return 'hello {first_name}'.format(**user) if user else 'not logged in'
With @action.uses(auth)
we tell py4web that this action needs to
have information about the user, then try to parse the session for a
user session.
@action('index')
@action.uses(auth.user)
def index():
user = auth.get_user()
return 'hello {first_name}'.format(**user)'
Here @action.uses(auth.user)
tells py4web that this action requires
a logged in user and should redirect to login if no user is logged in.
Auth Plugins
Plugins are defined in “py4web/utils/auth_plugins” and they have a hierachical structure. Some are exclusive and some are not. For example, default, LDAP, PAM, and SAML are exclusive (the developer has to pick one). Default, Google, Facebook, and Twitter OAuth are not exclusive (the developer can pick them all and the user gets to choose using the UI).
The <auth/>
components will automatically adapt to display login
forms as required by the installed plugins.
At this time we cannot guarantee that the following plugins work well. They have been ported from web2py where they do work but testing is still needed
PAM
Configuring PAM is the easiest:
from py4web.utils.auth_plugins.pam_plugin import PamPlugin
auth.register_plugin(PamPlugin())
This one like all plugins must be imported and registered. Once registered the UI (components/auth) and the RESTful APIs know how to handle it. The constructor of this plugins does not require any arguments (where other plugins do).
The auth.register_plugin(...)
must come before the
auth.enable()
since it makes no sense to expose APIs before desired
plugins are mounted.
LDAP
from py4web.utils.auth_plugins.ldap_plugin import LDAPPlugin
LDAP_SETTING = {
'mode': 'ad',
'server': 'my.domain.controller',
'base_dn': 'ou=Users,dc=domain,dc=com'
}
auth.register_plugin(LDAPPlugin(**LDAP_SETTINGS))
OAuth2 with Google (tested OK)
from py4web.utils.auth_plugins.oauth2google import OAuth2Google # TESTED
auth.register_plugin(OAuth2Google(
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
callback_url='auth/plugin/oauth2google/callback'))
The client id and client secret must be provided by Google.
OAuth2 with Facebook (tested OK)
from py4web.utils.auth_plugins.oauth2facebook import OAuth2Facebook # UNTESTED
auth.register_plugin(OAuth2Facebook(
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
callback_url='auth/plugin/oauth2google/callback'))
The client id and client secret must be provided by Facebook.
Tags and Permissions
Py4web does not have the concept of groups as web2py does. Experience showed that while that mechanism is powerful it suffers from two problems: it is overkill for most apps, and it is not flexible enough for very complex apps. Py4web provides a general purpose tagging mechanism that allows the developer to tag any record of any table, check for the existence of tags, as well as checking for records containing a tag. Group membership can be thought of a type of tag that we apply to users. Permissions can also be tags. Developer are free to create their own logic on top of the tagging system.
To use the tagging system you need to create an object to tag a table:
groups = Tags(db.auth_user)
Then you can add one or more tags to records of the table as well as remove existing tags:
groups.add(user.id, 'manager')
groups.add(user.id, ['dancer', 'teacher'])
groups.remove(user.id, 'dancer')
Here the use case is group based access control where the developer
first checks if a user is a member of the 'manager'
group, if the
user is not a manager (or no one is logged in) py4web redirects to the
'not authorized url'
. If the user is in the correct group then
py4web displays ‘hello manager’:
@action('index')
@action.uses(auth.user)
def index():
if not 'manager' in groups.get(auth.get_user()['id']):
redirect(URL('not_authorized'))
return 'hello manager'
Here the developer queries the db for all records having the desired tag(s):
@action('find_by_tag/{group_name}')
@action.uses(db)
def find(group_name):
users = db(groups.find([group_name])).select(orderby=db.auth_user.first_name | db.auth_user.last_name)
return {'users': users}
We leave it to you as an exercise to create a fixture has_membership
to enable the following syntax:
@action('index')
@action.uses(has_membership(groups, 'teacher'))
def index():
return 'hello teacher'
Important: Tags
are automatically hierarchical. For example, if
a user has a group tag ‘teacher/high-school/physics’, then all the
following seaches will return the user:
groups.find('teacher/high-school/physics')
groups.find('teacher/high-school')
groups.find('teacher')
This means that slashes have a special meaning for tags. Slahes at the beginning or the end of a tag are optional. All other chars are allowed on equal footing.
Notice that one table can have multiple associated Tags
objects. The
name groups here is completely arbitary but has a specific semantic
meaning. Different Tags
objects are orthogonal to each other. The
limit to their use is your creativity.
For example you could create a table groups:
db.define_table('auth_group', Field('name'), Field('description'))
and to Tags:
groups = Tags(db.auth_user)
permissions = Tags(db.auth_groups)
Then create a zapper group, give it a permission, and make a user member of the group:
zap_id = db.auth_group.insert(name='zapper', description='can zap database')
permissions.add(zap_id, 'zap database')
groups.add(user.id, 'zapper')
And you can check for a user permission via an explicit join:
@action('zap')
@action.uses(auth.user)
def zap():
user = auth.get_user()
permission = 'zap database'
if db(permissions.find(permission))(
db.auth_group.name.belongs(groups.get(user['id']))
).count():
# zap db
return 'database zapped'
else:
return 'you do not belong to any group with permission to zap db'
Notice here permissions.find(permission)
generates a query for all
groups with the permission and we further filter those groups for those
the current user is member of. We count them and if we find any, then
the user has the permission.