Section 4: Add authorization to Todo application¶
If you had noticed from the previous steps, there was a username
field
for all of the Todos, but the username
was always set to default
.
This step will be utilizing the username
field by exposing the notion
of users and authorization in the Todo application. For this section, we will
be doing the following to add authorization and users to the application:
Install PyJWT¶
For authorization, the application is going to be relying on JWT. To depend
on JWT, in the Chalice application PyJWT
needs to be installed and added
to our requirements.txt
file.
Instructions¶
Add
PyJWT
to yourrequirements.txt
file:$ echo PyJWT==1.6.1 >> requirements.txt
Make sure it is now installed in your virtualenv:
$ pip install -r requirements.txt
Verification¶
To ensure that it was installed, open the Python REPL and try to import
the PyJWT
library:
$ python
Python 2.7.10 (default, Mar 10 2016, 09:55:31)
[GCC 4.2.1 Compatible Apple LLVM 7.0.2 (clang-700.1.81)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import jwt
Copy over auth specific files¶
In order to add authentication to your Chalice application we have provided a few
files that help with some of the low-level details. We have added an auth.py
file
to chalicelib
which abstracts away some of the details of handling JWT tokens. We
have also added a users.py
script which is a command line utility for creating and
managing a user table.
Instructions¶
1) Copy in the chalice-workshop/code/todo-app/part1/04-add-auth/chalicelib/auth.py
file:
$ cp ../chalice-workshop/code/todo-app/part1/04-add-auth/chalicelib/auth.py chalicelib/auth.py
2) Copy over the chalice-workshop/code/todo-app/part1/04-add-auth/users.py
script for
creating users:
$ cp ../chalice-workshop/code/todo-app/part1/04-add-auth/users.py users.py
Verification¶
From within the mytodo
directory of your Todo Chalice application, the
structure should be the following:
$ tree
.
├── app.py
├── chalicelib
│ ├── __init__.py
│ ├── auth.py
│ └── db.py
├── createtable.py
├── requirements.txt
└── users.py
Create a DynamoDB user table¶
Using the createtable.py
script, this will create another DynamoDB table
for storing users to use in the Chalice application.
Instructions¶
Run the
createtable.py
script to create the DynamoDB table:$ python createtable.py -t users
Verification¶
Check that the return code of the command is 0
:
$ echo $?
0
Also cat
the .chalice/config.json
to make sure the USERS_TABLE_NAME
shows up as an environment variable:
$ cat .chalice/config.json
{
"stages": {
"dev": {
"environment_variables": {
"USERS_TABLE_NAME": "users-app-21658b12-517e-4441-baef-99b8fc2f0b61",
"APP_TABLE_NAME": "todo-app-323ca4c3-54fb-4e49-a584-c52625e5d85d"
},
"autogen_policy": false,
"api_gateway_stage": "api"
}
},
"version": "2.0",
"app_name": "mytodo"
}
Add a user to the user table¶
Using the users.py
script, create a new user in your users database to
use with your chalice application.
Instructions¶
Run the
users.py
script with the-c
argument to create a user. You will be prompted for a username and a password:$ python users.py -c Username: user Password:
Verification¶
Using the users.py
script, make sure that the user is listed in your
database:
$ python users.py -l
user
Also make sure that the password is correct by testing the username and
password with the users.py
script:
$ python users.py -t
Username: user
Password:
Password verified.
You can also test an incorrect password. You should see this output:
$ python users.py -t
Username: user
Password:
Password verification failed.
Create get_users_db
function¶
Now that we have created a DynamoDB user table, we will create a convenience function for loading it.
Instructions¶
Add a new variable
_USER_DB
in yourapp.py
file with a value of None:
app = Chalice(app_name='mytodo')
app.debug = True
_DB = None
# This is the new value you're adding.
_USER_DB = None
Create a function for fetching our current database table for users. Similar to the function that gets the app table. Add this function to your
app.py
file:
1 2 3 4 5 6 | def get_users_db():
global _USER_DB
if _USER_DB is None:
_USER_DB = boto3.resource('dynamodb').Table(
os.environ['USERS_TABLE_NAME'])
return _USER_DB
|
Create a login route¶
We will now create a login route where users can trade their username/password for a JWT token.
Instructions¶
Define a new Chalice route
/login
that accepts the POST method and grabs theusername
andpassword
from the request, and forwards it along to a helper function in theauth
code you copied in earlier which will trade those for a JWT token.
1 2 3 4 5 6 7 8 | @app.route('/login', methods=['POST'])
def login():
body = app.current_request.json_body
record = get_users_db().get_item(
Key={'username': body['username']})['Item']
jwt_token = auth.get_jwt_token(
body['username'], body['password'], record)
return {'token': jwt_token}
|
Notice the above code snippit uses the
auth
file that we copied into our chalicelib directory at the beginning of this step. Add the following import statement to the top ofapp.py
so we can use it:from chalicelib import auth
Verification¶
Start up a local server using
chalice local
.Using the username and password generated previously, run
chalice local
and make an HTTPPOST
request to the/login
URI:$ echo '{"username": "user", "password": "password"}' | \ http POST localhost:8000/login HTTP/1.1 200 OK Content-Length: 218 Content-Type: application/json Date: Fri, 20 Oct 2017 22:48:42 GMT Server: BaseHTTP/0.3 Python/2.7.10 { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1MDg1Mzk3MjIsImp0aSI6IjI5ZDJhNmFkLTdlY2YtNDYzZC1iOTY1LTk0M2VhNzU0YWMzYyIsInN1YiI6InVzZXIiLCJuYmYiOjE1MDg1Mzk3MjJ9.95hlpRWARK95aYCh0YE7ls_cvraoenNux8gmIy8vQU8" }
This should return a JWT to use as an Authorization
header for that user.
Create a custom authorizer and attach to a route¶
To add authorization to our app we will start by defining an authorizer and attaching it to one of our routes.
Instructions¶
Create an authorizer function that checks the validity of a JWT token using the existing code in the
auth.py
file we copied earlier. If the token is valid (didn’t throw an error) we will return a policy that allows access to all of our routes, and sets theprincipal_id
to the username in the JWT token.Once we have defined the authorizer, we will attach it to the
get_todos
route.
1 2 3 4 5 | @app.authorizer()
def jwt_auth(auth_request):
token = auth_request.token
decoded = auth.decode_jwt_token(token)
return AuthResponse(routes=['*'], principal_id=decoded['sub'])
|
1 2 | @app.route('/todos', methods=['GET'], authorizer=jwt_auth)
def get_todos():
|
Also make sure to import the AuthResponse
class at the top of the app.py
file:
from chalice import AuthResponse
Verification¶
Start the local dev server
chalice local
Try to get the todo, the request should be rejected without authorization:
$ http localhost:8000/todos HTTP/1.1 401 Unauthorized Content-Length: 26 Content-Type: application/json Date: Tue, 24 Oct 2017 02:50:50 GMT Server: BaseHTTP/0.3 Python/2.7.13 x-amzn-ErrorType: UnauthorizedException x-amzn-RequestId: 297d1da8-b9a8-4824-a1f3-293607aac715 { "message": "Unauthorized" }
Try the same call again but with your authorization token passed in the
Authorization
header:$ http localhost:8000/todos \ Authorization:eyJhbGciOi.... really long token here... Content-Length: 137 Content-Type: application/json Date: Tue, 24 Oct 2017 02:50:43 GMT Server: BaseHTTP/0.3 Python/2.7.13 [ { "description": "My first Todo", "metadata": {}, "state": "unstarted", "uid": "f9a992d6-41c0-45a6-84b8-e7239f7d7100", "username": "john" } ]
Attach authorizer to the rest of the routes¶
Now attach the authorizer to all the other routes except the login
route.
Instructions¶
Attach the
jwt_auth
authorizer to theadd_new_todo
route.Attach the
jwt_auth
authorizer to theget_todo
route.Attach the
jwt_auth
authorizer to thedelete_todo
route.Attach the
jwt_auth
authorizer to theupdate_todo
route.
1 2 | @app.route('/todos', methods=['POST'], authorizer=jwt_auth)
def add_new_todo():
|
1 2 | @app.route('/todos/{uid}', methods=['GET'], authorizer=jwt_auth)
def get_todo(uid):
|
1 2 | @app.route('/todos/{uid}', methods=['DELETE'], authorizer=jwt_auth)
def delete_todo(uid):
|
1 2 | @app.route('/todos/{uid}', methods=['PUT'], authorizer=jwt_auth)
def update_todo(uid):
|
Verification¶
Start up the local dev server
chalice local
Try each route without an authorization token. You should get a
401
Unauthorized response:$ echo '{"description": "My first Todo", "metadata": {}}' | \ http POST localhost:8000/todos HTTP/1.1 401 Unauthorized Content-Length: 26 Content-Type: application/json Date: Tue, 24 Oct 2017 03:14:14 GMT Server: BaseHTTP/0.3 Python/2.7.13 x-amzn-ErrorType: UnauthorizedException x-amzn-RequestId: 58c2d520-07e6-4535-b034-aaba41bab8ab { "message": "Unauthorized" }
$ http GET localhost:8000/todos/fake-id HTTP/1.1 401 Unauthorized Content-Length: 26 Content-Type: application/json Date: Tue, 24 Oct 2017 03:15:10 GMT Server: BaseHTTP/0.3 Python/2.7.13 x-amzn-ErrorType: UnauthorizedException x-amzn-RequestId: b2304a70-ff8d-453f-b119-10e75326463a { "message": "Unauthorized" }$ http DELETE localhost:8000/todos/fake-id HTTP/1.1 401 Unauthorized Content-Length: 26 Content-Type: application/json Date: Tue, 24 Oct 2017 03:17:10 GMT Server: BaseHTTP/0.3 Python/2.7.13 x-amzn-ErrorType: UnauthorizedException x-amzn-RequestId: 69419241-b244-462b-b108-72091f7d7b5b { "message": "Unauthorized" }$ echo '{"state": "started"}' | http PUT localhost:8000/todos/fake-id HTTP/1.1 401 Unauthorized Content-Length: 26 Content-Type: application/json Date: Tue, 24 Oct 2017 03:18:59 GMT Server: BaseHTTP/0.3 Python/2.7.13 x-amzn-ErrorType: UnauthorizedException x-amzn-RequestId: edc77f3d-3d3d-4a29-850a-502f21aeed96 { "message": "Unauthorized" }
Now try to create, get, update, and delete a todo from your application by using the
Authorization
header in all your requests:$ echo '{"description": "My first Todo", "metadata": {}}' | \ http POST localhost:8000/todos Authorization:eyJhbG... auth token ... HTTP/1.1 200 OK Content-Length: 36 Content-Type: application/json Date: Tue, 24 Oct 2017 03:24:28 GMT Server: BaseHTTP/0.3 Python/2.7.13 93dbabdb-3b2f-4029-845b-7754406c494f
$ echo '{"state": "started"}' | \ http PUT localhost:8000/todos/93dbabdb-3b2f-4029-845b-7754406c494f \ Authorization:eyJhbG... auth token ... HTTP/1.1 200 OK Content-Length: 4 Content-Type: application/json Date: Tue, 24 Oct 2017 03:25:28 GMT Server: BaseHTTP/0.3 Python/2.7.13 null$ http localhost:8000/todos/93dbabdb-3b2f-4029-845b-7754406c494f \ Authorization:eyJhbG... auth token ... HTTP/1.1 200 OK Content-Length: 135 Content-Type: application/json Date: Tue, 24 Oct 2017 03:26:29 GMT Server: BaseHTTP/0.3 Python/2.7.13 { "description": "My first Todo", "metadata": {}, "state": "started", "uid": "93dbabdb-3b2f-4029-845b-7754406c494f", "username": "default" }$ http DELETE localhost:8000/todos/93dbabdb-3b2f-4029-845b-7754406c494f \ Authorization:eyJhbG... auth token ... HTTP/1.1 200 OK Content-Length: 4 Content-Type: application/json Date: Tue, 24 Oct 2017 03:27:10 GMT Server: BaseHTTP/0.3 Python/2.7.13 null
Use authorizer provided username¶
Now that we have authorizers hooked up to all our routes we can use that
instead of relying on the default user of default
.
Instructions¶
First create a function named
get_authorized_username
that will be used to convert the information we have in ourcurrent_request
into a username.
1 2 | def get_authorized_username(current_request):
return current_request.context['authorizer']['principalId']
|
Now we need to update each function that interacts with our database to calculate the
username
and pass it to thexxx_item
method.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | @app.route('/todos', methods=['GET'], authorizer=jwt_auth)
def get_todos():
username = get_authorized_username(app.current_request)
return get_app_db().list_items(username=username)
@app.route('/todos', methods=['POST'], authorizer=jwt_auth)
def add_new_todo():
body = app.current_request.json_body
username = get_authorized_username(app.current_request)
return get_app_db().add_item(
username=username,
description=body['description'],
metadata=body.get('metadata'),
)
@app.route('/todos/{uid}', methods=['GET'], authorizer=jwt_auth)
def get_todo(uid):
username = get_authorized_username(app.current_request)
return get_app_db().get_item(uid, username=username)
@app.route('/todos/{uid}', methods=['DELETE'], authorizer=jwt_auth)
def delete_todo(uid):
username = get_authorized_username(app.current_request)
return get_app_db().delete_item(uid, username=username)
@app.route('/todos/{uid}', methods=['PUT'], authorizer=jwt_auth)
def update_todo(uid):
body = app.current_request.json_body
username = get_authorized_username(app.current_request)
get_app_db().update_item(
uid,
description=body.get('description'),
state=body.get('state'),
metadata=body.get('metadata'),
username=username)
|
Verification¶
Spin up the local Chalice server with
chalice local
.Create a new todo and pass in your auth token:
$ echo '{"description": "a todo", "metadata": {}}' | \ http POST localhost:8000/todos Authorization:eyJhbG... auth token ... HTTP/1.1 200 OK Content-Length: 36 Content-Type: application/json Date: Tue, 24 Oct 2017 04:16:57 GMT Server: BaseHTTP/0.3 Python/2.7.13 71048cc2-8583-41e5-9dfe-b9669d15af7d
List your todos using the get_todos route:
$ http localhost:8000/todos Authorization:eyJhbG... auth token ... HTTP/1.1 200 OK Content-Length: 132 Content-Type: application/json Date: Tue, 24 Oct 2017 04:21:58 GMT Server: BaseHTTP/0.3 Python/2.7.13 [ { "description": "a todo", "metadata": {}, "state": "unstarted", "uid": "7212a932-769b-4a19-9531-a950db7006a5", "username": "john" } ]
Notice that now the username is no longer
default
it should be whatever username went with the auth token you supplied.Try making a new user with
python users.py -c
and then get their JWT token by calling the login route with their credentials.Call the same route as above as the new user by passing in their JWT token in the
Authorization
header. They should get no todos since they have not created any yet:http localhost:8000/todos 'Authorization:...the other auth token...' HTTP/1.1 200 OK Content-Length: 2 Content-Type: application/json Date: Tue, 24 Oct 2017 04:25:56 GMT Server: BaseHTTP/0.3 Python/2.7.13 []
Deploying your authorizer code¶
Now that we have it working locally lets deploy it and verify that it still works.
Instructions¶
chalice deploy
your app.
Verification¶
Try the same two calls above against the real API Gateway endpoint you get from your deploy instead of the localhost endpoint. If you lose your endpoint you can run
chalice url
which will print out your API Gateway endpoint:$ http <your endpoint here>/todos \ Authorization:...auth token that has no todos... HTTP/1.1 200 OK Connection: keep-alive Content-Length: 2 Content-Type: application/json Date: Tue, 24 Oct 2017 04:43:20 GMT Via: 1.1 cff9911a0035fa608bcaa4e9709161b3.cloudfront.net (CloudFront) X-Amz-Cf-Id: bunfoZShHff_f3AqBPS2d5Ae3ymqgBusANDP9G6NvAZB3gOfr1IsVA== X-Amzn-Trace-Id: sampled=0;root=1-59f01668-388cc9fa3db607662c2d623c X-Cache: Miss from cloudfront x-amzn-RequestId: 06de2818-b93f-11e7-bbb0-b760b41808da []
$ http <your endpoint here>/todos \ Authorization:...auth token that has a todo... HTTP/1.1 200 OK Connection: keep-alive Content-Length: 132 Content-Type: application/json Date: Tue, 24 Oct 2017 04:43:45 GMT Via: 1.1 a05e153e17e2a6485edf7bf733e131a4.cloudfront.net (CloudFront) X-Amz-Cf-Id: wR_7Bp4KglDjF41_9TNxXmc3Oiu2kll5XS1sTCCP_LD1kMC3C-nqOA== X-Amzn-Trace-Id: sampled=0;root=1-59f01681-bb8ce2d74dc0c6f8fe095f9d X-Cache: Miss from cloudfront x-amzn-RequestId: 155f88f7-b93f-11e7-b351-775deacbeb7a [ { "description": "a todo", "metadata": {}, "state": "unstarted", "uid": "7212a932-769b-4a19-9531-a950db7006a5", "username": "john" } ]
Final Code¶
When you are finished your app.py
file should look like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | import os
import boto3
from chalice import Chalice, AuthResponse
from chalicelib import auth, db
app = Chalice(app_name='mytodo')
app.debug = True
_DB = None
_USER_DB = None
@app.route('/login', methods=['POST'])
def login():
body = app.current_request.json_body
record = get_users_db().get_item(
Key={'username': body['username']})['Item']
jwt_token = auth.get_jwt_token(
body['username'], body['password'], record)
return {'token': jwt_token}
@app.authorizer()
def jwt_auth(auth_request):
token = auth_request.token
decoded = auth.decode_jwt_token(token)
return AuthResponse(routes=['*'], principal_id=decoded['sub'])
def get_users_db():
global _USER_DB
if _USER_DB is None:
_USER_DB = boto3.resource('dynamodb').Table(
os.environ['USERS_TABLE_NAME'])
return _USER_DB
# Rest API code
def get_app_db():
global _DB
if _DB is None:
_DB = db.DynamoDBTodo(
boto3.resource('dynamodb').Table(
os.environ['APP_TABLE_NAME'])
)
return _DB
def get_authorized_username(current_request):
return current_request.context['authorizer']['principalId']
@app.route('/todos', methods=['GET'], authorizer=jwt_auth)
def get_todos():
username = get_authorized_username(app.current_request)
return get_app_db().list_items(username=username)
@app.route('/todos', methods=['POST'], authorizer=jwt_auth)
def add_new_todo():
body = app.current_request.json_body
username = get_authorized_username(app.current_request)
return get_app_db().add_item(
username=username,
description=body['description'],
metadata=body.get('metadata'),
)
@app.route('/todos/{uid}', methods=['GET'], authorizer=jwt_auth)
def get_todo(uid):
username = get_authorized_username(app.current_request)
return get_app_db().get_item(uid, username=username)
@app.route('/todos/{uid}', methods=['DELETE'], authorizer=jwt_auth)
def delete_todo(uid):
username = get_authorized_username(app.current_request)
return get_app_db().delete_item(uid, username=username)
@app.route('/todos/{uid}', methods=['PUT'], authorizer=jwt_auth)
def update_todo(uid):
body = app.current_request.json_body
username = get_authorized_username(app.current_request)
get_app_db().update_item(
uid,
description=body.get('description'),
state=body.get('state'),
metadata=body.get('metadata'),
username=username)
|