Compare commits

...

4 Commits

Author SHA1 Message Date
Sebastián Ramírez
098e629344 🔖 Bump version, after changes in OAuth2 utils 2018-12-24 20:21:28 +04:00
Sebastián Ramírez
bbe5f28b77 📝 Add docs for OAuth2 security 2018-12-24 20:20:48 +04:00
Sebastián Ramírez
4a0922ebab ♻️ Update OAuth2 class utilities to be dependencies 2018-12-24 20:20:21 +04:00
Sebastián Ramírez
8f16868c6a Add passlib and pyjwt to development dependencies 2018-12-24 20:19:05 +04:00
11 changed files with 297 additions and 82 deletions

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

View File

@@ -64,8 +64,7 @@ async def get_current_active_user(current_user: User = Depends(get_current_user)
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
data = form_data.parse()
user_dict = fake_users_db.get(data.username)
user_dict = fake_users_db.get(form_data.username)
if not user_dict:
raise HTTPException(status_code=400, detail="Incorrect username or password")
user = UserInDB(**user_dict)

View File

@@ -1,5 +1,4 @@
from datetime import datetime, timedelta
from typing import Optional
import jwt
from fastapi import Depends, FastAPI, Security
@@ -23,7 +22,8 @@ fake_users_db = {
"username": "johndoe",
"full_name": "John Doe",
"email": "johndoe@example.com",
"password_hash": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW",
"hashed_password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW",
"disabled": False,
}
}
@@ -39,9 +39,9 @@ class TokenPayload(BaseModel):
class User(BaseModel):
username: str
email: Optional[str] = None
full_name: Optional[str] = None
disabled: Optional[bool] = None
email: str = None
full_name: str = None
disabled: bool = None
class UserInDB(User):
@@ -102,24 +102,21 @@ async def get_current_user(token: str = Security(oauth2_scheme)):
async def get_current_active_user(current_user: User = Depends(get_current_user)):
if not current_user.disabled:
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
@app.post("/token", response_model=Token)
async def route_login_access_token(form_data: OAuth2PasswordRequestForm):
data = form_data.parse()
user = authenticate_user(fake_users_db, data.username, data.password)
async def route_login_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=400, detail="Incorrect email or password")
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": create_access_token(
data={"username": data.username}, expires_delta=access_token_expires
),
"token_type": "bearer",
}
access_token = create_access_token(
data={"username": form_data.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/users/me", response_model=User)

View File

@@ -1,5 +1,207 @@
Coming soon...
Now that we have all the security flow, let's make the application actually secure, using JWT tokens and secure password hashing.
This code is something you can actually use in your application, save the password hashes in your database, etc.
We are going to start from where we left in the previous chapter and increment it.
## About JWT
JWT means "JSON Web Tokens".
It's a standard to codify a JSON object in a long string.
It is not encrypted, so, anyone could recover the information from the contents.
But it's signed. So, when you receive a token that you emitted, you can verify that you actually emitted it.
That way, you can create a token with an expiration of, let's say, 1 week, and then, after a week, when the user comes back with the token, you know he's still signed into your system.
And after a week, the token will be expired. And if the user (or a third party) tried to modify the token to change the expiration, you would be able to discover it, because the signature would not match.
If you want to play with JWT tokens and see how they work, check <a href="https://jwt.io/" target="_blank">https://jwt.io</a>.
## Install `PyJWT`
We need to install `PyJWT` to generate and verity the JWT tokens in Python:
```bash
pip install pyjwt
```
## Password hashing
"Hashing" means converting some content (a password in this case) into a sequence of bytes (just a string) that look like gibberish.
Whenever you pass exactly the same content (exactly the same password) you get exactly the same gibberish.
But you cannot convert from the gibberish back to the password.
### What for?
If your database is stolen, the thief won't have your users' plaintext passwords, only the hashes.
So, the thief won't be able to try to use that password in another system (as many users use the same password everywhere, this would be dangerous).
## Install `passlib`
PassLib is a great Python package to handle password hashes.
It supports many secure hashing algorithms, and utilities to work with them.
The recommended algorithm is "Bcrypt".
So, install PassLib with Bcrypt:
```Python
pip install passlib[bcrypt]
```
!!! tip
With `passlib`, you could even configure it to be able to read passwords created by **Django** (among many others).
So, you would be able to, for example, share the same data from a Django application in a database with a FastAPI application. Or gradually migrate a Django application using the same database.
## Hash and verify the passwords
Import the tools we need from `passlib`.
Create a PassLib "context". This is what will be used to hash and verify passwords.
!!! tip
The PassLib context also has functionality to use different hashing algorithms, deprecate old ones, but allow verifying them, etc.
For example, you could use it to read and verify passwords generated by another system (like Django) but hash any new passwords with a different algorithm like Bcrypt.
And be compatible with all of them at the same time.
Create a utility function to hash a password coming from the user.
And another utility to verify if a received password matches the hash stored.
And another one to authenticate and return a user.
```Python hl_lines="7 51 58 59 62 63 72 73 74 75 76 77 78"
{!./src/security/tutorial004.py!}
```
!!! note
If you check the new (fake) database `fake_users_db`, you will see how the hashed password looks like now: `"$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW"`.
## Handle JWT tokens
Import the modules installed.
Create a random secret key that will be used to sign the JWT tokens.
To generate a secure random secret, key use the command:
```bash
openssl rand -hex 32
```
And copy the output to the variable `SECRET_KEY` (don't use the one in the example).
Create a variable `ALGORITHM` with the algorithm used to sign the JWT token and set it to `"HS256"`.
And another one for the `TOKEN_SUBJECT`, and set it to, for example, `"access"`.
Create a variable for the expiration of the token.
Define a Pydantic Model that will be used in the token endpoint for the response.
Create a utility function to generate a new access token.
```Python hl_lines="3 6 14 15 16 17 31 32 33 81 82 83 84 85 86 87 88 89"
{!./src/security/tutorial004.py!}
```
## Update the dependencies
Update `get_current_user` to receive the same token as before, but this time, using JWT tokens.
Decode the received token, verify it, and return the current user.
If the token is invalid, return an HTTP error right away.
```Python hl_lines="92 93 94 95 96 97 98 99 100 101"
{!./src/security/tutorial004.py!}
```
## Update the `/token` path operation
Create a `timedelta` with the expiration time of the token.
Create a real JWT access token and return it.
```Python hl_lines="115 116 117 118 119"
{!./src/security/tutorial004.py!}
```
## Check it
Run the server and go to the docs: <a href="http://127.0.0.1:8000/docs" target="_blank">http://127.0.0.1:8000/docs</a>.
You'll see the user interface like:
<img src="/img/tutorial/security/image07.png">
Authorize the application the same way as before.
Using the credentials:
Username: `johndoe`
Password: `secret`
!!! check
Notice that nowhere in the code is the plaintext password "`secret`", we only have the hashed version.
<img src="/img/tutorial/security/image08.png">
Call the endpoint `/users/me`, you will get the response as:
```JSON
{
"username": "johndoe",
"email": "johndoe@example.com",
"full_name": "John Doe",
"disabled": false
}
```
<img src="/img/tutorial/security/image09.png">
If you open the developer tools, you could see how the data sent and received is just the token, the password is only sent in the first request to authenticate the user:
<img src="/img/tutorial/security/image10.png">
!!! note
Notice the header `Authorization`, with a value that starts with `Bearer `.
## Advanced usage with `scopes`
We didn't use it in this example, but `Security` can receive a parameter `scopes`, as a list of strings.
It would describe the scopes required for a specific path operation, as different path operations might require different security scopes, even while using the same `OAuth2PasswordBearer` (or any of the other tools).
This only applies to OAuth2, and it might be, more or less, an advanced feature, but it is there, if you need to use it.
## Recap
This concludes our tour for the security features of **FastAPI**.
In almost any framework handling the security becomes a rather complex subject quite quickly.
Many packages that simplify it a lot have to make many compromises with the data model, database, and available features. And some of these packages that simplify things too much actually have security flaws underneath.
---
**FastAPI** doesn't make any compromise with any database, data model or tool.
It gives you all the flexibility to chose the ones that fit your project the best.
And you can use directly many well maintained and widely used packages like `passlib` and `pyjwt`, because **FastAPI** doesn't require any complex mechanisms to integrate external packages.
But it provides you the tools to simplify the process as much as possible without compromising flexibility, robustness or security.
And you can use secure, standard protocols like OAuth2 in a relatively simple way.

View File

@@ -16,13 +16,13 @@ But for the login path operation, we need to use these names to be compatible wi
The spec also states that the `username` and `password` must be sent as form data (so, no JSON here).
### `scopes`
### `scope`
The spec also says that the client can send another field of "`scopes`".
The spec also says that the client can send another form field "`scope`".
As a long string with all these "scopes" separated by spaces.
The form field name is `scope` (in singular), but it is actually a long string with "scopes" separated by spaces.
Each "scope" is just a string.
Each "scope" is just a string (without spaces).
They are normally used to declare specific security permissions, for exampe:
@@ -39,8 +39,6 @@ They are normally used to declare specific security permissions, for exampe:
For OAuth2 they are just strings.
And when using `scopes` it normally referes to a long string of "scopes" separated by spaces.
## Code to get the `username` and `password`
@@ -48,17 +46,17 @@ Now let's use the utilities provided by **FastAPI** to handle this.
### `OAuth2PasswordRequestForm`
First, import `OAuth2PasswordRequestForm`, and use it as the body declaration of the path `/token`:
First, import `OAuth2PasswordRequestForm`, and use it as a dependency with `Depends` for the path `/token`:
```Python hl_lines="2 63"
```Python hl_lines="2 66"
{!./src/security/tutorial003.py!}
```
`OAuth2PasswordRequestForm` declares a form body with:
`OAuth2PasswordRequestForm` is a class dependency that declares a form body with:
* The `username`.
* The `password`.
* An optional `scopes` field as a big string, composed of strings separated by spaces.
* An optional `scope` field as a big string, composed of strings separated by spaces.
* An optional `grant_type`.
!!! tip
@@ -69,24 +67,20 @@ First, import `OAuth2PasswordRequestForm`, and use it as the body declaration of
* An optional `client_id` (we don't need it for our example).
* An optional `client_secret` (we don't need it for our example).
### Parse and use the form data
`OAuth2PasswordRequestForm` provides a `.parse()` method that converts the `scopes` string into an actual list of strings.
We are not using `scopes` in this example, but the functionality is there if you need it.
### Use the form data
!!! tip
The `.parse()` method returns a Pydantic model `OAuth2PasswordRequestData`.
The instance of the dependency class `OAuth2PasswordRequestForm` won't have an attribute `scope` with the long string separated by spaces, instead, it will have a `scopes` attribute with the actual list of strings for each scope sent.
But you don't need to import it, your editor will know its type and provide you with completion and type checks automatically.
We are not using `scopes` in this example, but the functionality is there if you need it.
Now, get the user data from the (fake) database, using this `username`.
Now, get the user data from the (fake) database, using the `username` from the form field.
If there is no such user, we return an error saying "incorrect username or password".
For the error, we use the exception `HTTPException` provided by Starlette directly:
```Python hl_lines="4 64 65 66 67"
```Python hl_lines="4 67 68 69"
{!./src/security/tutorial003.py!}
```
@@ -98,9 +92,9 @@ Let's put that data in the Pydantic `UserInDB` model first.
You should never save plaintext passwords, so, we'll use the (fake) password hashing system.
If the password doesn't match, we return the same error.
If the passwords don't match, we return the same error.
```Python hl_lines="68 69 70 71"
```Python hl_lines="70 71 72 73"
{!./src/security/tutorial003.py!}
```
@@ -112,11 +106,11 @@ Pass the keys and values of the `user_dict` directly as key-value arguments, equ
```Python
UserInDB(
username=user_dict["username"],
email=user_dict["email"],
full_name=user_dict["full_name"],
disabled=user_dict["disabled"],
hashed_password=user_dict["hashed_password"],
username = user_dict["username"],
email = user_dict["email"],
full_name = user_dict["full_name"],
disabled = user_dict["disabled"],
hashed_password = user_dict["hashed_password"],
)
```
@@ -124,18 +118,18 @@ UserInDB(
The response of the `token` endpoint must be a JSON object.
It should have a `token_type`. In our case, as we are using "Bearer" tokens, the token type should be `bearer`.
It should have a `token_type`. In our case, as we are using "Bearer" tokens, the token type should be "`bearer`".
And it should have an `access_token`, with a string containing our access token.
For this simple example, we are going to just be completely insecure and return the same `username` as the token.
!!! tip
In the next chapter, you will see a real secure implementation, with password hasing and JWT tokens.
In the next chapter, you will see a real secure implementation, with password hashing and JWT tokens.
But for now, let's focus on the specific details we need.
```Python hl_lines="73"
```Python hl_lines="75"
{!./src/security/tutorial003.py!}
```
@@ -151,7 +145,7 @@ Both of these dependencies will just return an HTTP error if the user doesn't ex
So, in our endpoint, we will only get a user if the user exists, was correctly authenticated, and is active:
```Python hl_lines="49 50 51 52 53 56 57 58 59 77"
```Python hl_lines="50 51 52 53 54 55 56 59 60 61 62 79"
{!./src/security/tutorial003.py!}
```

View File

@@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
__version__ = "0.1.13"
__version__ = "0.1.14"
from .applications import FastAPI
from .routing import APIRouter

View File

@@ -1,24 +1,13 @@
from typing import List, Optional
from typing import Optional
from fastapi.openapi.models import OAuth2 as OAuth2Model, OAuthFlows as OAuthFlowsModel
from fastapi.params import Form
from fastapi.security.base import SecurityBase
from pydantic import BaseModel, Schema
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.status import HTTP_403_FORBIDDEN
class OAuth2PasswordRequestData(BaseModel):
grant_type: str = "password"
username: str
password: str
scope: Optional[List[str]] = None
# Client ID and secret might come from headers
client_id: Optional[str] = None
client_secret: Optional[str] = None
class OAuth2PasswordRequestForm:
"""
This is a dependency class, use it like:
@@ -28,7 +17,7 @@ class OAuth2PasswordRequestForm:
data = form_data.parse()
print(data.username)
print(data.password)
for scope in data.scope:
for scope in data.scopes:
print(scope)
if data.client_id:
print(data.client_id)
@@ -40,8 +29,8 @@ class OAuth2PasswordRequestForm:
It creates the following Form request parameters in your endpoint:
grant_type: the OAuth2 spec says it is required and MUST be the fixed string "password".
Nevertheless, this model is permissive and allows not passing it. If you want to enforce it,
use instead the OAuth2PasswordRequestFormStrict model.
Nevertheless, this dependency class is permissive and allows not passing it. If you want to enforce it,
use instead the OAuth2PasswordRequestFormStrict dependency.
username: username string. The OAuth2 spec requires the exact field name "username".
password: password string. The OAuth2 spec requires the exact field name "password".
scope: Optional string. Several scopes (each one a string) separated by spaces. E.g.
@@ -50,9 +39,6 @@ class OAuth2PasswordRequestForm:
using HTTP Basic auth, as: client_id:client_secret
client_secret: optional string. OAuth2 recommends sending the client_id and client_secret (if any)
using HTTP Basic auth, as: client_id:client_secret
It has the method parse() that returns a model with all the same data and the scopes extracted as a list of strings.
"""
def __init__(
@@ -67,24 +53,61 @@ class OAuth2PasswordRequestForm:
self.grant_type = grant_type
self.username = username
self.password = password
self.scope = scope
self.scopes = scope.split()
self.client_id = client_id
self.client_secret = client_secret
def parse(self) -> OAuth2PasswordRequestData:
return OAuth2PasswordRequestData(
grant_type=self.grant_type,
username=self.username,
password=self.password,
scope=self.scope.split(),
client_id=self.client_id,
client_secret=self.client_secret,
)
class OAuth2PasswordRequestFormStrict(OAuth2PasswordRequestForm):
# The OAuth2 spec says it MUST have the value "password"
grant_type: str = Schema(..., regex="password")
"""
This is a dependency class, use it like:
@app.post("/login")
def login(form_data: Oauth2PasswordRequestFormStrict = Depends()):
data = form_data.parse()
print(data.username)
print(data.password)
for scope in data.scopes:
print(scope)
if data.client_id:
print(data.client_id)
if data.client_secret:
print(data.client_secret)
return data
It creates the following Form request parameters in your endpoint:
grant_type: the OAuth2 spec says it is required and MUST be the fixed string "password".
This dependency is strict about it. If you want to be permissive, use instead the
OAuth2PasswordRequestFormStrict dependency class.
username: username string. The OAuth2 spec requires the exact field name "username".
password: password string. The OAuth2 spec requires the exact field name "password".
scope: Optional string. Several scopes (each one a string) separated by spaces. E.g.
"items:read items:write users:read profile openid"
client_id: optional string. OAuth2 recommends sending the client_id and client_secret (if any)
using HTTP Basic auth, as: client_id:client_secret
client_secret: optional string. OAuth2 recommends sending the client_id and client_secret (if any)
using HTTP Basic auth, as: client_id:client_secret
"""
def __init__(
self,
grant_type: str = Form(..., regex="password"),
username: str = Form(...),
password: str = Form(...),
scope: str = Form(""),
client_id: Optional[str] = Form(None),
client_secret: Optional[str] = Form(None),
):
super().__init__(
grant_type=grant_type,
username=username,
password=password,
scope=scope,
client_id=client_id,
client_secret=client_secret,
)
class OAuth2(SecurityBase):

View File

@@ -44,8 +44,8 @@ doc = [
"markdown-include"
]
dev = [
"prospector",
"rope"
"pyjwt",
"passlib[bcrypt]"
]
all = [
"requests",