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
PyJWTto yourrequirements.txtfile:$ 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.pyscript 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.pyscript with the-cargument 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_DBin yourapp.pyfile 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.pyfile:
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
/loginthat accepts the POST method and grabs theusernameandpasswordfrom the request, and forwards it along to a helper function in theauthcode 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
authfile that we copied into our chalicelib directory at the beginning of this step. Add the following import statement to the top ofapp.pyso 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 localand make an HTTPPOSTrequest to the/loginURI:$ 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.pyfile 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_idto the username in the JWT token.Once we have defined the authorizer, we will attach it to the
get_todosroute.
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 localTry 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
Authorizationheader:$ 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_authauthorizer to theadd_new_todoroute.Attach the
jwt_authauthorizer to theget_todoroute.Attach the
jwt_authauthorizer to thedelete_todoroute.Attach the
jwt_authauthorizer to theupdate_todoroute.
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 localTry each route without an authorization token. You should get a
401Unauthorized 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
Authorizationheader 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_usernamethat will be used to convert the information we have in ourcurrent_requestinto 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
usernameand pass it to thexxx_itemmethod.
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-b9669d15af7dList 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
defaultit should be whatever username went with the auth token you supplied.Try making a new user with
python users.py -cand 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
Authorizationheader. 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 deployyour 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 urlwhich 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)
|