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
script:
- pyflakes aiohttp_security tests
- pep8 aiohttp_security tests
- flake8 aiohttp_security tests
- coverage run --source=aiohttp_security setup.py test
after_success:

View File

@ -9,6 +9,14 @@ __ aiohttp_web_
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

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
more handy.

View File

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

View File

@ -9,7 +9,6 @@ users = sa.Table(
sa.Column('id', sa.Integer, nullable=False),
sa.Column('login', 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,
server_default='FALSE'),
sa.Column('disabled', sa.Boolean, nullable=False,

View File

@ -1,7 +1,9 @@
import asyncio
import sqlalchemy as sa
from aiohttp_security.abc import AbstractAuthorizationPolicy
from passlib.hash import sha256_crypt
from . import db
@ -11,11 +13,11 @@ class DBAuthorizationPolicy(AbstractAuthorizationPolicy):
self.dbengine = dbengine
@asyncio.coroutine
def authorized_user_id(self, identity):
def authorized_userid(self, identity):
with (yield from self.dbengine) as conn:
where = [db.users.c.login == identity,
not db.users.c.disabled]
query = db.users.count().where(sa.and_(*where))
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
@ -24,12 +26,42 @@ class DBAuthorizationPolicy(AbstractAuthorizationPolicy):
@asyncio.coroutine
def permits(self, identity, permission, context=None):
with (yield from self.dbengine) as conn:
where = [db.users.c.login == identity,
not db.users.c.disabled]
record = self.data.get(identity)
if record is not None:
# TODO: implement actual permission checker
if permission in record:
return True
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[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

View File

@ -3,53 +3,93 @@ import functools
from aiohttp import web
from aiohttp_security import remember, forget, authorized_userid, permits
from .db_auth import check_credentials
def require(permission):
def wrapper(f):
@asyncio.coroutine
@functools.wraps(f)
def wrapped(self, request):
has_perm = yield from permits(request)
has_perm = yield from permits(request, permission)
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 wrapped
return wrapper
class Web:
@require('public')
class Web(object):
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
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
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')
@asyncio.coroutine
def logout(self, request):
pass
return web.HTTPUnauthorized(
body=b'Invalid username/password combination')
@require('public')
@asyncio.coroutine
def public(self, request):
pass
def logout(self, request):
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')
@asyncio.coroutine
def protected(self, request):
pass
def protected_page(self, request):
response = web.Response(body=b'You are on protected page')
return response
@asyncio.coroutine
def configure(self, app):
app.add_route('GET', '/', self.index, name='index')
app.add_route('POST', '/login', self.login, name='login')
app.add_route('POST', '/logout', self.logout, name='logout')
app.add_route('GET', '/public', self.public, name='public')
app.add_route('GET', '/protected', self.protected, name='protected')
router = app.router
router.add_route('GET', '/', self.index, name='index')
router.add_route('POST', '/login', self.login, name='login')
router.add_route('GET', '/logout', self.logout, name='logout')
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
def init(loop):
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',
database='aiohttp_security',
host='127.0.0.1')
app = web.Application(loop=loop)
app.db_engine = db_engine
setup_session(app, RedisStorage(redis_pool))
setup_security(app,
SessionIdentityPolicy(),
DBAuthorizationPolicy(dbengine))
DBAuthorizationPolicy(db_engine))
web_handlers = Web()
yield from web_handlers.configure(app)
web_handlers.configure(app)
handler = app.make_handler()
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
@ -54,3 +55,7 @@ def main():
loop.run_forever()
except KeyboardInterrupt:
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
==============================================
===============================================
.. code::python
Simple example::
import asyncio
from aiohttp import web
@ -13,7 +16,7 @@ How to Make a Simple Server With Authorization
return web.Response(body=text.encode('utf-8'))
# 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')
@asyncio.coroutine
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
reference
example
example_db_auth
glossary

View File

@ -8,3 +8,6 @@ alabaster>=0.6.2
pep257
aiohttp_session>=0.4.0
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 pytest
import socket
import asyncio
import pytest
import aiohttp
from aiohttp import web
@ -79,7 +81,8 @@ class Client:
while path.startswith('/'):
path = path[1:]
url = self._url + path
return self._session.get(url, **kwargs)
resp = self._session.get(url, **kwargs)
return resp
def post(self, path, **kwargs):
while path.startswith('/'):
@ -97,6 +100,7 @@ class Client:
@pytest.yield_fixture
def create_app_and_client(create_server, loop):
client = None
cookie_jar = aiohttp.CookieJar(loop=loop, unsafe=True)
@asyncio.coroutine
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)
if client_params is None:
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
yield maker

View File

@ -34,7 +34,7 @@ def test_remember(create_app_and_client):
app.router.add_route('GET', '/', handler)
resp = yield from client.get('/')
assert 200 == resp.status
assert 'Andrew' == client.cookies['AIOHTTP_SECURITY'].value
assert 'Andrew' == resp.cookies['AIOHTTP_SECURITY'].value
yield from resp.release()
@ -98,5 +98,6 @@ def test_forget(create_app_and_client):
resp = yield from client.post('/logout')
assert 200 == resp.status
assert resp.url.endswith('/')
assert '' == client.cookies['AIOHTTP_SECURITY'].value
with pytest.raises(KeyError):
_ = client.cookies['AIOHTTP_SECURITY'] # noqa
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.session_identity import SessionIdentityPolicy
from aiohttp_security.api import IDENTITY_KEY
from aiohttp_session import (SimpleCookieStorage, session_middleware,
get_session)
from aiohttp_session import SimpleCookieStorage, get_session
from aiohttp_session import setup as setup_session
class Autz(AbstractAuthorizationPolicy):
@ -27,7 +27,7 @@ def create_app_and_client2(create_app_and_client):
@asyncio.coroutine
def maker(*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())
return app, client
return maker
@ -82,6 +82,7 @@ def test_identify(create_app_and_client2):
resp = yield from client.post('/')
assert 200 == resp.status
yield from resp.release()
resp = yield from client.get('/')
assert 200 == resp.status
yield from resp.release()
@ -103,7 +104,7 @@ def test_forget(create_app_and_client2):
@asyncio.coroutine
def logout(request):
response = web.HTTPFound(location='/')
response = web.HTTPFound('/')
yield from forget(request, response)
return response
@ -111,12 +112,14 @@ def test_forget(create_app_and_client2):
app.router.add_route('GET', '/', index)
app.router.add_route('POST', '/login', login)
app.router.add_route('POST', '/logout', logout)
resp = yield from client.post('/login')
assert 200 == resp.status
assert resp.url.endswith('/')
txt = yield from resp.text()
assert 'Andrew' == txt
yield from resp.release()
resp = yield from client.post('/logout')
assert 200 == resp.status
assert resp.url.endswith('/')