├── __init__.py
├── database.py
├── application.py
├── templates
├── index.html
├── mypage.html
├── admin
│ └── index.html
├── layout.html
├── security
│ ├── forgot_password.html
│ ├── reset_password.html
│ ├── login_user.html
│ └── register_user.html
└── _nav.html
├── requirements.txt
├── .travis.yml
├── static
└── style.css
├── .gitignore
├── config.py
├── LICENSE
├── admin.py
├── README.md
├── models.py
├── server.py
└── test.py
/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/database.py:
--------------------------------------------------------------------------------
1 | from flask_sqlalchemy import SQLAlchemy
2 | db = SQLAlchemy()
--------------------------------------------------------------------------------
/application.py:
--------------------------------------------------------------------------------
1 | from flask import Flask
2 |
3 | app = Flask(__name__)
4 | app.config.from_object('config.DevelopmentConfig')
--------------------------------------------------------------------------------
/templates/index.html:
--------------------------------------------------------------------------------
1 | {% extends "layout.html" %}
2 |
3 | {% block content %}
4 |
5 |
Welcome!
6 |
7 | It works! This page is accessible from the public.
8 |
9 | {% endblock %}
--------------------------------------------------------------------------------
/templates/mypage.html:
--------------------------------------------------------------------------------
1 | {% extends "layout.html" %}
2 |
3 | {% block content %}
4 |
5 | My page
6 |
7 | It works! This page is only accessible for logged in users.
8 |
9 | {% endblock %}
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Flask-Restless==0.16.0
2 | # 0.17.0 is broken https://github.com/jfinkels/flask-restless/issues/409
3 | Flask-JWT==0.3.2
4 | Flask-Security==1.7.5
5 | Flask-Admin==1.5.1
6 | Flask-SQLAlchemy==2.3.2
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | python:
3 | # - "2.6" Not supported because of missing OrderedDict
4 | - "2.7"
5 | # - "3.2" Not supported because of missing unicode literals
6 | - "3.4"
7 | - "3.7"
8 | install:
9 | - "pip install -r requirements.txt"
10 | script: python test.py
--------------------------------------------------------------------------------
/templates/admin/index.html:
--------------------------------------------------------------------------------
1 | {% extends 'admin/master.html' %}
2 |
3 | {% block body %}
4 |
5 | {% if current_user.is_authenticated %}
6 | Welcome to the admin.
7 | Logout
8 | {% else %}
9 | Please login for access.
10 | {% endif %}
11 |
12 | Return to site
13 |
14 | {% endblock %}
15 |
16 |
17 |
--------------------------------------------------------------------------------
/templates/layout.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {% block head %}
5 |
6 | {% block title %}{% endblock %} - {{config.APP_NAME}}
7 | {% endblock %}
8 |
9 |
10 | {% include "_nav.html" %}
11 |
12 |
13 | {% block content %}{% endblock %}
14 |
15 |
16 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/templates/security/forgot_password.html:
--------------------------------------------------------------------------------
1 | {% from "security/_macros.html" import render_field_with_errors, render_field %}
2 |
3 | {% extends "layout.html" %}
4 |
5 | {% block content %}
6 |
7 | {% include "security/_messages.html" %}
8 |
9 | Send password reset instructions
10 |
15 | {% endblock %}
--------------------------------------------------------------------------------
/static/style.css:
--------------------------------------------------------------------------------
1 | body, html {
2 | margin: 0;
3 | }
4 | body {
5 | font-family: sans-serif;
6 | padding: 50px;
7 | }
8 |
9 | /* abstract */
10 | .nav {
11 | list-style: none;
12 | margin: 0;
13 | padding: 0;
14 | }
15 | .nav li {
16 | display: inline-block;
17 | }
18 |
19 | /* layout */
20 | nav {
21 | background-color: #f0f0f0;
22 | margin: -50px -50px 50px;
23 | }
24 | nav a {
25 | text-decoration: none;
26 | }
27 | nav .nav li a {
28 | display: block;
29 | padding: 1em;
30 | }
31 | nav .nav li a:hover {
32 | background-color: rgba(0,0,0,0.05);
33 | }
34 | .nav-account {
35 | float: right;
36 | }
--------------------------------------------------------------------------------
/templates/security/reset_password.html:
--------------------------------------------------------------------------------
1 | {% from "security/_macros.html" import render_field_with_errors, render_field %}
2 |
3 | {% extends "layout.html" %}
4 |
5 | {% block content %}
6 |
7 | {% include "security/_messages.html" %}
8 |
9 | Reset password
10 |
16 | {% endblock %}
--------------------------------------------------------------------------------
/templates/security/login_user.html:
--------------------------------------------------------------------------------
1 | {% from "security/_macros.html" import render_field_with_errors, render_field %}
2 | {% extends "layout.html" %}
3 |
4 | {% block content %}
5 |
6 | {% include "security/_messages.html" %}
7 |
8 | Login
9 |
10 |
18 |
19 | {% endblock %}
--------------------------------------------------------------------------------
/templates/security/register_user.html:
--------------------------------------------------------------------------------
1 | {% from "security/_macros.html" import render_field_with_errors, render_field %}
2 | {% extends "layout.html" %}
3 |
4 | {% block content %}
5 |
6 | {% include "security/_messages.html" %}
7 |
8 | Register
9 |
18 |
19 | {% endblock %}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 |
5 | # C extensions
6 | *.so
7 |
8 | # Distribution / packaging
9 | .Python
10 | env/
11 | bin/
12 | build/
13 | develop-eggs/
14 | dist/
15 | eggs/
16 | lib/
17 | lib64/
18 | parts/
19 | sdist/
20 | var/
21 | *.egg-info/
22 | .installed.cfg
23 | *.egg
24 |
25 | # Installer logs
26 | pip-log.txt
27 | pip-delete-this-directory.txt
28 |
29 | # Unit test / coverage reports
30 | htmlcov/
31 | .tox/
32 | .coverage
33 | .cache
34 | nosetests.xml
35 | coverage.xml
36 |
37 | # Translations
38 | *.mo
39 |
40 | # Mr Developer
41 | .mr.developer.cfg
42 | .project
43 | .pydevproject
44 |
45 | # Rope
46 | .ropeproject
47 |
48 | # Django stuff:
49 | *.log
50 | *.pot
51 |
52 | # Sphinx documentation
53 | docs/_build/
54 |
55 | # Databse
56 | *.sqlite
57 |
--------------------------------------------------------------------------------
/config.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 |
3 |
4 | class Config(object):
5 | DEBUG = False
6 | TESTING = False
7 | SQLALCHEMY_DATABASE_URI = ''
8 |
9 | APP_NAME = 'ApplicationName'
10 | SECRET_KEY = 'add_secret'
11 | JWT_EXPIRATION_DELTA = timedelta(days=30)
12 | JWT_AUTH_URL_RULE = '/api/v1/auth'
13 | SECURITY_REGISTERABLE = True
14 | SECURITY_RECOVERABLE = True
15 | SECURITY_TRACKABLE = True
16 | SECURITY_PASSWORD_HASH = 'sha512_crypt'
17 | SECURITY_PASSWORD_SALT = 'add_salt'
18 | SQLALCHEMY_TRACK_MODIFICATIONS = False
19 |
20 |
21 | class ProductionConfig(Config):
22 | SQLALCHEMY_DATABASE_URI = 'mysql://username:password@localhost/db'
23 |
24 |
25 | class DevelopmentConfig(Config):
26 | SQLALCHEMY_DATABASE_URI = 'sqlite:///data.sqlite'
27 | DEBUG = True
28 |
29 |
30 | class TestingConfig(Config):
31 | SQLALCHEMY_DATABASE_URI = 'sqlite://'
32 | TESTING = True
33 |
--------------------------------------------------------------------------------
/templates/_nav.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 Paul Grau
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/admin.py:
--------------------------------------------------------------------------------
1 | from flask_admin.contrib.sqla import ModelView
2 | from flask_admin import Admin, BaseView, expose
3 | from flask_security import current_user
4 | from flask import redirect
5 | from flask_security import logout_user
6 |
7 | from application import app
8 | from database import db
9 | from models import User, Role, SomeStuff
10 |
11 |
12 | class LogoutView(BaseView):
13 | @expose('/')
14 | def index(self):
15 | logout_user()
16 | return redirect('/admin')
17 |
18 | def is_visible(self):
19 | return current_user.is_authenticated
20 |
21 |
22 | class LoginView(BaseView):
23 | @expose('/')
24 | def index(self):
25 | logout_user()
26 | return redirect('/login?next=/admin')
27 |
28 | def is_visible(self):
29 | return not current_user.is_authenticated
30 |
31 |
32 | class AdminModelView(ModelView):
33 | def is_accessible(self):
34 | return current_user.is_authenticated
35 |
36 |
37 | class UserModelView(AdminModelView):
38 | column_list = ('email', 'active', 'last_login_at', 'roles', )
39 |
40 |
41 | def init_admin():
42 | admin = Admin(app)
43 | admin.add_view(UserModelView(User, db.session, category='Auth'))
44 | admin.add_view(AdminModelView(Role, db.session, category='Auth'))
45 | admin.add_view(AdminModelView(SomeStuff, db.session))
46 | admin.add_view(LogoutView(name='Logout', endpoint='logout'))
47 | admin.add_view(LoginView(name='Login', endpoint='login'))
48 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://travis-ci.org/graup/flask-restless-security)
2 |
3 | This is a starting point for a [Flask](http://flask.pocoo.org/) website + API using:
4 |
5 | - [Flask-Restless](https://flask-restless.readthedocs.org/en/latest/) (API)
6 | - [Flask-Security](https://pythonhosted.org/Flask-Security/) (Authentication)
7 | - [Flask-JWT](https://pythonhosted.org/Flask-JWT/) (API authentication)
8 | - [Flask-Admin](http://flask-admin.readthedocs.org/en/latest/) (Admin views)
9 | - [SQLAlchemy](http://www.sqlalchemy.org/) (ORM)
10 |
11 | Plus stubs for
12 |
13 | - Templates
14 | - Testing
15 |
16 | I got the basic idea from Nic:
17 | http://stackoverflow.com/a/24258886/700283
18 |
19 | The goal here is simple code. You can read through everything in a short time
20 | and get a good idea of how you could put these pieces together.
21 |
22 | Setup
23 | =====
24 |
25 | - Create and activate a vitualenv
26 | - Run `pip install -r requirements.txt`
27 | - Start server using `python server.py`
28 |
29 | **Website**
30 |
31 | - Access site at /. Not much there, just a basic example for logging in
32 |
33 | **Admin**
34 |
35 | - Access admin at /admin
36 |
37 | **API auth**
38 |
39 | - POST /api/v1/auth {'username': '', 'password': ''}
40 | - Returns JSON with {'access_token':''}
41 | - Then request from API using header 'Authorization: JWT $token'
42 |
43 | **Tests**
44 |
45 | - Run tests using `python test.py`
--------------------------------------------------------------------------------
/models.py:
--------------------------------------------------------------------------------
1 | from database import db
2 | from flask_security import UserMixin, RoleMixin, SQLAlchemyUserDatastore
3 |
4 | roles_users = db.Table('roles_users',
5 | db.Column('user_id', db.Integer(), db.ForeignKey('user.id')),
6 | db.Column('role_id', db.Integer(), db.ForeignKey('role.id')))
7 |
8 |
9 | class User(db.Model, UserMixin):
10 | id = db.Column(db.Integer, primary_key=True)
11 | email = db.Column(db.String(255), unique=True)
12 | password = db.Column(db.String(255))
13 | active = db.Column(db.Boolean())
14 | confirmed_at = db.Column(db.DateTime())
15 | roles = db.relationship('Role', secondary=roles_users,
16 | backref=db.backref('users', lazy='dynamic'))
17 | last_login_at = db.Column(db.DateTime())
18 | current_login_at = db.Column(db.DateTime())
19 | last_login_ip = db.Column(db.String(255))
20 | current_login_ip = db.Column(db.String(255))
21 | login_count = db.Column(db.Integer)
22 |
23 | def __repr__(self):
24 | return '' % self.email
25 |
26 |
27 | class Role(db.Model, RoleMixin):
28 | id = db.Column(db.Integer(), primary_key=True)
29 | name = db.Column(db.String(80), unique=True)
30 | description = db.Column(db.String(255))
31 |
32 | user_datastore = SQLAlchemyUserDatastore(db, User, Role)
33 |
34 |
35 | class SomeStuff(db.Model):
36 | __tablename__ = 'somestuff'
37 | id = db.Column(db.Integer, primary_key=True)
38 | data1 = db.Column(db.Integer)
39 | data2 = db.Column(db.String(10))
40 | user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
41 | user = db.relationship(User, lazy='joined', join_depth=1, viewonly=True)
--------------------------------------------------------------------------------
/server.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, request, redirect
2 | from flask_security import Security, logout_user, login_required
3 | from flask_security.utils import encrypt_password, verify_password
4 | from flask_restless import APIManager
5 | from flask_jwt import JWT, jwt_required
6 |
7 | from database import db
8 | from application import app
9 | from models import User, SomeStuff, user_datastore
10 | from admin import init_admin
11 |
12 | # Setup Flask-Security =======================================================
13 | security = Security(app, user_datastore)
14 |
15 | # Views ======================================================================
16 | @app.route('/')
17 | def home():
18 | return render_template('index.html')
19 |
20 |
21 | @app.route('/mypage')
22 | @login_required
23 | def mypage():
24 | return render_template('mypage.html')
25 |
26 |
27 | @app.route('/logout')
28 | def log_out():
29 | logout_user()
30 | return redirect(request.args.get('next') or '/')
31 |
32 |
33 | # JWT Token authentication ===================================================
34 | def authenticate(username, password):
35 | user = user_datastore.find_user(email=username)
36 | if user and username == user.email and verify_password(password, user.password):
37 | return user
38 | return None
39 |
40 |
41 | def load_user(payload):
42 | user = user_datastore.find_user(id=payload['identity'])
43 | return user
44 |
45 |
46 | jwt = JWT(app, authenticate, load_user)
47 |
48 | # Flask-Restless API =========================================================
49 | @jwt_required()
50 | def auth_func(**kw):
51 | pass
52 |
53 |
54 | apimanager = APIManager(app, flask_sqlalchemy_db=db)
55 | apimanager.create_api(SomeStuff,
56 | methods=['GET', 'POST', 'DELETE', 'PUT'],
57 | url_prefix='/api/v1',
58 | collection_name='free_stuff',
59 | include_columns=['id', 'data1', 'data2', 'user_id'])
60 | apimanager.create_api(SomeStuff,
61 | methods=['GET', 'POST', 'DELETE', 'PUT'],
62 | url_prefix='/api/v1',
63 | preprocessors=dict(GET_SINGLE=[auth_func], GET_MANY=[auth_func]),
64 | collection_name='protected_stuff',
65 | include_columns=['id', 'data1', 'data2', 'user_id'])
66 |
67 |
68 | # Setup Admin ================================================================
69 | init_admin()
70 |
71 |
72 | # Bootstrap ==================================================================
73 | def create_test_models():
74 | user_datastore.create_user(email='test', password=encrypt_password('test'))
75 | user_datastore.create_user(email='test2', password=encrypt_password('test2'))
76 | stuff = SomeStuff(data1=2, data2='toto', user_id=1)
77 | db.session.add(stuff)
78 | stuff = SomeStuff(data1=5, data2='titi', user_id=1)
79 | db.session.add(stuff)
80 | db.session.commit()
81 |
82 |
83 | @app.before_first_request
84 | def bootstrap_app():
85 | if not app.config['TESTING']:
86 | if db.session.query(User).count() == 0:
87 | create_test_models()
88 |
89 |
90 | # Start server ===============================================================
91 | if __name__ == '__main__':
92 | db.init_app(app)
93 | with app.app_context():
94 | db.create_all()
95 | app.run()
96 |
--------------------------------------------------------------------------------
/test.py:
--------------------------------------------------------------------------------
1 | import os
2 | from application import app
3 | from server import user_datastore
4 | from database import db
5 | import unittest
6 | import tempfile
7 | import re
8 | import json
9 |
10 | from flask_security.utils import encrypt_password
11 | from flask_security import current_user
12 | from flask_security.utils import login_user
13 | from models import User, Role, SomeStuff
14 |
15 |
16 | class FlaskTestCase(unittest.TestCase):
17 | def setUp(self):
18 | app.config.from_object('config.TestingConfig')
19 | self.client = app.test_client()
20 |
21 | db.init_app(app)
22 | with app.app_context():
23 | db.create_all()
24 | user_datastore.create_user(email='test', password=encrypt_password('test'))
25 | db.session.commit()
26 |
27 | def tearDown(self):
28 | with app.app_context():
29 | db.session.remove()
30 | db.drop_all()
31 |
32 | def _post(self, route, data=None, content_type=None, follow_redirects=True, headers=None):
33 | content_type = content_type or 'application/x-www-form-urlencoded'
34 | return self.client.post(route, data=data, follow_redirects=follow_redirects, content_type=content_type,
35 | headers=headers)
36 |
37 | def _login(self, email=None, password=None):
38 | # Get CSRF token from login form
39 | csrf_token = ''
40 | rv = self.client.get('/login')
41 | matches = re.findall('name="csrf_token".*?value="(.*?)"', rv.data.decode())
42 | if matches:
43 | csrf_token = matches[0]
44 |
45 | # POST login form
46 | email = email or 'test'
47 | password = password or 'test'
48 | data = {
49 | 'email': email,
50 | 'password': password,
51 | 'remember': 'y',
52 | 'csrf_token': csrf_token
53 | }
54 | return self._post('/login', data=data, follow_redirects=False)
55 |
56 |
57 | class ModelsTest(FlaskTestCase):
58 | def test_protectedstuff(self):
59 | with app.app_context():
60 | instance = SomeStuff(data1=1337, data2='Test')
61 | db.session.add(instance)
62 | db.session.commit()
63 | self.assertTrue(hasattr(instance, 'id'))
64 |
65 |
66 | class ViewsTest(FlaskTestCase):
67 | def test_page(self):
68 | rv = self.client.get('/')
69 | self.assertEqual(200, rv.status_code)
70 |
71 | def test_protected_page(self):
72 | rv = self.client.get('/mypage')
73 | self.assertIn('Redirecting...', rv.data.decode())
74 |
75 | self._login()
76 |
77 | rv = self.client.get('/mypage')
78 | self.assertIn('It works', rv.data.decode())
79 |
80 | rv = self.client.get('/logout')
81 | self.assertEqual(302, rv.status_code)
82 |
83 |
84 | class APITest(FlaskTestCase):
85 | def _auth(self, username=None, password=None):
86 | username = username or 'test'
87 | password = password or 'test'
88 | rv = self._post('/api/v1/auth',
89 | data=json.dumps({'username': username, 'password': password})
90 | )
91 | return json.loads(rv.data.decode())
92 |
93 | def _get(self, route, data=None, content_type=None, follow_redirects=True, headers=None):
94 | content_type = content_type or 'application/json'
95 | if hasattr(self, 'token'):
96 | headers = headers or {'Authorization': 'JWT ' + self.token}
97 | return self.client.get(route, data=data, follow_redirects=follow_redirects, content_type=content_type,
98 | headers=headers)
99 |
100 | def _post(self, route, data=None, content_type=None, follow_redirects=True, headers=None):
101 | content_type = content_type or 'application/json'
102 | if hasattr(self, 'token'):
103 | headers = headers or {'Authorization': 'Bearer ' + self.token}
104 | return self.client.post(route, data=data, follow_redirects=follow_redirects, content_type=content_type,
105 | headers=headers)
106 |
107 | def test_auth(self):
108 | # Get auth token with invalid credentials
109 | auth_resp = self._auth('not', 'existing')
110 | self.assertEqual(401, auth_resp['status_code'])
111 |
112 | # Get auth token with valid credentials
113 | auth_resp = self._auth('test', 'test')
114 | self.assertIn(u'access_token', auth_resp)
115 |
116 | self.token = auth_resp['access_token']
117 |
118 | # Get empty collection
119 | rv = self._get('/api/v1/protected_stuff')
120 | self.assertEqual(200, rv.status_code)
121 | data = json.loads(rv.data.decode())
122 | self.assertEqual(data['num_results'], 0)
123 |
124 | # Post object to collection
125 | rv = self._post('/api/v1/protected_stuff', data=json.dumps({'data1': 1337, 'data2': 'Test'}))
126 | self.assertEqual(201, rv.status_code)
127 |
128 | # Get collection if new object
129 | rv = self._get('/api/v1/protected_stuff')
130 | data = json.loads(rv.data.decode())
131 | self.assertEqual(data['num_results'], 1)
132 |
133 | # Post another object and get it back
134 | rv = self._post('/api/v1/protected_stuff', data=json.dumps({'data1': 2, 'data2': ''}))
135 | self.assertEqual(201, rv.status_code)
136 | rv = self._get('/api/v1/protected_stuff/2')
137 | data = json.loads(rv.data.decode())
138 | self.assertEqual(data['data1'], 2)
139 |
140 |
141 | if __name__ == '__main__':
142 | unittest.main()
143 |
--------------------------------------------------------------------------------