Create working demo (#5)

* Add docs example and update demo source code

* Fail with debug

* Fix tests

* Update sql scripts

* Update docs

* Add checking for the password

* Update documentation with launch/usage example

* Launch flake8 instead of pep8 and pyflakes
This commit is contained in:
Misha Behersky 2016-08-30 20:38:59 +03:00 committed by Andrew Svetlov
parent fec22f971c
commit 820dcc8d93
17 changed files with 415 additions and 60 deletions

View File

@ -9,8 +9,7 @@ install:
- pip install coveralls - pip install coveralls
script: script:
- pyflakes aiohttp_security tests - flake8 aiohttp_security tests
- pep8 aiohttp_security tests
- coverage run --source=aiohttp_security setup.py test - coverage run --source=aiohttp_security setup.py test
after_success: after_success:

View File

@ -9,6 +9,14 @@ __ aiohttp_web_
Usage Usage
----- -----
To install type ``pip install aiohttp_security``.
Launch ``make doc`` and see examples or look under **demo** directory for a
sample project.
Develop
-------
``pip install -r requirements-dev``
License License

View File

@ -1,4 +1,4 @@
"""Identity polocy for storing info directly into HTTP cookie. """Identity policy for storing info directly into HTTP cookie.
Use mostly for demonstration purposes, SessionIdentityPolicy is much Use mostly for demonstration purposes, SessionIdentityPolicy is much
more handy. more handy.

View File

@ -1,7 +1,7 @@
"""Identity policy for storing info into aiohttp_session session. """Identity policy for storing info into aiohttp_session session.
aiohttp_session.setup() should be called on application initialization aiohttp_session.setup() should be called on application initialization
to conffigure aiohttp_session properly. to configure aiohttp_session properly.
""" """
import asyncio import asyncio

View File

@ -9,7 +9,6 @@ users = sa.Table(
sa.Column('id', sa.Integer, nullable=False), sa.Column('id', sa.Integer, nullable=False),
sa.Column('login', sa.String(256), nullable=False), sa.Column('login', sa.String(256), nullable=False),
sa.Column('passwd', sa.String(256), nullable=False), sa.Column('passwd', sa.String(256), nullable=False),
sa.Column('salt', sa.String(256), nullable=False),
sa.Column('is_superuser', sa.Boolean, nullable=False, sa.Column('is_superuser', sa.Boolean, nullable=False,
server_default='FALSE'), server_default='FALSE'),
sa.Column('disabled', sa.Boolean, nullable=False, sa.Column('disabled', sa.Boolean, nullable=False,

View File

@ -1,7 +1,9 @@
import asyncio import asyncio
import sqlalchemy as sa import sqlalchemy as sa
from aiohttp_security.abc import AbstractAuthorizationPolicy from aiohttp_security.abc import AbstractAuthorizationPolicy
from passlib.hash import sha256_crypt
from . import db from . import db
@ -11,11 +13,11 @@ class DBAuthorizationPolicy(AbstractAuthorizationPolicy):
self.dbengine = dbengine self.dbengine = dbengine
@asyncio.coroutine @asyncio.coroutine
def authorized_user_id(self, identity): def authorized_userid(self, identity):
with (yield from self.dbengine) as conn: with (yield from self.dbengine) as conn:
where = [db.users.c.login == identity, where = sa.and_(db.users.c.login == identity,
not db.users.c.disabled] sa.not_(db.users.c.disabled))
query = db.users.count().where(sa.and_(*where)) query = db.users.count().where(where)
ret = yield from conn.scalar(query) ret = yield from conn.scalar(query)
if ret: if ret:
return identity return identity
@ -24,12 +26,42 @@ class DBAuthorizationPolicy(AbstractAuthorizationPolicy):
@asyncio.coroutine @asyncio.coroutine
def permits(self, identity, permission, context=None): def permits(self, identity, permission, context=None):
with (yield from self.dbengine) as conn: if identity is None:
where = [db.users.c.login == identity, return False
not db.users.c.disabled]
record = self.data.get(identity) with (yield from self.dbengine) as conn:
if record is not None: where = sa.and_(db.users.c.login == identity,
# TODO: implement actual permission checker sa.not_(db.users.c.disabled))
if permission in record: query = db.users.select().where(where)
return True ret = yield from conn.execute(query)
user = yield from ret.fetchone()
if user is not None:
user_id = user[0]
is_superuser = user[3]
if is_superuser:
return True
where = db.permissions.c.user_id == user_id
query = db.permissions.select().where(where)
ret = yield from conn.execute(query)
result = yield from ret.fetchall()
if ret is not None:
for record in result:
if record.perm_name == permission:
return True
return False
@asyncio.coroutine
def check_credentials(db_engine, username, password):
with (yield from db_engine) as conn:
where = sa.and_(db.users.c.login == username,
sa.not_(db.users.c.disabled))
query = db.users.select().where(where)
ret = yield from conn.execute(query)
user = yield from ret.fetchone()
if user is not None:
hash = user[2]
return sha256_crypt.verify(password, hash)
return False return False

View File

@ -3,53 +3,93 @@ import functools
from aiohttp import web from aiohttp import web
from aiohttp_security import remember, forget, authorized_userid, permits from aiohttp_security import remember, forget, authorized_userid, permits
from .db_auth import check_credentials
def require(permission): def require(permission):
def wrapper(f): def wrapper(f):
@asyncio.coroutine @asyncio.coroutine
@functools.wraps(f) @functools.wraps(f)
def wrapped(self, request): def wrapped(self, request):
has_perm = yield from permits(request) has_perm = yield from permits(request, permission)
if not has_perm: if not has_perm:
raise web.HTTPForbidden() message = 'User has no permission {}'.format(permission)
raise web.HTTPForbidden(body=message.encode())
return (yield from f(self, request)) return (yield from f(self, request))
return wrapped return wrapped
return wrapper return wrapper
class Web: class Web(object):
@require('public') index_template = """
<!doctype html>
<head>
</head>
<body>
<p>{message}</p>
<form action="/login" method="post">
Login:
<input type="text" name="login">
Password:
<input type="password" name="password">
<input type="submit" value="Login">
</form>
<a href="/logout">Logout</a>
</body>
"""
@asyncio.coroutine @asyncio.coroutine
def index(self, request): def index(self, request):
pass username = yield from authorized_userid(request)
if username:
template = self.index_template.format(
message='Hello, {username}!'.format(username=username))
else:
template = self.index_template.format(message='You need to login')
response = web.Response(body=template.encode())
return response
@require('public')
@asyncio.coroutine @asyncio.coroutine
def login(self, request): def login(self, request):
pass response = web.HTTPFound('/')
form = yield from request.post()
login = form.get('login')
password = form.get('password')
db_engine = request.app.db_engine
if (yield from check_credentials(db_engine, login, password)):
yield from remember(request, response, login)
return response
@require('protected') return web.HTTPUnauthorized(
@asyncio.coroutine body=b'Invalid username/password combination')
def logout(self, request):
pass
@require('public') @require('public')
@asyncio.coroutine @asyncio.coroutine
def public(self, request): def logout(self, request):
pass response = web.Response(body=b'You have been logged out')
yield from forget(request, response)
return response
@require('public')
@asyncio.coroutine
def internal_page(self, request):
response = web.Response(
body=b'This page is visible for all registered users')
return response
@require('protected') @require('protected')
@asyncio.coroutine @asyncio.coroutine
def protected(self, request): def protected_page(self, request):
pass response = web.Response(body=b'You are on protected page')
return response
@asyncio.coroutine
def configure(self, app): def configure(self, app):
app.add_route('GET', '/', self.index, name='index') router = app.router
app.add_route('POST', '/login', self.login, name='login') router.add_route('GET', '/', self.index, name='index')
app.add_route('POST', '/logout', self.logout, name='logout') router.add_route('POST', '/login', self.login, name='login')
app.add_route('GET', '/public', self.public, name='public') router.add_route('GET', '/logout', self.logout, name='logout')
app.add_route('GET', '/protected', self.protected, name='protected') router.add_route('GET', '/public', self.internal_page, name='public')
router.add_route('GET', '/protected', self.protected_page,
name='protected')

View File

@ -16,22 +16,23 @@ from demo.handlers import Web
@asyncio.coroutine @asyncio.coroutine
def init(loop): def init(loop):
redis_pool = yield from create_pool(('localhost', 6379)) redis_pool = yield from create_pool(('localhost', 6379))
dbengine = yield from create_engine(user='aiohttp_security', db_engine = yield from create_engine(user='aiohttp_security',
password='aiohttp_security', password='aiohttp_security',
database='aiohttp_security', database='aiohttp_security',
host='127.0.0.1') host='127.0.0.1')
app = web.Application(loop=loop) app = web.Application(loop=loop)
app.db_engine = db_engine
setup_session(app, RedisStorage(redis_pool)) setup_session(app, RedisStorage(redis_pool))
setup_security(app, setup_security(app,
SessionIdentityPolicy(), SessionIdentityPolicy(),
DBAuthorizationPolicy(dbengine)) DBAuthorizationPolicy(db_engine))
web_handlers = Web() web_handlers = Web()
yield from web_handlers.configure(app) web_handlers.configure(app)
handler = app.make_handler() handler = app.make_handler()
srv = yield from loop.create_server(handler, '127.0.0.1', 8080) srv = yield from loop.create_server(handler, '127.0.0.1', 8080)
print("Server started at http://127.0.0.1:8080") print('Server started at http://127.0.0.1:8080')
return srv, app, handler return srv, app, handler
@ -54,3 +55,7 @@ def main():
loop.run_forever() loop.run_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
loop.run_until_complete((finalize(srv, app, handler))) loop.run_until_complete((finalize(srv, app, handler)))
if __name__ == '__main__':
main()

5
demo/sql/init_db.sql Normal file
View File

@ -0,0 +1,5 @@
CREATE USER aiohttp_security WITH PASSWORD 'aiohttp_security';
DROP DATABASE IF EXISTS aiohttp_security;
CREATE DATABASE aiohttp_security;
ALTER DATABASE aiohttp_security OWNER TO aiohttp_security;
GRANT ALL PRIVILEGES ON DATABASE aiohttp_security TO aiohttp_security;

38
demo/sql/sample_data.sql Normal file
View File

@ -0,0 +1,38 @@
-- create users table
CREATE TABLE IF NOT EXISTS users
(
id integer NOT NULL,
login character varying(256) NOT NULL,
passwd character varying(256) NOT NULL,
is_superuser boolean NOT NULL DEFAULT false,
disabled boolean NOT NULL DEFAULT false,
CONSTRAINT user_pkey PRIMARY KEY (id),
CONSTRAINT user_login_key UNIQUE (login)
);
-- and permissions for them
CREATE TABLE IF NOT EXISTS permissions
(
id integer NOT NULL,
user_id integer NOT NULL,
perm_name character varying(64) NOT NULL,
CONSTRAINT permission_pkey PRIMARY KEY (id),
CONSTRAINT user_permission_fkey FOREIGN KEY (user_id)
REFERENCES users (id) MATCH SIMPLE
ON UPDATE NO ACTION ON DELETE CASCADE
);
-- insert some data
INSERT INTO users(id, login, passwd, is_superuser, disabled)
VALUES (1, 'admin', '$5$rounds=535000$2kqN9fxCY6Xt5/pi$tVnh0xX87g/IsnOSuorZG608CZDFbWIWBr58ay6S4pD', TRUE, FALSE);
INSERT INTO users(id, login, passwd, is_superuser, disabled)
VALUES (2, 'moderator', '$5$rounds=535000$2kqN9fxCY6Xt5/pi$tVnh0xX87g/IsnOSuorZG608CZDFbWIWBr58ay6S4pD', FALSE, FALSE);
INSERT INTO users(id, login, passwd, is_superuser, disabled)
VALUES (3, 'user', '$5$rounds=535000$2kqN9fxCY6Xt5/pi$tVnh0xX87g/IsnOSuorZG608CZDFbWIWBr58ay6S4pD', FALSE, FALSE);
INSERT INTO permissions(id, user_id, perm_name)
VALUES (1, 2, 'protected');
INSERT INTO permissions(id, user_id, perm_name)
VALUES (2, 2, 'public');
INSERT INTO permissions(id, user_id, perm_name)
VALUES (3, 3, 'public');

View File

@ -1,8 +1,11 @@
.. _aiohttp-security-example:
===============================================
How to Make a Simple Server With Authorization How to Make a Simple Server With Authorization
============================================== ===============================================
.. code::python Simple example::
import asyncio import asyncio
from aiohttp import web from aiohttp import web
@ -13,7 +16,7 @@ How to Make a Simple Server With Authorization
return web.Response(body=text.encode('utf-8')) return web.Response(body=text.encode('utf-8'))
# option 2: auth at a higher level? # option 2: auth at a higher level?
# set user_id and allowed in the wsgo handler # set user_id and allowed in the wsgi handler
@protect('view_user') @protect('view_user')
@asyncio.coroutine @asyncio.coroutine
def user_handler(request): def user_handler(request):

210
docs/example_db_auth.rst Normal file
View File

@ -0,0 +1,210 @@
.. _aiohttp-security-example-db-auth:
===========================================
Permissions with PostgreSQL-based storage
===========================================
Make sure that you have PostgreSQL and Redis servers up and running.
If you want the full source code in advance or for comparison, check out
the `demo source`_.
.. _demo source:
https://github.com/aio-libs/aiohttp_security/tree/master/demo
.. _passlib:
https://pythonhosted.org/passlib/
Database
--------
Launch these sql scripts to init database and fill it with sample data:
``psql template1 < demo/sql/init_db.sql``
and then
``psql template1 < demo/sql/sample_data.sql``
You will have two tables for storing users and their permissions
+--------------+
| users |
+==============+
| id |
+--------------+
| login |
+--------------+
| passwd |
+--------------+
| is_superuser |
+--------------+
| disabled |
+--------------+
and second table is permissions table:
+-----------------+
| permissions |
+=================+
| id |
+-----------------+
| user_id |
+-----------------+
| permission_name |
+-----------------+
Writing policies
----------------
You need to implement two entities: *IdentityPolicy* and *AuthorizationPolicy*.
First one should have these methods: *identify*, *remember* and *forget*.
For second one: *authorized_userid* and *permits*. We will use built-in
*SessionIdentityPolicy* and write our own database-based authorization policy.
In our example we will lookup database by user login and if present return
this identity::
@asyncio.coroutine
def authorized_userid(self, identity):
with (yield from self.dbengine) as conn:
where = sa.and_(db.users.c.login == identity,
sa.not_(db.users.c.disabled))
query = db.users.count().where(where)
ret = yield from conn.scalar(query)
if ret:
return identity
else:
return None
For permission check we will fetch the user first, check if he is superuser
(all permissions are allowed), otherwise check if permission is explicitly set
for that user::
@asyncio.coroutine
def permits(self, identity, permission, context=None):
if identity is None:
return False
with (yield from self.dbengine) as conn:
where = sa.and_(db.users.c.login == identity,
sa.not_(db.users.c.disabled))
query = db.users.select().where(where)
ret = yield from conn.execute(query)
user = yield from ret.fetchone()
if user is not None:
user_id = user[0]
is_superuser = user[4]
if is_superuser:
return True
where = db.permissions.c.user_id == user_id
query = db.permissions.select().where(where)
ret = yield from conn.execute(query)
result = yield from ret.fetchall()
if ret is not None:
for record in result:
if record.perm_name == permission:
return True
return False
Setup
-----
Once we have all the code in place we can install it for our application::
from aiohttp_session.redis_storage import RedisStorage
from aiohttp_security import setup as setup_security
from aiohttp_security import SessionIdentityPolicy
from aiopg.sa import create_engine
from aioredis import create_pool
from .db_auth import DBAuthorizationPolicy
@asyncio.coroutine
def init(loop):
redis_pool = yield from create_pool(('localhost', 6379))
dbengine = yield from create_engine(user='aiohttp_security',
password='aiohttp_security',
database='aiohttp_security',
host='127.0.0.1')
app = web.Application(loop=loop)
setup_session(app, RedisStorage(redis_pool))
setup_security(app,
SessionIdentityPolicy(),
DBAuthorizationPolicy(dbengine))
return app
Now we have authorization and can decorate every other view with access rights
based on permissions. This simple decorator (for class-based handlers) will
help to do that::
def require(permission):
def wrapper(f):
@asyncio.coroutine
@functools.wraps(f)
def wrapped(self, request):
has_perm = yield from permits(request, permission)
if not has_perm:
message = 'User has no permission {}'.format(permission)
raise web.HTTPForbidden(body=message.encode())
return (yield from f(self, request))
return wrapped
return wrapper
For each view you need to protect just apply the decorator on it::
class Web:
@require('protected')
@asyncio.coroutine
def protected_page(self, request):
response = web.Response(body=b'You are on protected page')
return response
If someone will try to access this protected page he will see::
403, User has no permission "protected"
The best part about it is that you can implement any logic you want until it
follows the API conventions.
Launch application
------------------
For working with passwords there is a good library passlib_. Once you've
created some users you want to check their credentials on login. Similar
function may do what you trying to accomplish::
from passlib.hash import sha256_crypt
@asyncio.coroutine
def check_credentials(db_engine, username, password):
with (yield from db_engine) as conn:
where = sa.and_(db.users.c.login == username,
sa.not_(db.users.c.disabled))
query = db.users.select().where(where)
ret = yield from conn.execute(query)
user = yield from ret.fetchone()
if user is not None:
hash = user[2]
return sha256_crypt.verify(password, hash)
return False
Final step is to launch your application::
python demo/main.py
Try to login with admin/moderator/user accounts (with *password* password)
and access **/public** or **/protected** endpoints.

View File

@ -20,6 +20,7 @@ Contents:
usage usage
reference reference
example example
example_db_auth
glossary glossary

View File

@ -8,3 +8,6 @@ alabaster>=0.6.2
pep257 pep257
aiohttp_session>=0.4.0 aiohttp_session>=0.4.0
aiopg[sa] aiopg[sa]
aioredis==0.2.8
hiredis==0.2.0
passlib==1.6.5

View File

@ -1,8 +1,10 @@
import aiohttp
import asyncio
import gc import gc
import pytest
import socket import socket
import asyncio
import pytest
import aiohttp
from aiohttp import web from aiohttp import web
@ -79,7 +81,8 @@ class Client:
while path.startswith('/'): while path.startswith('/'):
path = path[1:] path = path[1:]
url = self._url + path url = self._url + path
return self._session.get(url, **kwargs) resp = self._session.get(url, **kwargs)
return resp
def post(self, path, **kwargs): def post(self, path, **kwargs):
while path.startswith('/'): while path.startswith('/'):
@ -97,6 +100,7 @@ class Client:
@pytest.yield_fixture @pytest.yield_fixture
def create_app_and_client(create_server, loop): def create_app_and_client(create_server, loop):
client = None client = None
cookie_jar = aiohttp.CookieJar(loop=loop, unsafe=True)
@asyncio.coroutine @asyncio.coroutine
def maker(*, server_params=None, client_params=None): def maker(*, server_params=None, client_params=None):
@ -108,7 +112,11 @@ def create_app_and_client(create_server, loop):
app, url = yield from create_server(**server_params) app, url = yield from create_server(**server_params)
if client_params is None: if client_params is None:
client_params = {} client_params = {}
client = Client(aiohttp.ClientSession(loop=loop, **client_params), url)
client = Client(
aiohttp.ClientSession(loop=loop, cookie_jar=cookie_jar),
url
)
return app, client return app, client
yield maker yield maker

View File

@ -34,7 +34,7 @@ def test_remember(create_app_and_client):
app.router.add_route('GET', '/', handler) app.router.add_route('GET', '/', handler)
resp = yield from client.get('/') resp = yield from client.get('/')
assert 200 == resp.status assert 200 == resp.status
assert 'Andrew' == client.cookies['AIOHTTP_SECURITY'].value assert 'Andrew' == resp.cookies['AIOHTTP_SECURITY'].value
yield from resp.release() yield from resp.release()
@ -98,5 +98,6 @@ def test_forget(create_app_and_client):
resp = yield from client.post('/logout') resp = yield from client.post('/logout')
assert 200 == resp.status assert 200 == resp.status
assert resp.url.endswith('/') assert resp.url.endswith('/')
assert '' == client.cookies['AIOHTTP_SECURITY'].value with pytest.raises(KeyError):
_ = client.cookies['AIOHTTP_SECURITY'] # noqa
yield from resp.release() yield from resp.release()

View File

@ -7,8 +7,8 @@ from aiohttp_security import (remember, forget,
from aiohttp_security import setup as setup_security from aiohttp_security import setup as setup_security
from aiohttp_security.session_identity import SessionIdentityPolicy from aiohttp_security.session_identity import SessionIdentityPolicy
from aiohttp_security.api import IDENTITY_KEY from aiohttp_security.api import IDENTITY_KEY
from aiohttp_session import (SimpleCookieStorage, session_middleware, from aiohttp_session import SimpleCookieStorage, get_session
get_session) from aiohttp_session import setup as setup_session
class Autz(AbstractAuthorizationPolicy): class Autz(AbstractAuthorizationPolicy):
@ -27,7 +27,7 @@ def create_app_and_client2(create_app_and_client):
@asyncio.coroutine @asyncio.coroutine
def maker(*args, **kwargs): def maker(*args, **kwargs):
app, client = yield from create_app_and_client(*args, **kwargs) app, client = yield from create_app_and_client(*args, **kwargs)
app.middlewares.append(session_middleware(SimpleCookieStorage())) setup_session(app, SimpleCookieStorage())
setup_security(app, SessionIdentityPolicy(), Autz()) setup_security(app, SessionIdentityPolicy(), Autz())
return app, client return app, client
return maker return maker
@ -82,6 +82,7 @@ def test_identify(create_app_and_client2):
resp = yield from client.post('/') resp = yield from client.post('/')
assert 200 == resp.status assert 200 == resp.status
yield from resp.release() yield from resp.release()
resp = yield from client.get('/') resp = yield from client.get('/')
assert 200 == resp.status assert 200 == resp.status
yield from resp.release() yield from resp.release()
@ -103,7 +104,7 @@ def test_forget(create_app_and_client2):
@asyncio.coroutine @asyncio.coroutine
def logout(request): def logout(request):
response = web.HTTPFound(location='/') response = web.HTTPFound('/')
yield from forget(request, response) yield from forget(request, response)
return response return response
@ -111,12 +112,14 @@ def test_forget(create_app_and_client2):
app.router.add_route('GET', '/', index) app.router.add_route('GET', '/', index)
app.router.add_route('POST', '/login', login) app.router.add_route('POST', '/login', login)
app.router.add_route('POST', '/logout', logout) app.router.add_route('POST', '/logout', logout)
resp = yield from client.post('/login') resp = yield from client.post('/login')
assert 200 == resp.status assert 200 == resp.status
assert resp.url.endswith('/') assert resp.url.endswith('/')
txt = yield from resp.text() txt = yield from resp.text()
assert 'Andrew' == txt assert 'Andrew' == txt
yield from resp.release() yield from resp.release()
resp = yield from client.post('/logout') resp = yield from client.post('/logout')
assert 200 == resp.status assert 200 == resp.status
assert resp.url.endswith('/') assert resp.url.endswith('/')