├── requirements.txt ├── .gitignore ├── LICENSE ├── api.py └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | click==6.7 2 | Flask==1.1.2 3 | Flask-HTTPAuth==4.0.0 4 | Flask-SQLAlchemy==2.4.1 5 | itsdangerous==0.24 6 | Jinja2==2.11.3 7 | MarkupSafe==1.1.1 8 | PyJWT==2.4.0 9 | SQLAlchemy==1.3.6 10 | Werkzeug==1.0.1 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Miguel Grinberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /api.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import time 4 | from flask import Flask, abort, request, jsonify, g, url_for 5 | from flask_sqlalchemy import SQLAlchemy 6 | from flask_httpauth import HTTPBasicAuth 7 | import jwt 8 | from werkzeug.security import generate_password_hash, check_password_hash 9 | 10 | # initialization 11 | app = Flask(__name__) 12 | app.config['SECRET_KEY'] = 'the quick brown fox jumps over the lazy dog' 13 | app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' 14 | app.config['SQLALCHEMY_COMMIT_ON_TEARDOWN'] = True 15 | 16 | # extensions 17 | db = SQLAlchemy(app) 18 | auth = HTTPBasicAuth() 19 | 20 | 21 | class User(db.Model): 22 | __tablename__ = 'users' 23 | id = db.Column(db.Integer, primary_key=True) 24 | username = db.Column(db.String(32), index=True) 25 | password_hash = db.Column(db.String(128)) 26 | 27 | def hash_password(self, password): 28 | self.password_hash = generate_password_hash(password) 29 | 30 | def verify_password(self, password): 31 | return check_password_hash(self.password_hash, password) 32 | 33 | def generate_auth_token(self, expires_in=600): 34 | return jwt.encode( 35 | {'id': self.id, 'exp': time.time() + expires_in}, 36 | app.config['SECRET_KEY'], algorithm='HS256') 37 | 38 | @staticmethod 39 | def verify_auth_token(token): 40 | try: 41 | data = jwt.decode(token, app.config['SECRET_KEY'], 42 | algorithms=['HS256']) 43 | except: 44 | return 45 | return User.query.get(data['id']) 46 | 47 | 48 | @auth.verify_password 49 | def verify_password(username_or_token, password): 50 | # first try to authenticate by token 51 | user = User.verify_auth_token(username_or_token) 52 | if not user: 53 | # try to authenticate with username/password 54 | user = User.query.filter_by(username=username_or_token).first() 55 | if not user or not user.verify_password(password): 56 | return False 57 | g.user = user 58 | return True 59 | 60 | 61 | @app.route('/api/users', methods=['POST']) 62 | def new_user(): 63 | username = request.json.get('username') 64 | password = request.json.get('password') 65 | if username is None or password is None: 66 | abort(400) # missing arguments 67 | if User.query.filter_by(username=username).first() is not None: 68 | abort(400) # existing user 69 | user = User(username=username) 70 | user.hash_password(password) 71 | db.session.add(user) 72 | db.session.commit() 73 | return (jsonify({'username': user.username}), 201, 74 | {'Location': url_for('get_user', id=user.id, _external=True)}) 75 | 76 | 77 | @app.route('/api/users/') 78 | def get_user(id): 79 | user = User.query.get(id) 80 | if not user: 81 | abort(400) 82 | return jsonify({'username': user.username}) 83 | 84 | 85 | @app.route('/api/token') 86 | @auth.login_required 87 | def get_auth_token(): 88 | token = g.user.generate_auth_token(600) 89 | return jsonify({'token': token.decode('ascii'), 'duration': 600}) 90 | 91 | 92 | @app.route('/api/resource') 93 | @auth.login_required 94 | def get_resource(): 95 | return jsonify({'data': 'Hello, %s!' % g.user.username}) 96 | 97 | 98 | if __name__ == '__main__': 99 | if not os.path.exists('db.sqlite'): 100 | db.create_all() 101 | app.run(debug=True) 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | REST-auth 2 | ========= 3 | 4 | Companion application to my [RESTful Authentication with Flask](http://blog.miguelgrinberg.com/post/restful-authentication-with-flask) article. 5 | 6 | Installation 7 | ------------ 8 | 9 | After cloning, create a virtual environment and install the requirements. For Linux and Mac users: 10 | 11 | $ virtualenv venv 12 | $ source venv/bin/activate 13 | (venv) $ pip install -r requirements.txt 14 | 15 | If you are on Windows, then use the following commands instead: 16 | 17 | $ virtualenv venv 18 | $ venv\Scripts\activate 19 | (venv) $ pip install -r requirements.txt 20 | 21 | Running 22 | ------- 23 | 24 | To run the server use the following command: 25 | 26 | (venv) $ python api.py 27 | * Running on http://127.0.0.1:5000/ 28 | * Restarting with reloader 29 | 30 | Then from a different terminal window you can send requests. 31 | 32 | API Documentation 33 | ----------------- 34 | 35 | - POST **/api/users** 36 | 37 | Register a new user.
38 | The body must contain a JSON object that defines `username` and `password` fields.
39 | On success a status code 201 is returned. The body of the response contains a JSON object with the newly added user. A `Location` header contains the URI of the new user.
40 | On failure status code 400 (bad request) is returned.
41 | Notes: 42 | - The password is hashed before it is stored in the database. Once hashed, the original password is discarded. 43 | - In a production deployment secure HTTP must be used to protect the password in transit. 44 | 45 | - GET **/api/users/<int:id>** 46 | 47 | Return a user.
48 | On success a status code 200 is returned. The body of the response contains a JSON object with the requested user.
49 | On failure status code 400 (bad request) is returned. 50 | 51 | - GET **/api/token** 52 | 53 | Return an authentication token.
54 | This request must be authenticated using a HTTP Basic Authentication header.
55 | On success a JSON object is returned with a field `token` set to the authentication token for the user and a field `duration` set to the (approximate) number of seconds the token is valid.
56 | On failure status code 401 (unauthorized) is returned. 57 | 58 | - GET **/api/resource** 59 | 60 | Return a protected resource.
61 | This request must be authenticated using a HTTP Basic Authentication header. Instead of username and password, the client can provide a valid authentication token in the username field. If using an authentication token the password field is not used and can be set to any value.
62 | On success a JSON object with data for the authenticated user is returned.
63 | On failure status code 401 (unauthorized) is returned. 64 | 65 | Example 66 | ------- 67 | 68 | The following `curl` command registers a new user with username `miguel` and password `python`: 69 | 70 | $ curl -i -X POST -H "Content-Type: application/json" -d '{"username":"miguel","password":"python"}' http://127.0.0.1:5000/api/users 71 | HTTP/1.0 201 CREATED 72 | Content-Type: application/json 73 | Content-Length: 27 74 | Location: http://127.0.0.1:5000/api/users/1 75 | Server: Werkzeug/0.9.4 Python/2.7.3 76 | Date: Thu, 28 Nov 2013 19:56:39 GMT 77 | 78 | { 79 | "username": "miguel" 80 | } 81 | 82 | These credentials can now be used to access protected resources: 83 | 84 | $ curl -u miguel:python -i -X GET http://127.0.0.1:5000/api/resource 85 | HTTP/1.0 200 OK 86 | Content-Type: application/json 87 | Content-Length: 30 88 | Server: Werkzeug/0.9.4 Python/2.7.3 89 | Date: Thu, 28 Nov 2013 20:02:25 GMT 90 | 91 | { 92 | "data": "Hello, miguel!" 93 | } 94 | 95 | Using the wrong credentials the request is refused: 96 | 97 | $ curl -u miguel:ruby -i -X GET http://127.0.0.1:5000/api/resource 98 | HTTP/1.0 401 UNAUTHORIZED 99 | Content-Type: text/html; charset=utf-8 100 | Content-Length: 19 101 | WWW-Authenticate: Basic realm="Authentication Required" 102 | Server: Werkzeug/0.9.4 Python/2.7.3 103 | Date: Thu, 28 Nov 2013 20:03:18 GMT 104 | 105 | Unauthorized Access 106 | 107 | Finally, to avoid sending username and password with every request an authentication token can be requested: 108 | 109 | $ curl -u miguel:python -i -X GET http://127.0.0.1:5000/api/token 110 | HTTP/1.0 200 OK 111 | Content-Type: application/json 112 | Content-Length: 139 113 | Server: Werkzeug/0.9.4 Python/2.7.3 114 | Date: Thu, 28 Nov 2013 20:04:15 GMT 115 | 116 | { 117 | "duration": 600, 118 | "token": "eyJhbGciOiJIUzI1NiIsImV4cCI6MTM4NTY2OTY1NSwiaWF0IjoxMzg1NjY5MDU1fQ.eyJpZCI6MX0.XbOEFJkhjHJ5uRINh2JA1BPzXjSohKYDRT472wGOvjc" 119 | } 120 | 121 | And now during the token validity period there is no need to send username and password to authenticate anymore: 122 | 123 | $ curl -u eyJhbGciOiJIUzI1NiIsImV4cCI6MTM4NTY2OTY1NSwiaWF0IjoxMzg1NjY5MDU1fQ.eyJpZCI6MX0.XbOEFJkhjHJ5uRINh2JA1BPzXjSohKYDRT472wGOvjc:x -i -X GET http://127.0.0.1:5000/api/resource 124 | HTTP/1.0 200 OK 125 | Content-Type: application/json 126 | Content-Length: 30 127 | Server: Werkzeug/0.9.4 Python/2.7.3 128 | Date: Thu, 28 Nov 2013 20:05:08 GMT 129 | 130 | { 131 | "data": "Hello, miguel!" 132 | } 133 | 134 | Once the token expires it cannot be used anymore and the client needs to request a new one. Note that in this last example the password is arbitrarily set to `x`, since the password isn't used for token authentication. 135 | 136 | An interesting side effect of this implementation is that it is possible to use an unexpired token as authentication to request a new token that extends the expiration time. This effectively allows the client to change from one token to the next and never need to send username and password after the initial token was obtained. 137 | 138 | Change Log 139 | ---------- 140 | 141 | **v0.3** - Return token duration. 142 | 143 | **v0.2** - Return a 201 status code and Location header from */api/users* endpoint. 144 | 145 | **v0.1** - Initial release. 146 | 147 | --------------------------------------------------------------------------------