5 changed files with 221 additions and 14 deletions
After Width: | Height: | Size: 82 KiB |
After Width: | Height: | Size: 79 KiB |
After Width: | Height: | Size: 89 KiB |
@ -1,5 +1,3 @@ |
|||
from typing import Optional |
|||
|
|||
from fastapi import Depends, FastAPI, Security |
|||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm |
|||
from pydantic import BaseModel |
|||
@ -10,7 +8,8 @@ fake_users_db = { |
|||
"username": "johndoe", |
|||
"full_name": "John Doe", |
|||
"email": "[email protected]", |
|||
"password_hash": "fakehashedsecret", |
|||
"hashed_password": "fakehashedsecret", |
|||
"disabled": False, |
|||
} |
|||
} |
|||
|
|||
@ -26,9 +25,9 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token") |
|||
|
|||
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): |
|||
@ -51,26 +50,28 @@ def fake_decode_token(token): |
|||
async def get_current_user(token: str = Security(oauth2_scheme)): |
|||
user = fake_decode_token(token) |
|||
if not user: |
|||
raise HTTPException(status_code=400, detail="Inactive user") |
|||
raise HTTPException( |
|||
status_code=400, detail="Invalid authentication credentials" |
|||
) |
|||
return user |
|||
|
|||
|
|||
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") |
|||
async def login(form_data: OAuth2PasswordRequestForm): |
|||
async def login(form_data: OAuth2PasswordRequestForm = Depends()): |
|||
data = form_data.parse() |
|||
user_dict = fake_users_db[data.username] |
|||
user_dict = fake_users_db.get(data.username) |
|||
if not user_dict: |
|||
raise HTTPException(status_code=400, detail="Incorrect username or password") |
|||
user = UserInDB(**user_dict) |
|||
if not user: |
|||
raise HTTPException(status_code=400, detail="Incorrect email or password") |
|||
hashed_password = fake_hash_password(data.password) |
|||
if not hashed_password == user.hashed_password: |
|||
raise HTTPException(status_code=400, detail="Incorrect email or password") |
|||
raise HTTPException(status_code=400, detail="Incorrect username or password") |
|||
|
|||
return {"access_token": user.username, "token_type": "bearer"} |
|||
|
|||
|
@ -1,5 +1,211 @@ |
|||
Coming soon... |
|||
Now let's build from the previous chapter and add the missing parts to have a complete security flow. |
|||
|
|||
## Get the `username` and `password` |
|||
|
|||
We are going to use **FastAPI** security utilities to get the `username` and `password`. |
|||
|
|||
OAuth2 specifies that when using the "password flow" (that we are using) the client / user must send a `username` and `password` fields as form data. |
|||
|
|||
And the spec says that the fields have to be named like that. So `user-name` or `email` wouldn't work. |
|||
|
|||
But don't worry, you can show it as you wish to your final users in the frontend. |
|||
|
|||
And your database models can use any other names you want. |
|||
|
|||
But for the login path operation, we need to use these names to be compatible with the spec (and be able to, for example, use the integrated API documentation system). |
|||
|
|||
The spec also states that the `username` and `password` must be sent as form data (so, no JSON here). |
|||
|
|||
### `scopes` |
|||
|
|||
The spec also says that the client can send another field of "`scopes`". |
|||
|
|||
As a long string with all these "scopes" separated by spaces. |
|||
|
|||
Each "scope" is just a string. |
|||
|
|||
They are normally used to declare specific security permissions, for exampe: |
|||
|
|||
* `"users:read"` or `"users:write"` are common examples. |
|||
* `instagram_basic` is used by Facebook / Instagram. |
|||
* `https://www.googleapis.com/auth/drive` is used by Google. |
|||
|
|||
!!! info |
|||
In OAuth2 a "scope" is just a string that declares a specific permision required. |
|||
|
|||
It doesn't matter if it has other characters like `:`, or if it is a URL. |
|||
|
|||
Those details are implementation specific. |
|||
|
|||
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` |
|||
|
|||
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`: |
|||
|
|||
```Python hl_lines="2 63" |
|||
{!./src/security/tutorial003.py!} |
|||
``` |
|||
|
|||
`OAuth2PasswordRequestForm` 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 `grant_type`. |
|||
|
|||
!!! tip |
|||
The OAuth2 spec actually *requires* a field `grant_type` with a fixed value of `password`, but `OAuth2PasswordRequestForm` doesn't enforce it. |
|||
|
|||
If you need to enforce it, use `OAuth2PasswordRequestFormStrict` instead of `OAuth2PasswordRequestForm`. |
|||
|
|||
* 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. |
|||
|
|||
!!! tip |
|||
The `.parse()` method returns a Pydantic model `OAuth2PasswordRequestData`. |
|||
|
|||
But you don't need to import it, your editor will know its type and provide you with completion and type checks automatically. |
|||
|
|||
Now, get the user data from the (fake) database, using this `username`. |
|||
|
|||
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" |
|||
{!./src/security/tutorial003.py!} |
|||
``` |
|||
|
|||
### Check the password |
|||
|
|||
At this point we have a the user data from our database, but we haven't checked the password. |
|||
|
|||
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. |
|||
|
|||
```Python hl_lines="68 69 70 71" |
|||
{!./src/security/tutorial003.py!} |
|||
``` |
|||
|
|||
#### About `**user_dict` |
|||
|
|||
`UserInDB(**user_dict)` means: |
|||
|
|||
Pass the keys and values of the `user_dict` directly as key-value arguments, equivalent to: |
|||
|
|||
```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"], |
|||
) |
|||
``` |
|||
|
|||
## Return the token |
|||
|
|||
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`. |
|||
|
|||
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. |
|||
|
|||
But for now, let's focus on the specific details we need. |
|||
|
|||
```Python hl_lines="73" |
|||
{!./src/security/tutorial003.py!} |
|||
``` |
|||
|
|||
## Update the dependencies |
|||
|
|||
Now we are going to update our dependencies. |
|||
|
|||
We want to get the `current_user` *only* if this user is active. |
|||
|
|||
So, we create an additional dependency `get_current_active_user` that in turn uses `get_current_user` as a dependency. |
|||
|
|||
Both of these dependencies will just return an HTTP error if the user doesn't exists, or if is inactive. |
|||
|
|||
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" |
|||
{!./src/security/tutorial003.py!} |
|||
``` |
|||
|
|||
## See it in action |
|||
|
|||
Open the interactive docs: <a href="http://127.0.0.1:8000/docs" target="_blank">http://127.0.0.1:8000/docs</a>. |
|||
|
|||
### Authenticate |
|||
|
|||
Click the "Authorize" button. |
|||
|
|||
Use the credentials: |
|||
|
|||
User: `johndoe` |
|||
Password: `secret` |
|||
|
|||
<img src="/img/tutorial/security/image04.png"> |
|||
|
|||
After authenticating in the system, you will see it like: |
|||
|
|||
<img src="/img/tutorial/security/image05.png"> |
|||
|
|||
### Get your own user data |
|||
|
|||
Now use the operation `GET` with the path `/users/me`. |
|||
|
|||
You will get your user's data, like: |
|||
|
|||
```JSON |
|||
{ |
|||
"username": "johndoe", |
|||
"email": "[email protected]", |
|||
"full_name": "John Doe", |
|||
"disabled": false, |
|||
"hashed_password": "fakehashedsecret" |
|||
} |
|||
``` |
|||
|
|||
<img src="/img/tutorial/security/image06.png"> |
|||
|
|||
If you click the lock icon and logout, and then try the same operation again, you will get an HTTP 403 error of: |
|||
|
|||
```JSON |
|||
{ |
|||
"detail": "Not authenticated" |
|||
} |
|||
``` |
|||
|
|||
## Recap |
|||
|
|||
You now have the tools to implement a complete security system based on `username` and `password` for your API. |
|||
|
|||
Using these tools, you can make the security system compatible with any database and with any user or data model. |
|||
|
|||
The only detail missing is that it is not actually "secure" yet. |
|||
|
|||
In the next chapter you'll see how to use a secure password hashing library and JWT tokens. |
|||
|
Loading…
Reference in new issue