Revamp authentication routes structure (#201)

* Fix #68: use makefun to generate dynamic dependencies

* Remove every Starlette imports

* Split every routers and remove event handlers

* Make users router optional

* Pass after_update handler to get_users_router

* Update documentation

* Remove test file

* Write migration doc for splitted routers
This commit is contained in:
François Voron
2020-05-24 10:18:01 +02:00
committed by GitHub
parent 0a0dcadfdc
commit 7721f8dcc1
48 changed files with 1633 additions and 1167 deletions

View File

@@ -39,6 +39,7 @@ pyjwt = "==1.7.1"
python-multipart = "==0.0.5"
motor = "==2.1.0"
tortoise-orm = ">=0.15.18,<0.17.0"
makefun = ">=1.9.2,<1.10"
[requires]
python_version = "3.7"

32
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "cd178656ef0c106c2a71e1c48b5da4ef08a8dcc650838b9f21ba8c30c2cc417c"
"sha256": "adf89bd2e09507bfc26904d8fa1a98ceb16f1f9c1486bbca53834d68f85c40f2"
},
"pipfile-spec": 6,
"requires": {
@@ -123,6 +123,14 @@
],
"version": "==2.9"
},
"makefun": {
"hashes": [
"sha256:3fb5993cfe3b318ea8bb820a707898c8dfd15e3864a1c923430c35528094d146",
"sha256:8ebc8f5bbd84a010ad67f0ee6bf6ca896481869aba2a740809590f8bff7e7b94"
],
"index": "pypi",
"version": "==1.9.2"
},
"motor": {
"hashes": [
"sha256:599719bc6dcddc3b9ea4e09659fb0073d5fadcc24735999b2902f48cef33f909",
@@ -253,10 +261,10 @@
},
"six": {
"hashes": [
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
"version": "==1.14.0"
"version": "==1.15.0"
},
"sqlalchemy": {
"hashes": [
@@ -399,10 +407,10 @@
},
"codecov": {
"hashes": [
"sha256:43ad6cb3e7de073d911aa3ab6a754db88d270fcb0e0d8e2062b964098a51d69b"
"sha256:2ebd639d8f621aabcce399e475b0302e436cb7e00e7724d1b2224bbf3f215a0c"
],
"index": "pypi",
"version": "==2.1.0"
"version": "==2.1.3"
},
"coverage": {
"hashes": [
@@ -728,10 +736,10 @@
},
"packaging": {
"hashes": [
"sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3",
"sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"
"sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
"sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"
],
"version": "==20.3"
"version": "==20.4"
},
"pathspec": {
"hashes": [
@@ -895,10 +903,10 @@
},
"six": {
"hashes": [
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
"version": "==1.14.0"
"version": "==1.15.0"
},
"sniffio": {
"hashes": [

View File

@@ -37,6 +37,7 @@ Add quickly a registration and authentication system to your [FastAPI](https://f
* [X] Multiple customizable authentication backends
* [X] JWT authentication backend included
* [X] Cookie authentication backend included
* [X] Full OpenAPI schema support, even with several authentication backends
## Development

View File

@@ -18,16 +18,6 @@ auth_backends.append(cookie_authentication)
As you can see, instantiation is quite simple. You just have to define a constant `SECRET` which is used to encode the token and the lifetime of the cookie (in seconds).
You can optionally define the `name` which will be used to generate its [`/login` route](../../usage/routes.md#post-loginname). **Defaults to `cookie`**.
```py
cookie_authentication = CookieAuthentication(
secret=SECRET,
lifetime_seconds=3600,
name="my-cookie",
)
```
You can also define the parameters for the generated cookie:
* `cookie_name` (`fastapiusersauth`): Name of the cookie.
@@ -36,6 +26,17 @@ You can also define the parameters for the generated cookie:
* `cookie_secure` (`True`): Whether to only send the cookie to the server via SSL request.
* `cookie_httponly` (`True`): Whether to prevent access to the cookie via JavaScript.
!!! tip
You can also optionally define the `name`. It's useful in the case you wish to have several backends of the same class. Each backend should have a unique name. **Defaults to `cookie`**.
```py
cookie_authentication = CookieAuthentication(
secret=SECRET,
lifetime_seconds=3600,
name="my-cookie",
)
```
!!! tip
The value of the cookie is actually a JWT. This authentication backend shares most of its logic with the [JWT](./jwt.md) one.
@@ -61,4 +62,4 @@ This method expects that you provide a valid cookie in the headers.
## Next steps
We will now configure the main **FastAPI Users** object that will expose the [API router](../router.md).
We will now configure the main **FastAPI Users** object that will expose the [routers](../routers/index.md).

View File

@@ -8,9 +8,7 @@ You can have **several** authentication methods, e.g. a cookie authentication fo
When checking authentication, each method is run one after the other. The first method yielding a user wins. If no method yields a user, an `HTTPException` is raised.
Each defined method will generate a [`/login/{name}`](../../usage/routes.md#post-loginname) route where `name` is defined on the authentication method object.
Each defined method will generate a [`/logout/{name}`](../../usage/routes.md#post-logoutname) route where `name` is defined on the authentication method object.
For each backend, you'll be able to add a router with the corresponding `/login` and `/logout` (if applicable routes). More on this in the [routers documentation](routers/index.md).
## Provided methods

View File

@@ -18,15 +18,16 @@ auth_backends.append(jwt_authentication)
As you can see, instantiation is quite simple. You just have to define a constant `SECRET` which is used to encode the token and the lifetime of token (in seconds).
You can also optionally define the `name` which will be used to generate its [`/login` route](../../usage/routes.md#post-loginname). **Defaults to `jwt`**.
!!! tip
You can also optionally define the `name`. It's useful in the case you wish to have several backends of the same class. Each backend should have a unique name. **Defaults to `jwt`**.
```py
jwt_authentication = JWTAuthentication(
secret=SECRET,
lifetime_seconds=3600,
name="my-jwt",
)
```
```py
jwt_authentication = JWTAuthentication(
secret=SECRET,
lifetime_seconds=3600,
name="my-jwt",
)
```
## Login
@@ -43,11 +44,7 @@ This method will return a JWT token upon successful login:
## Logout
This method is not applicable to this backend and won't do anything.
!!! success "`202 Accepted`"
> Check documentation about [logout route](../../usage/routes.md#post-logoutname).
This backend does not provide a logout method (a JWT is valid until it expires).
## Authentication
@@ -59,4 +56,4 @@ curl http://localhost:9000/protected-route -H'Authorization: Bearer eyJ0eXAiOiJK
## Next steps
We will now configure the main **FastAPI Users** object that will expose the [API router](../router.md).
We will now configure the main **FastAPI Users** object that will expose the [routers](../routers/index.md).

View File

@@ -121,12 +121,43 @@ google_oauth_client = GoogleOAuth2("CLIENT_ID", "CLIENT_SECRET")
app = FastAPI()
fastapi_users = FastAPIUsers(
user_db, auth_backends, User, UserCreate, UserUpdate, UserDB, SECRET,
user_db, auth_backends, User, UserCreate, UserUpdate, UserDB
)
google_oauth_router = fastapi_users.get_oauth_router(google_oauth_client, SECRET)
app.include_router(google_oauth_router, prefix="/google-oauth", tags=["users"])
app.include_router(google_oauth_router, prefix="/auth/google", tags=["auth"])
```
### After register
You can provide a custom function to be called after a successful registration. It is called with **two argument**: the **user** that has just registered, and the original **`Request` object**.
Typically, you'll want to **send a welcome e-mail** or add it to your marketing analytics pipeline.
You can define it as an `async` or standard method.
Example:
```py
from fastapi import FastAPI
from fastapi_users import FastAPIUsers
from httpx_oauth.clients.google import GoogleOAuth2
def on_after_register(user: UserDB, request: Request):
print(f"User {user.id} has registered.")
google_oauth_client = GoogleOAuth2("CLIENT_ID", "CLIENT_SECRET")
app = FastAPI()
fastapi_users = FastAPIUsers(
user_db, auth_backends, User, UserCreate, UserUpdate, UserDB
)
google_oauth_router = fastapi_users.get_oauth_router(google_oauth_client, SECRET, after_register=on_after_register)
app.include_router(google_oauth_router, prefix="/auth/google", tags=["auth"])
```
### Full example

View File

@@ -1,101 +0,0 @@
# Router
We're almost there! The last step is to configure the `FastAPIUsers` object that will wire the database adapter, the authentication class and the user models to expose the FastAPI router.
## Configure `FastAPIUsers`
Configure `FastAPIUsers` object with all the elements we defined before. More precisely:
* `db`: Database adapter instance.
* `auth_backends`: List of authentication backends. See [Authentication](./authentication/index.md).
* `user_model`: Pydantic model of a user.
* `user_create_model`: Pydantic model for creating a user.
* `user_update_model`: Pydantic model for updating a user.
* `user_db_model`: Pydantic model of a DB representation of a user.
* `reset_password_token_secret`: Secret to encode reset password token.
* `reset_password_token_lifetime_seconds`: Lifetime of reset password token in seconds. Default to one hour.
```py
from fastapi_users import FastAPIUsers
fastapi_users = FastAPIUsers(
user_db,
auth_backends,
User,
UserCreate,
UserUpdate,
UserDB,
SECRET,
)
```
And then, include the router in the FastAPI app:
```py
app = FastAPI()
app.include_router(fastapi_users.router, prefix="/users", tags=["users"])
```
## Event handlers
In order to be as unopinionated as possible, we expose decorators that allow you to plug your own logic after some actions. You can have several handlers per event.
### After register
This event handler is called after a successful registration. It is called with **two argument**: the **user** that has just registered, and the original **`Request` object**.
Typically, you'll want to **send a welcome e-mail** or add it to your marketing analytics pipeline.
You can define it as an `async` or standard method.
Example:
```py
@fastapi_users.on_after_register()
def on_after_register(user: User, request: Request):
print(f"User {user.id} has registered.")
```
### After forgot password
This event handler is called after a successful forgot password request. It is called with **three arguments**:
* The **user** which has requested to reset their password.
* A ready-to-use **JWT token** that will be accepted by the reset password route.
* The original **`Request` object**.
Typically, you'll want to **send an e-mail** with the link (and the token) that allows the user to reset their password.
You can define it as an `async` or standard method.
Example:
```py
@fastapi_users.on_after_forgot_password()
def on_after_forgot_password(user: User, token: str, request: Request):
print(f"User {user.id} has forgot their password. Reset token: {token}")
```
### After update
This event handler is called after a successful update user request. It is called with **three arguments**:
* The **user** which was updated.
* The dictionary containing the updated fields.
* The original **`Request` object**.
It may be useful if you wish for example update your user in a data analytics or customer success platform.
You can define it as an `async` or standard method.
Example:
```py
@fastapi_users.on_after_update()
def on_after_update(user: User, updated_user_data: Dict[str, Any], request: Request):
print(f"User {user.id} has been updated with the following data: {updated_user_data}")
```
## Next steps
Check out a [full example](full_example.md) that will show you the big picture.

View File

@@ -0,0 +1,33 @@
# Auth router
The auth router will generate `/login` and `/logout` (if applicable) routes for a given [authentication backend](../authentication/index.md).
Check the [routes usage](../../usage/routes.md) to learn how to use them.
## Setup
```py
from fastapi import FastAPI
from fastapi_users import FastAPIUsers
from fastapi_users.authentication import JWTAuthentication
SECRET = "SECRET"
jwt_authentication = JWTAuthentication(secret=SECRET, lifetime_seconds=3600))
fastapi_users = FastAPIUsers(
user_db,
[jwt_authentication],
User,
UserCreate,
UserUpdate,
UserDB,
)
app = FastAPI()
app.include_router(
fastapi_users.get_auth_router(jwt_authentication),
prefix="/auth/jwt",
tags=["auth"],
)
```

View File

@@ -0,0 +1,39 @@
# Routers
We're almost there! The last step is to configure the `FastAPIUsers` object that will wire the database adapter, the authentication classes and let us generate the actual **API routes**.
## Configure `FastAPIUsers`
Configure `FastAPIUsers` object with all the elements we defined before. More precisely:
* `db`: Database adapter instance.
* `auth_backends`: List of authentication backends. See [Authentication](../authentication/index.md).
* `user_model`: Pydantic model of a user.
* `user_create_model`: Pydantic model for creating a user.
* `user_update_model`: Pydantic model for updating a user.
* `user_db_model`: Pydantic model of a DB representation of a user.
```py
from fastapi_users import FastAPIUsers
fastapi_users = FastAPIUsers(
user_db,
auth_backends,
User,
UserCreate,
UserUpdate,
UserDB,
)
```
## Available routers
This helper class will let you generate useful routers to setup the authentication system. Each of them is **optional**, so you can pick only the one that you are interested in! Here are the routers provided:
* [Auth router](./auth.md): Provides `/login` and `/logout` routes for a given [authentication backend](../authentication/index.md).
* [Register router](./register.md): Provides `/register` routes to allow a user to create a new account.
* [Reset password router](./reset.md): Provides `/forgot-password` and `/reset-password` routes to allow a user to reset its password.
* [Users router](./users.md): Provides routes to manage users.
* [OAuth router](../oauth.md): Provides routes to perform an OAuth authentication against a service provider (like Google or Facebook).
You should check out each of them to understand how to use them.

View File

@@ -0,0 +1,54 @@
# Register routes
The register router will generate a `/register` route to allow a user to create a new account.
Check the [routes usage](../../usage/routes.md) to learn how to use them.
## Setup
```py
from fastapi import FastAPI
from fastapi_users import FastAPIUsers
from fastapi_users.authentication import JWTAuthentication
SECRET = "SECRET"
jwt_authentication = JWTAuthentication(secret=SECRET, lifetime_seconds=3600))
fastapi_users = FastAPIUsers(
user_db,
[jwt_authentication],
User,
UserCreate,
UserUpdate,
UserDB,
)
app = FastAPI()
app.include_router(
fastapi_users.get_register_router(),
prefix="/auth",
tags=["auth"],
)
```
## After register
You can provide a custom function to be called after a successful registration. It is called with **two argument**: the **user** that has just registered, and the original **`Request` object**.
Typically, you'll want to **send a welcome e-mail** or add it to your marketing analytics pipeline.
You can define it as an `async` or standard method.
Example:
```py
def on_after_register(user: UserDB, request: Request):
print(f"User {user.id} has registered.")
app.include_router(
fastapi_users.get_register_router(on_after_register),
prefix="/auth",
tags=["auth"],
)
```

View File

@@ -0,0 +1,59 @@
# Reset password router
The reset password router will generate `/forgot-password` (the user asks for a token to reset its password) and `/reset-password` (the user changes its password given the token) routes.
Check the [routes usage](../../usage/routes.md) to learn how to use them.
## Setup
```py
from fastapi import FastAPI
from fastapi_users import FastAPIUsers
fastapi_users = FastAPIUsers(
user_db,
auth_backends,
User,
UserCreate,
UserUpdate,
UserDB,
)
app = FastAPI()
app.include_router(
fastapi_users.get_reset_password_router("SECRET"),
prefix="/auth",
tags=["auth"],
)
```
Parameters:
* `reset_password_token_secret`: Secret to encode reset password token.
* `reset_password_token_lifetime_seconds`: Lifetime of reset password token. **Defaults to 3600**.
* `after_forgot_password`: Optional function called after a successful forgot password request. See below.
## After forgot password
You can provide a custom function to be called after a successful forgot password request. It is called with **three arguments**:
* The **user** which has requested to reset their password.
* A ready-to-use **JWT token** that will be accepted by the reset password route.
* The original **`Request` object**.
Typically, you'll want to **send an e-mail** with the link (and the token) that allows the user to reset their password.
You can define it as an `async` or standard method.
Example:
```py
def on_after_forgot_password(user: UserDB, token: str, request: Request):
print(f"User {user.id} has forgot their password. Reset token: {token}")
app.include_router(
fastapi_users.get_reset_password_router("SECRET", after_forgot_password=on_after_forgot_password),
prefix="/auth",
tags=["auth"],
)
```

View File

@@ -0,0 +1,51 @@
# Users router
This router provides routes to manage users. Check the [routes usage](../../usage/routes.md) to learn how to use them.
## Setup
```py
from fastapi import FastAPI
from fastapi_users import FastAPIUsers
fastapi_users = FastAPIUsers(
user_db,
auth_backends,
User,
UserCreate,
UserUpdate,
UserDB,
)
app = FastAPI()
app.include_router(
fastapi_users.get_users_router(),
prefix="/users",
tags=["users"],
)
```
## After update
You can provide a custom function to be called after a successful update user request. It is called with **three arguments**:
* The **user** which was updated.
* The dictionary containing the updated fields.
* The original **`Request` object**.
It may be useful if you wish for example update your user in a data analytics or customer success platform.
You can define it as an `async` or standard method.
Example:
```py
def on_after_update(user: UserDB, updated_user_data: Dict[str, Any], request: Request):
print(f"User {user.id} has been updated with the following data: {updated_user_data}")
app.include_router(
fastapi_users.get_users_router(on_after_update),
prefix="/users",
tags=["users"],
)
```

View File

@@ -80,3 +80,71 @@ db.getCollection('users').find().forEach(function(user) {
db.getCollection('users').update({_id: user._id}, [{$set: {id: uuid}}]);
});
```
## Splitted routers
You now have the responsibility to **wire the routers**. FastAPI Users doesn't give a bloated users router anymore.
**Event handlers** are also removed. You have to provide your "after-" logic as a parameter of the router generator.
### Before
```py
jwt_authentication = JWTAuthentication(secret=SECRET, lifetime_seconds=3600)
app = FastAPI()
fastapi_users = FastAPIUsers(
user_db, [jwt_authentication], User, UserCreate, UserUpdate, UserDB,
)
app.include_router(fastapi_users.router, prefix="/users", tags=["users"])
@fastapi_users.on_after_register()
def on_after_register(user: User, request: Request):
print(f"User {user.id} has registered.")
@fastapi_users.on_after_forgot_password()
def on_after_forgot_password(user: User, token: str, request: Request):
print(f"User {user.id} has forgot their password. Reset token: {token}")
```
### After
```py
def on_after_register(user: UserDB, request: Request):
print(f"User {user.id} has registered.")
def on_after_forgot_password(user: UserDB, token: str, request: Request):
print(f"User {user.id} has forgot their password. Reset token: {token}")
jwt_authentication = JWTAuthentication(secret=SECRET, lifetime_seconds=3600)
app = FastAPI()
fastapi_users = FastAPIUsers(
user_db, [jwt_authentication], User, UserCreate, UserUpdate, UserDB,
)
app.include_router(
fastapi_users.get_auth_router(jwt_authentication), prefix="/auth/jwt", tags=["auth"]
)
app.include_router(
fastapi_users.get_register_router(on_after_register), prefix="/auth", tags=["auth"]
)
app.include_router(
fastapi_users.get_reset_password_router(
SECRET, after_forgot_password=on_after_forgot_password
),
prefix="/auth",
tags=["auth"],
)
app.include_router(fastapi_users.get_users_router(), prefix="/users", tags=["users"])
```
Important things to notice:
* `FastAPIUsers` takes two arguments less (`reset_password_token_secret` and `reset_password_token_lifetime_seconds`).
* You have more flexibility to choose the **prefix** and **tags** of the routers.
* The `/login`/`/logout` are now your responsibility to include for each backend. The path will change (before `/login/jwt`, after `/jwt/login`).
* If you don't care about some of those routers, you can discard them.

View File

@@ -1,9 +1,8 @@
import motor.motor_asyncio
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi_users import FastAPIUsers, models
from fastapi_users.authentication import JWTAuthentication
from fastapi_users.db import MongoDBUserDatabase
from starlette.requests import Request
DATABASE_URL = "mongodb://localhost:27017"
SECRET = "SECRET"
@@ -32,22 +31,32 @@ db = client["database_name"]
collection = db["users"]
user_db = MongoDBUserDatabase(UserDB, collection)
auth_backends = [
JWTAuthentication(secret=SECRET, lifetime_seconds=3600),
]
app = FastAPI()
fastapi_users = FastAPIUsers(
user_db, auth_backends, User, UserCreate, UserUpdate, UserDB, SECRET,
)
app.include_router(fastapi_users.router, prefix="/users", tags=["users"])
@fastapi_users.on_after_register()
def on_after_register(user: User, request: Request):
def on_after_register(user: UserDB, request: Request):
print(f"User {user.id} has registered.")
@fastapi_users.on_after_forgot_password()
def on_after_forgot_password(user: User, token: str, request: Request):
def on_after_forgot_password(user: UserDB, token: str, request: Request):
print(f"User {user.id} has forgot their password. Reset token: {token}")
jwt_authentication = JWTAuthentication(secret=SECRET, lifetime_seconds=3600)
app = FastAPI()
fastapi_users = FastAPIUsers(
user_db, [jwt_authentication], User, UserCreate, UserUpdate, UserDB,
)
app.include_router(
fastapi_users.get_auth_router(jwt_authentication), prefix="/auth/jwt", tags=["auth"]
)
app.include_router(
fastapi_users.get_register_router(on_after_register), prefix="/auth", tags=["auth"]
)
app.include_router(
fastapi_users.get_reset_password_router(
SECRET, after_forgot_password=on_after_forgot_password
),
prefix="/auth",
tags=["auth"],
)
app.include_router(fastapi_users.get_users_router(), prefix="/users", tags=["users"])

View File

@@ -1,11 +1,10 @@
import databases
import sqlalchemy
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi_users import FastAPIUsers, models
from fastapi_users.authentication import JWTAuthentication
from fastapi_users.db import SQLAlchemyBaseUserTable, SQLAlchemyUserDatabase
from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base
from starlette.requests import Request
DATABASE_URL = "sqlite:///./test.db"
SECRET = "SECRET"
@@ -44,27 +43,36 @@ users = UserTable.__table__
user_db = SQLAlchemyUserDatabase(UserDB, database, users)
auth_backends = [
JWTAuthentication(secret=SECRET, lifetime_seconds=3600),
]
app = FastAPI()
fastapi_users = FastAPIUsers(
user_db, auth_backends, User, UserCreate, UserUpdate, UserDB, SECRET,
)
app.include_router(fastapi_users.router, prefix="/users", tags=["users"])
@fastapi_users.on_after_register()
def on_after_register(user: User, request: Request):
def on_after_register(user: UserDB, request: Request):
print(f"User {user.id} has registered.")
@fastapi_users.on_after_forgot_password()
def on_after_forgot_password(user: User, token: str, request: Request):
def on_after_forgot_password(user: UserDB, token: str, request: Request):
print(f"User {user.id} has forgot their password. Reset token: {token}")
jwt_authentication = JWTAuthentication(secret=SECRET, lifetime_seconds=3600)
app = FastAPI()
fastapi_users = FastAPIUsers(
user_db, [jwt_authentication], User, UserCreate, UserUpdate, UserDB,
)
app.include_router(
fastapi_users.get_auth_router(jwt_authentication), prefix="/auth/jwt", tags=["auth"]
)
app.include_router(
fastapi_users.get_register_router(on_after_register), prefix="/auth", tags=["auth"]
)
app.include_router(
fastapi_users.get_reset_password_router(
SECRET, after_forgot_password=on_after_forgot_password
),
prefix="/auth",
tags=["auth"],
)
app.include_router(fastapi_users.get_users_router(), prefix="/users", tags=["users"])
@app.on_event("startup")
async def startup():
await database.connect()

View File

@@ -1,8 +1,7 @@
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi_users import FastAPIUsers, models
from fastapi_users.authentication import JWTAuthentication
from fastapi_users.db import TortoiseBaseUserModel, TortoiseUserDatabase
from starlette.requests import Request
from tortoise.contrib.starlette import register_tortoise
DATABASE_URL = "sqlite://./test.db"
@@ -33,21 +32,31 @@ user_db = TortoiseUserDatabase(UserDB, UserModel)
app = FastAPI()
register_tortoise(app, db_url=DATABASE_URL, modules={"models": ["test"]})
auth_backends = [
JWTAuthentication(secret=SECRET, lifetime_seconds=3600),
]
fastapi_users = FastAPIUsers(
user_db, auth_backends, User, UserCreate, UserUpdate, UserDB, SECRET,
)
app.include_router(fastapi_users.router, prefix="/users", tags=["users"])
@fastapi_users.on_after_register()
def on_after_register(user: User, request: Request):
def on_after_register(user: UserDB, request: Request):
print(f"User {user.id} has registered.")
@fastapi_users.on_after_forgot_password()
def on_after_forgot_password(user: User, token: str, request: Request):
def on_after_forgot_password(user: UserDB, token: str, request: Request):
print(f"User {user.id} has forgot their password. Reset token: {token}")
jwt_authentication = JWTAuthentication(secret=SECRET, lifetime_seconds=3600)
fastapi_users = FastAPIUsers(
user_db, [jwt_authentication], User, UserCreate, UserUpdate, UserDB,
)
app.include_router(
fastapi_users.get_auth_router(jwt_authentication), prefix="/auth/jwt", tags=["auth"]
)
app.include_router(
fastapi_users.get_register_router(on_after_register), prefix="/auth", tags=["auth"]
)
app.include_router(
fastapi_users.get_reset_password_router(
SECRET, after_forgot_password=on_after_forgot_password
),
prefix="/auth",
tags=["auth"],
)
app.include_router(fastapi_users.get_users_router(), prefix="/users", tags=["users"])

View File

@@ -1,10 +1,9 @@
import motor.motor_asyncio
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi_users import FastAPIUsers, models
from fastapi_users.authentication import JWTAuthentication
from fastapi_users.db import MongoDBUserDatabase
from httpx_oauth.clients.google import GoogleOAuth2
from starlette.requests import Request
DATABASE_URL = "mongodb://localhost:27017"
SECRET = "SECRET"
@@ -36,25 +35,37 @@ db = client["database_name"]
collection = db["users"]
user_db = MongoDBUserDatabase(UserDB, collection)
auth_backends = [
JWTAuthentication(secret=SECRET, lifetime_seconds=3600),
]
app = FastAPI()
fastapi_users = FastAPIUsers(
user_db, auth_backends, User, UserCreate, UserUpdate, UserDB, SECRET,
)
app.include_router(fastapi_users.router, prefix="/users", tags=["users"])
google_oauth_router = fastapi_users.get_oauth_router(google_oauth_client, SECRET)
app.include_router(google_oauth_router, prefix="/google-oauth", tags=["users"])
@fastapi_users.on_after_register()
def on_after_register(user: User, request: Request):
def on_after_register(user: UserDB, request: Request):
print(f"User {user.id} has registered.")
@fastapi_users.on_after_forgot_password()
def on_after_forgot_password(user: User, token: str, request: Request):
def on_after_forgot_password(user: UserDB, token: str, request: Request):
print(f"User {user.id} has forgot their password. Reset token: {token}")
jwt_authentication = JWTAuthentication(secret=SECRET, lifetime_seconds=3600)
app = FastAPI()
fastapi_users = FastAPIUsers(
user_db, [jwt_authentication], User, UserCreate, UserUpdate, UserDB,
)
app.include_router(
fastapi_users.get_auth_router(jwt_authentication), prefix="/auth/jwt", tags=["auth"]
)
app.include_router(
fastapi_users.get_register_router(on_after_register), prefix="/auth", tags=["auth"]
)
app.include_router(
fastapi_users.get_reset_password_router(
SECRET, after_forgot_password=on_after_forgot_password
),
prefix="/auth",
tags=["auth"],
)
app.include_router(fastapi_users.get_users_router(), prefix="/users", tags=["users"])
google_oauth_router = fastapi_users.get_oauth_router(
google_oauth_client, SECRET, after_register=on_after_register
)
app.include_router(google_oauth_router, prefix="/auth/google", tags=["auth"])

View File

@@ -1,6 +1,6 @@
import databases
import sqlalchemy
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi_users import FastAPIUsers, models
from fastapi_users.authentication import JWTAuthentication
from fastapi_users.db import (
@@ -10,7 +10,6 @@ from fastapi_users.db import (
)
from httpx_oauth.clients.google import GoogleOAuth2
from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base
from starlette.requests import Request
DATABASE_URL = "sqlite:///./test.db"
SECRET = "SECRET"
@@ -57,30 +56,41 @@ oauth_accounts = OAuthAccount.__table__
user_db = SQLAlchemyUserDatabase(UserDB, database, users, oauth_accounts)
auth_backends = [
JWTAuthentication(secret=SECRET, lifetime_seconds=3600),
]
app = FastAPI()
fastapi_users = FastAPIUsers(
user_db, auth_backends, User, UserCreate, UserUpdate, UserDB, SECRET,
)
app.include_router(fastapi_users.router, prefix="/users", tags=["users"])
google_oauth_router = fastapi_users.get_oauth_router(google_oauth_client, SECRET)
app.include_router(google_oauth_router, prefix="/google-oauth", tags=["users"])
@fastapi_users.on_after_register()
def on_after_register(user: User, request: Request):
def on_after_register(user: UserDB, request: Request):
print(f"User {user.id} has registered.")
@fastapi_users.on_after_forgot_password()
def on_after_forgot_password(user: User, token: str, request: Request):
def on_after_forgot_password(user: UserDB, token: str, request: Request):
print(f"User {user.id} has forgot their password. Reset token: {token}")
jwt_authentication = JWTAuthentication(secret=SECRET, lifetime_seconds=3600)
app = FastAPI()
fastapi_users = FastAPIUsers(
user_db, [jwt_authentication], User, UserCreate, UserUpdate, UserDB,
)
app.include_router(
fastapi_users.get_auth_router(jwt_authentication), prefix="/auth/jwt", tags=["auth"]
)
app.include_router(
fastapi_users.get_register_router(on_after_register), prefix="/auth", tags=["auth"]
)
app.include_router(
fastapi_users.get_reset_password_router(
SECRET, after_forgot_password=on_after_forgot_password
),
prefix="/auth",
tags=["auth"],
)
app.include_router(fastapi_users.get_users_router(), prefix="/users", tags=["users"])
google_oauth_router = fastapi_users.get_oauth_router(
google_oauth_client, SECRET, after_register=on_after_register
)
app.include_router(google_oauth_router, prefix="/auth/google", tags=["auth"])
@app.on_event("startup")
async def startup():
await database.connect()

View File

@@ -1,4 +1,4 @@
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi_users import FastAPIUsers, models
from fastapi_users.authentication import JWTAuthentication
from fastapi_users.db import (
@@ -7,7 +7,6 @@ from fastapi_users.db import (
TortoiseUserDatabase,
)
from httpx_oauth.clients.google import GoogleOAuth2
from starlette.requests import Request
from tortoise import fields
from tortoise.contrib.starlette import register_tortoise
@@ -46,24 +45,36 @@ user_db = TortoiseUserDatabase(UserDB, UserModel, OAuthAccountModel)
app = FastAPI()
register_tortoise(app, db_url=DATABASE_URL, modules={"models": ["test"]})
auth_backends = [
JWTAuthentication(secret=SECRET, lifetime_seconds=3600),
]
fastapi_users = FastAPIUsers(
user_db, auth_backends, User, UserCreate, UserUpdate, UserDB, SECRET,
)
app.include_router(fastapi_users.router, prefix="/users", tags=["users"])
google_oauth_router = fastapi_users.get_oauth_router(google_oauth_client, SECRET)
app.include_router(google_oauth_router, prefix="/google-oauth", tags=["users"])
@fastapi_users.on_after_register()
def on_after_register(user: User, request: Request):
def on_after_register(user: UserDB, request: Request):
print(f"User {user.id} has registered.")
@fastapi_users.on_after_forgot_password()
def on_after_forgot_password(user: User, token: str, request: Request):
def on_after_forgot_password(user: UserDB, token: str, request: Request):
print(f"User {user.id} has forgot their password. Reset token: {token}")
jwt_authentication = JWTAuthentication(secret=SECRET, lifetime_seconds=3600)
fastapi_users = FastAPIUsers(
user_db, [jwt_authentication], User, UserCreate, UserUpdate, UserDB,
)
app.include_router(
fastapi_users.get_auth_router(jwt_authentication), prefix="/auth/jwt", tags=["auth"]
)
app.include_router(
fastapi_users.get_register_router(on_after_register), prefix="/auth", tags=["auth"]
)
app.include_router(
fastapi_users.get_reset_password_router(
SECRET, after_forgot_password=on_after_forgot_password
),
prefix="/auth",
tags=["auth"],
)
app.include_router(fastapi_users.get_users_router(), prefix="/users", tags=["users"])
google_oauth_router = fastapi_users.get_oauth_router(
google_oauth_client, SECRET, after_register=on_after_register
)
app.include_router(google_oauth_router, prefix="/auth/google", tags=["auth"])

View File

@@ -45,4 +45,4 @@ def protected_route():
return 'Hello, some user.'
```
You can see more about it [in FastAPI docs](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/).
You can read more about this [in FastAPI docs](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/).

View File

@@ -14,12 +14,12 @@ First step, of course, is to register as a user.
-H "Content-Type: application/json" \
-X POST \
-d "{\"email\": \"king.arthur@camelot.bt\",\"password\": \"guinevere\"}" \
http://localhost:8000/users/register
http://localhost:8000/auth/register
```
=== "axios"
```ts
axios.post('http://localhost:8000/users/register', {
axios.post('http://localhost:8000/auth/register', {
email: 'king.arthur@camelot.bt',
password: 'guinevere',
})
@@ -51,7 +51,7 @@ You'll get a JSON response looking like this:
Now, you can login as this new user.
Each [authentication backend](../configuration/authentication/index.md) will produce a single route. For example, the [JWT backend](../configuration/authentication/jwt.md) will produce the `/users/login/jwt` route. Each backend will have a different response.
You can generate a [login route](../configuration/routers/auth.md) for each [authentication backend](../configuration/authentication/index.md). Each backend will have a different response.
### JWT backend
@@ -64,7 +64,7 @@ Each [authentication backend](../configuration/authentication/index.md) will pro
-X POST \
-F "username=king.arthur@camelot.bt" \
-F "password=guinevere" \
http://localhost:8000/users/login/jwt
http://localhost:8000/auth/jwt/login
```
=== "axios"
@@ -73,7 +73,7 @@ Each [authentication backend](../configuration/authentication/index.md) will pro
formData.set('username', 'king.arthur@camelot.bt');
formData.set('password', 'guinevere');
axios.post(
'http://localhost:8000/users/login/jwt',
'http://localhost:8000/auth/jwt/login',
formData,
{
headers: {
@@ -112,7 +112,7 @@ You can use this token to make authenticated requests as the user `king.arthur@c
-X POST \
-F "username=king.arthur@camelot.bt" \
-F "password=guinevere" \
http://localhost:8000/users/login/cookie
http://localhost:8000/auth/cookie/login
```
=== "axios"
@@ -121,7 +121,7 @@ You can use this token to make authenticated requests as the user `king.arthur@c
formData.set('username', 'king.arthur@camelot.bt');
formData.set('password', 'guinevere');
axios.post(
'http://localhost:8000/users/login/cookie',
'http://localhost:8000/auth/cookie/login',
formData,
{
headers: {
@@ -380,12 +380,12 @@ We can also end the session. Note that it doesn't apply to every [authentication
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-X POST \
http://localhost:8000/users/logout/cookie
http://localhost:8000/auth/cookie/logout
```
=== "axios"
```ts
axios.post('http://localhost:8000/users/logout/cookie',
axios.post('http://localhost:8000/auth/cookie/logout',
null,
{
headers: {

View File

@@ -2,11 +2,49 @@
You'll find here the routes exposed by **FastAPI Users**. Note that you can also review them through the [interactive API docs](https://fastapi.tiangolo.com/tutorial/first-steps/#interactive-api-docs).
## Unauthenticated
## Auth router
Each [authentication backend](../configuration/authentication/index.md) you [generate a router for](../configuration/routers/auth.md) will produce the following routes. Take care about the prefix you gave it, especially if you have several backends.
### `POST /login`
Login a user against the method named `name`. Check the corresponding [authentication method](../configuration/authentication/index.md) to view the success response.
!!! abstract "Payload (`application/x-www-form-urlencoded`)"
```
username=king.arthur@camelot.bt&password=guinevere
```
!!! fail "`422 Validation Error`"
!!! fail "`400 Bad Request`"
Bad credentials or the user is inactive.
```json
{
"detail": "LOGIN_BAD_CREDENTIALS"
}
```
### `POST /logout`
Logout the authenticated user against the method named `name`. Check the corresponding [authentication method](../configuration/authentication/index.md) to view the success response.
!!! fail "`401 Unauthorized`"
Missing token or inactive user.
!!! success "`200 OK`"
The logout process was successful.
!!! tip
Some backend (like JWT) won't produce this route.
## Register router
### `POST /register`
Register a new user. Will call the `on_after_register` [event handlers](../configuration/router.md#event-handlers) on successful registration.
Register a new user. Will call the `after_register` [handler](../configuration/routers/register.md#after-register) on successful registration.
!!! abstract "Payload"
```json
@@ -37,42 +75,11 @@ Register a new user. Will call the `on_after_register` [event handlers](../confi
}
```
### `POST /login/{name}`
Login a user against the method named `name`. Check the corresponding [authentication method](../configuration/authentication/index.md) to view the success response.
!!! abstract "Payload (`application/x-www-form-urlencoded`)"
```
username=king.arthur@camelot.bt&password=guinevere
```
!!! fail "`422 Validation Error`"
!!! fail "`400 Bad Request`"
Bad credentials or the user is inactive.
```json
{
"detail": "LOGIN_BAD_CREDENTIALS"
}
```
### `POST /logout/{name}`
Logout the authenticated user against the method named `name`. Check the corresponding [authentication method](../configuration/authentication/index.md) to view the success response.
!!! fail "`401 Unauthorized`"
Missing token or inactive user.
!!! success "`200 OK`"
The logout process was successful.
!!! success "`202 Accepted`"
The logout process is not applicable for this authentication backend (e.g. JWT).
## Reset password router
### `POST /forgot-password`
Request a reset password procedure. Will generate a temporary token and call the `on_after_forgot_password` [event handlers](../configuration/router.md#event-handlers) if the user exists.
Request a reset password procedure. Will generate a temporary token and call the `after_forgot_password` [handlers](../configuration/routers/reset.md#after-forgot-password) if the user exists.
To prevent malicious users from guessing existing users in your databse, the route will always return a `202 Accepted` response, even if the user requested does not exist.
@@ -110,11 +117,11 @@ Reset a password. Requires the token generated by the `/forgot-password` route.
}
```
### OAuth routes
## OAuth router
Each OAuth router you define will expose the two following routes.
#### `GET /authorize`
### `GET /authorize`
Return the authorization URL for the OAuth service where you should redirect your user.
@@ -134,7 +141,7 @@ Return the authorization URL for the OAuth service where you should redirect you
!!! fail "`400 Bad Request`"
Unknown authentication backend.
#### `GET /callback`
### `GET /callback`
Handle the OAuth callback.
@@ -155,7 +162,7 @@ Depending on the situation, several things can happen:
* A new user is created and linked to the OAuth account.
* The user is authenticated following the chosen [authentication method](../configuration/authentication/index.md).
## Authenticated
## Users router
### `GET /me`
@@ -199,8 +206,6 @@ Update the current authenticated active user.
!!! fail "`401 Unauthorized`"
Missing token or inactive user.
## Superuser
### `GET /{user_id}`
Return the user with id `user_id`.

View File

@@ -1,8 +1,9 @@
import re
from inspect import Parameter, Signature
from typing import Sequence
from fastapi import HTTPException
from starlette import status
from starlette.requests import Request
from fastapi import Depends, HTTPException, status
from makefun import with_signature
from fastapi_users.authentication.base import BaseAuthentication # noqa: F401
from fastapi_users.authentication.cookie import CookieAuthentication # noqa: F401
@@ -10,6 +11,20 @@ from fastapi_users.authentication.jwt import JWTAuthentication # noqa: F401
from fastapi_users.db import BaseUserDatabase
from fastapi_users.models import BaseUserDB
INVALID_CHARS_PATTERN = re.compile(r"[^0-9a-zA-Z_]")
INVALID_LEADING_CHARS_PATTERN = re.compile(r"^[^a-zA-Z_]+")
def name_to_variable_name(name: str) -> str:
"""Transform a backend name string into a string safe to use as variable name."""
name = re.sub(INVALID_CHARS_PATTERN, "", name)
name = re.sub(INVALID_LEADING_CHARS_PATTERN, "", name)
return name
class DuplicateBackendNamesError(Exception):
pass
class Authenticator:
"""
@@ -32,26 +47,52 @@ class Authenticator:
self.backends = backends
self.user_db = user_db
async def get_current_user(self, request: Request) -> BaseUserDB:
return await self._authenticate(request)
# Here comes some blood magic 🧙‍♂️
# Thank to "makefun", we are able to generate callable
# with a dynamic number of dependencies at runtime.
# This way, each security schemes are detected by the OpenAPI generator.
try:
parameters = [
Parameter(
name=name_to_variable_name(backend.name),
kind=Parameter.POSITIONAL_OR_KEYWORD,
default=Depends(backend.scheme), # type: ignore
)
for backend in self.backends
]
signature = Signature(parameters)
except ValueError:
raise DuplicateBackendNamesError()
async def get_current_active_user(self, request: Request) -> BaseUserDB:
user = await self.get_current_user(request)
if not user.is_active:
raise self._get_credentials_exception()
return user
@with_signature(signature, func_name="get_current_user")
async def get_current_user(*args, **kwargs):
return await self._authenticate(*args, **kwargs)
async def get_current_superuser(self, request: Request) -> BaseUserDB:
user = await self.get_current_active_user(request)
if not user.is_superuser:
raise self._get_credentials_exception(status.HTTP_403_FORBIDDEN)
return user
@with_signature(signature, func_name="get_current_active_user")
async def get_current_active_user(*args, **kwargs):
user = await get_current_user(*args, **kwargs)
if not user.is_active:
raise self._get_credentials_exception()
return user
async def _authenticate(self, request: Request) -> BaseUserDB:
@with_signature(signature, func_name="get_current_superuser")
async def get_current_superuser(*args, **kwargs):
user = await get_current_active_user(*args, **kwargs)
if not user.is_superuser:
raise self._get_credentials_exception(status.HTTP_403_FORBIDDEN)
return user
self.get_current_user = get_current_user
self.get_current_active_user = get_current_active_user
self.get_current_superuser = get_current_superuser
async def _authenticate(self, *args, **kwargs) -> BaseUserDB:
for backend in self.backends:
user = await backend(request, self.user_db)
if user is not None:
return user
token: str = kwargs[name_to_variable_name(backend.name)]
if token:
user = await backend(token, self.user_db)
if user is not None:
return user
raise self._get_credentials_exception()
def _get_credentials_exception(

View File

@@ -1,28 +1,34 @@
from typing import Any, Optional
from typing import Any, Generic, Optional, TypeVar
from starlette.requests import Request
from starlette.responses import Response
from fastapi import Response
from fastapi.security.base import SecurityBase
from fastapi_users.db import BaseUserDatabase
from fastapi_users.models import BaseUserDB
T = TypeVar("T")
class BaseAuthentication:
class BaseAuthentication(Generic[T]):
"""
Base authentication backend.
Every backend should derive from this class.
:param name: Name of the backend. It will be used to name the login route.
:param name: Name of the backend.
:param logout: Whether or not this backend has a logout process.
"""
scheme: SecurityBase
name: str
logout: bool
def __init__(self, name: str = "base"):
def __init__(self, name: str = "base", logout: bool = False):
self.name = name
self.logout = logout
async def __call__(
self, request: Request, user_db: BaseUserDatabase
self, credentials: Optional[T], user_db: BaseUserDatabase
) -> Optional[BaseUserDB]:
raise NotImplementedError()

View File

@@ -1,14 +1,17 @@
from typing import Any, Optional
import jwt
from fastapi import Response
from fastapi.security import APIKeyCookie
from starlette.requests import Request
from starlette.responses import Response
from pydantic import UUID4
from fastapi_users.authentication.jwt import JWTAuthentication
from fastapi_users.authentication import BaseAuthentication
from fastapi_users.db.base import BaseUserDatabase
from fastapi_users.models import BaseUserDB
from fastapi_users.utils import JWT_ALGORITHM, generate_jwt
class CookieAuthentication(JWTAuthentication):
class CookieAuthentication(BaseAuthentication[str]):
"""
Authentication backend using a cookie.
@@ -24,6 +27,9 @@ class CookieAuthentication(JWTAuthentication):
:param name: Name of the backend. It will be used to name the login route.
"""
scheme: APIKeyCookie
token_audience: str = "fastapi-users:auth"
secret: str
lifetime_seconds: int
cookie_name: str
cookie_path: str
@@ -42,14 +48,40 @@ class CookieAuthentication(JWTAuthentication):
cookie_httponly: bool = True,
name: str = "cookie",
):
super().__init__(secret, lifetime_seconds, name=name)
super().__init__(name, logout=True)
self.secret = secret
self.lifetime_seconds = lifetime_seconds
self.cookie_name = cookie_name
self.cookie_path = cookie_path
self.cookie_domain = cookie_domain
self.cookie_secure = cookie_secure
self.cookie_httponly = cookie_httponly
self.api_key_cookie = APIKeyCookie(name=self.cookie_name, auto_error=False)
self.scheme = APIKeyCookie(name=self.cookie_name, auto_error=False)
async def __call__(
self, credentials: Optional[str], user_db: BaseUserDatabase,
) -> Optional[BaseUserDB]:
if credentials is None:
return None
try:
data = jwt.decode(
credentials,
self.secret,
audience=self.token_audience,
algorithms=[JWT_ALGORITHM],
)
user_id = data.get("user_id")
if user_id is None:
return None
except jwt.PyJWTError:
return None
try:
user_uiid = UUID4(user_id)
return await user_db.get(user_uiid)
except ValueError:
return None
async def get_login_response(self, user: BaseUserDB, response: Response) -> Any:
token = await self._generate_token(user)
@@ -72,5 +104,6 @@ class CookieAuthentication(JWTAuthentication):
self.cookie_name, path=self.cookie_path, domain=self.cookie_domain
)
async def _retrieve_token(self, request: Request) -> Optional[str]:
return await self.api_key_cookie.__call__(request)
async def _generate_token(self, user: BaseUserDB) -> str:
data = {"user_id": str(user.id), "aud": self.token_audience}
return generate_jwt(data, self.lifetime_seconds, self.secret, JWT_ALGORITHM)

View File

@@ -1,10 +1,9 @@
from typing import Any, Optional
import jwt
from fastapi import Response
from fastapi.security import OAuth2PasswordBearer
from pydantic import UUID4
from starlette.requests import Request
from starlette.responses import Response
from fastapi_users.authentication.base import BaseAuthentication
from fastapi_users.db.base import BaseUserDatabase
@@ -12,7 +11,7 @@ from fastapi_users.models import BaseUserDB
from fastapi_users.utils import JWT_ALGORITHM, generate_jwt
class JWTAuthentication(BaseAuthentication):
class JWTAuthentication(BaseAuthentication[str]):
"""
Authentication backend using a JWT in a Bearer header.
@@ -22,6 +21,7 @@ class JWTAuthentication(BaseAuthentication):
:param name: Name of the backend. It will be used to name the login route.
"""
scheme: OAuth2PasswordBearer
token_audience: str = "fastapi-users:auth"
secret: str
lifetime_seconds: int
@@ -33,21 +33,20 @@ class JWTAuthentication(BaseAuthentication):
tokenUrl: str = "/users/login",
name: str = "jwt",
):
super().__init__(name)
super().__init__(name, logout=False)
self.scheme = OAuth2PasswordBearer(tokenUrl, auto_error=False)
self.secret = secret
self.lifetime_seconds = lifetime_seconds
self.scheme = OAuth2PasswordBearer(tokenUrl, auto_error=False)
async def __call__(
self, request: Request, user_db: BaseUserDatabase,
self, credentials: Optional[str], user_db: BaseUserDatabase,
) -> Optional[BaseUserDB]:
token = await self._retrieve_token(request)
if token is None:
if credentials is None:
return None
try:
data = jwt.decode(
token,
credentials,
self.secret,
audience=self.token_audience,
algorithms=[JWT_ALGORITHM],
@@ -68,9 +67,6 @@ class JWTAuthentication(BaseAuthentication):
token = await self._generate_token(user)
return {"token": token}
async def _retrieve_token(self, request: Request) -> Optional[str]:
return await self.scheme.__call__(request)
async def _generate_token(self, user: BaseUserDB) -> str:
data = {"user_id": str(user.id), "aud": self.token_audience}
return generate_jwt(data, self.lifetime_seconds, self.secret, JWT_ALGORITHM)

View File

@@ -1,16 +1,17 @@
from collections import defaultdict
from typing import Callable, DefaultDict, List, Sequence, Type
from typing import Any, Callable, Dict, Optional, Sequence, Type
from fastapi import APIRouter, Request
from httpx_oauth.oauth2 import BaseOAuth2
from fastapi_users import models
from fastapi_users.authentication import Authenticator, BaseAuthentication
from fastapi_users.db import BaseUserDatabase
from fastapi_users.router import (
Event,
EventHandlersRouter,
get_auth_router,
get_oauth_router,
get_user_router,
get_register_router,
get_reset_password_router,
get_users_router,
)
@@ -24,20 +25,18 @@ class FastAPIUsers:
:param user_create_model: Pydantic model for creating a user.
:param user_update_model: Pydantic model for updating a user.
:param user_db_model: Pydantic model of a DB representation of a user.
:param reset_password_token_secret: Secret to encode reset password token.
:param reset_password_token_lifetime_seconds: Lifetime of reset password token.
:attribute router: Router exposing authentication routes.
:attribute oauth_routers: List of OAuth routers created through `get_oauth_router`.
:attribute get_current_user: Dependency callable to inject authenticated user.
:attribute get_current_active_user: Dependency callable to inject active user.
:attribute get_current_superuser: Dependency callable to inject superuser.
"""
db: BaseUserDatabase
authenticator: Authenticator
router: EventHandlersRouter
oauth_routers: List[EventHandlersRouter]
_user_model: Type[models.BaseUser]
_user_create_model: Type[models.BaseUserCreate]
_user_update_model: Type[models.BaseUserUpdate]
_user_db_model: Type[models.BaseUserDB]
_event_handlers: DefaultDict[Event, List[Callable]]
def __init__(
self,
@@ -47,44 +46,78 @@ class FastAPIUsers:
user_create_model: Type[models.BaseUserCreate],
user_update_model: Type[models.BaseUserUpdate],
user_db_model: Type[models.BaseUserDB],
reset_password_token_secret: str,
reset_password_token_lifetime_seconds: int = 3600,
):
self.db = db
self.authenticator = Authenticator(auth_backends, db)
self.router = get_user_router(
self.db,
user_model,
user_create_model,
user_update_model,
user_db_model,
self.authenticator,
reset_password_token_secret,
reset_password_token_lifetime_seconds,
self.router = get_users_router(
self.db, user_model, user_update_model, user_db_model, self.authenticator,
)
self.oauth_routers = []
self._user_model = user_model
self._user_db_model = user_db_model
self._user_create_model = user_create_model
self._user_update_model = user_update_model
self._user_db_model = user_db_model
self._event_handlers = defaultdict(list)
self.get_current_user = self.authenticator.get_current_user
self.get_current_active_user = self.authenticator.get_current_active_user
self.get_current_superuser = self.authenticator.get_current_superuser
def on_after_register(self) -> Callable:
"""Add an event handler on successful registration."""
return self._on_event(Event.ON_AFTER_REGISTER)
def get_register_router(
self, after_register: Optional[Callable[[models.UD, Request], None]] = None,
) -> APIRouter:
"""
Return a router with a register route.
def on_after_forgot_password(self) -> Callable:
"""Add an event handler on successful forgot password request."""
return self._on_event(Event.ON_AFTER_FORGOT_PASSWORD)
:param after_register: Optional function called
after a successful registration.
"""
return get_register_router(
self.db,
self._user_model,
self._user_create_model,
self._user_db_model,
after_register,
)
def on_after_update(self) -> Callable:
"""Add an event handler on successful update user request."""
return self._on_event(Event.ON_AFTER_UPDATE)
def get_reset_password_router(
self,
reset_password_token_secret: str,
reset_password_token_lifetime_seconds: int = 3600,
after_forgot_password: Optional[
Callable[[models.UD, str, Request], None]
] = None,
) -> APIRouter:
"""
Return a reset password process router.
:param reset_password_token_secret: Secret to encode reset password token.
:param reset_password_token_lifetime_seconds: Lifetime of reset password token.
:param after_forgot_password: Optional function called after a successful
forgot password request.
"""
return get_reset_password_router(
self.db,
reset_password_token_secret,
reset_password_token_lifetime_seconds,
after_forgot_password,
)
def get_auth_router(self, backend: BaseAuthentication) -> APIRouter:
"""
Return an auth router for a given authentication backend.
:param backend: The authentication backend instance.
"""
return get_auth_router(backend, self.db, self.authenticator)
def get_oauth_router(
self, oauth_client: BaseOAuth2, state_secret: str, redirect_url: str = None
) -> EventHandlersRouter:
self,
oauth_client: BaseOAuth2,
state_secret: str,
redirect_url: str = None,
after_register: Optional[Callable[[models.UD, Request], None]] = None,
) -> APIRouter:
"""
Return an OAuth router for a given OAuth client.
@@ -92,30 +125,36 @@ class FastAPIUsers:
:param state_secret: Secret used to encode the state JWT.
:param redirect_url: Optional arbitrary redirect URL for the OAuth2 flow.
If not given, the URL to the callback endpoint will be generated.
:param after_register: Optional function called
after a successful registration.
"""
oauth_router = get_oauth_router(
return get_oauth_router(
oauth_client,
self.db,
self._user_db_model,
self.authenticator,
state_secret,
redirect_url,
after_register,
)
for event_type in self._event_handlers:
for handler in self._event_handlers[event_type]:
oauth_router.add_event_handler(event_type, handler)
def get_users_router(
self,
after_update: Optional[
Callable[[models.UD, Dict[str, Any], Request], None]
] = None,
) -> APIRouter:
"""
Return a router with routes to manage users.
self.oauth_routers.append(oauth_router)
return oauth_router
def _on_event(self, event_type: Event) -> Callable:
def decorator(func: Callable) -> Callable:
self._event_handlers[event_type].append(func)
self.router.add_event_handler(event_type, func)
for oauth_router in self.oauth_routers:
oauth_router.add_event_handler(event_type, func)
return func
return decorator
:param after_update: Optional function called
after a successful user update.
"""
return get_users_router(
self.db,
self._user_model,
self._user_update_model,
self._user_db_model,
self.authenticator,
after_update,
)

View File

@@ -1,7 +1,6 @@
from fastapi_users.router.common import ( # noqa: F401
ErrorCode,
Event,
EventHandlersRouter,
)
from fastapi_users.router.auth import get_auth_router # noqa: F401
from fastapi_users.router.common import ErrorCode # noqa: F401
from fastapi_users.router.oauth import get_oauth_router # noqa: F401
from fastapi_users.router.users import get_user_router # noqa: F401
from fastapi_users.router.register import get_register_router # noqa: F401
from fastapi_users.router.reset import get_reset_password_router # noqa: F401
from fastapi_users.router.users import get_users_router # noqa: F401

View File

@@ -0,0 +1,40 @@
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi.security import OAuth2PasswordRequestForm
from fastapi_users import models
from fastapi_users.authentication import Authenticator, BaseAuthentication
from fastapi_users.db import BaseUserDatabase
from fastapi_users.router.common import ErrorCode
def get_auth_router(
backend: BaseAuthentication,
user_db: BaseUserDatabase[models.BaseUserDB],
authenticator: Authenticator,
) -> APIRouter:
"""Generate a router with login/logout routes for an authentication backend."""
router = APIRouter()
@router.post("/login")
async def login(
response: Response, credentials: OAuth2PasswordRequestForm = Depends()
):
user = await user_db.authenticate(credentials)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.LOGIN_BAD_CREDENTIALS,
)
return await backend.get_login_response(user, response)
if backend.logout:
@router.post("/logout")
async def logout(
response: Response, user=Depends(authenticator.get_current_active_user)
):
return await backend.get_logout_response(user, response)
return router

View File

@@ -1,9 +1,5 @@
import asyncio
from collections import defaultdict
from enum import Enum, auto
from typing import Callable, DefaultDict, List
from fastapi import APIRouter
from typing import Callable
class ErrorCode:
@@ -12,25 +8,8 @@ class ErrorCode:
RESET_PASSWORD_BAD_TOKEN = "RESET_PASSWORD_BAD_TOKEN"
class Event(Enum):
ON_AFTER_REGISTER = auto()
ON_AFTER_FORGOT_PASSWORD = auto()
ON_AFTER_UPDATE = auto()
class EventHandlersRouter(APIRouter):
event_handlers: DefaultDict[Event, List[Callable]]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.event_handlers = defaultdict(list)
def add_event_handler(self, event_type: Event, func: Callable) -> None:
self.event_handlers[event_type].append(func)
async def run_handlers(self, event_type: Event, *args, **kwargs) -> None:
for handler in self.event_handlers[event_type]:
if asyncio.iscoroutinefunction(handler):
await handler(*args, **kwargs)
else:
handler(*args, **kwargs)
async def run_handler(handler: Callable, *args, **kwargs):
if asyncio.iscoroutinefunction(handler):
await handler(*args, **kwargs)
else:
handler(*args, **kwargs)

View File

@@ -1,18 +1,15 @@
from typing import Dict, List, Type, cast
from typing import Callable, Dict, List, Optional, Type, cast
import jwt
from fastapi import Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback
from httpx_oauth.oauth2 import BaseOAuth2
from starlette import status
from starlette.requests import Request
from starlette.responses import Response
from fastapi_users import models
from fastapi_users.authentication import Authenticator
from fastapi_users.db import BaseUserDatabase
from fastapi_users.password import generate_password, get_password_hash
from fastapi_users.router.common import ErrorCode, Event, EventHandlersRouter
from fastapi_users.router.common import ErrorCode, run_handler
from fastapi_users.utils import JWT_ALGORITHM, generate_jwt
STATE_TOKEN_AUDIENCE = "fastapi-users:oauth-state"
@@ -38,9 +35,10 @@ def get_oauth_router(
authenticator: Authenticator,
state_secret: str,
redirect_url: str = None,
) -> EventHandlersRouter:
after_register: Optional[Callable[[models.UD, Request], None]] = None,
) -> APIRouter:
"""Generate a router with the OAuth routes."""
router = EventHandlersRouter()
router = APIRouter()
callback_route_name = f"{oauth_client.name}-callback"
if redirect_url is not None:
@@ -122,7 +120,8 @@ def get_oauth_router(
oauth_accounts=[new_oauth_account],
)
await user_db.create(user)
await router.run_handlers(Event.ON_AFTER_REGISTER, user, request)
if after_register:
await run_handler(after_register, user, request)
else:
# Update oauth
updated_oauth_accounts = []

View File

@@ -0,0 +1,45 @@
from typing import Callable, Optional, Type, cast
from fastapi import APIRouter, HTTPException, Request, status
from fastapi_users import models
from fastapi_users.db import BaseUserDatabase
from fastapi_users.password import get_password_hash
from fastapi_users.router.common import ErrorCode, run_handler
def get_register_router(
user_db: BaseUserDatabase[models.BaseUserDB],
user_model: Type[models.BaseUser],
user_create_model: Type[models.BaseUserCreate],
user_db_model: Type[models.BaseUserDB],
after_register: Optional[Callable[[models.UD, Request], None]] = None,
) -> APIRouter:
"""Generate a router with the register route."""
router = APIRouter()
@router.post(
"/register", response_model=user_model, status_code=status.HTTP_201_CREATED
)
async def register(request: Request, user: user_create_model): # type: ignore
user = cast(models.BaseUserCreate, user) # Prevent mypy complain
existing_user = await user_db.get_by_email(user.email)
if existing_user is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.REGISTER_USER_ALREADY_EXISTS,
)
hashed_password = get_password_hash(user.password)
db_user = user_db_model(
**user.create_update_dict(), hashed_password=hashed_password
)
created_user = await user_db.create(db_user)
if after_register:
await run_handler(after_register, created_user, request)
return created_user
return router

View File

@@ -0,0 +1,82 @@
from typing import Callable, Optional
import jwt
from fastapi import APIRouter, Body, HTTPException, Request, status
from pydantic import UUID4, EmailStr
from fastapi_users import models
from fastapi_users.db import BaseUserDatabase
from fastapi_users.password import get_password_hash
from fastapi_users.router.common import ErrorCode, run_handler
from fastapi_users.utils import JWT_ALGORITHM, generate_jwt
RESET_PASSWORD_TOKEN_AUDIENCE = "fastapi-users:reset"
def get_reset_password_router(
user_db: BaseUserDatabase[models.BaseUserDB],
reset_password_token_secret: str,
reset_password_token_lifetime_seconds: int = 3600,
after_forgot_password: Optional[Callable[[models.UD, str, Request], None]] = None,
) -> APIRouter:
"""Generate a router with the reset password routes."""
router = APIRouter()
@router.post("/forgot-password", status_code=status.HTTP_202_ACCEPTED)
async def forgot_password(
request: Request, email: EmailStr = Body(..., embed=True)
):
user = await user_db.get_by_email(email)
if user is not None and user.is_active:
token_data = {"user_id": str(user.id), "aud": RESET_PASSWORD_TOKEN_AUDIENCE}
token = generate_jwt(
token_data,
reset_password_token_lifetime_seconds,
reset_password_token_secret,
)
if after_forgot_password:
await run_handler(after_forgot_password, user, token, request)
return None
@router.post("/reset-password")
async def reset_password(token: str = Body(...), password: str = Body(...)):
try:
data = jwt.decode(
token,
reset_password_token_secret,
audience=RESET_PASSWORD_TOKEN_AUDIENCE,
algorithms=[JWT_ALGORITHM],
)
user_id = data.get("user_id")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
)
try:
user_uiid = UUID4(user_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
)
user = await user_db.get(user_uiid)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
)
user.hashed_password = get_password_hash(password)
await user_db.update(user)
except jwt.PyJWTError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
)
return router

View File

@@ -1,70 +1,25 @@
from typing import Any, Dict, Type, cast
from typing import Any, Callable, Dict, Optional, Type, cast
import jwt
from fastapi import Body, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import UUID4, EmailStr
from starlette import status
from starlette.requests import Request
from starlette.responses import Response
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import UUID4
from fastapi_users import models
from fastapi_users.authentication import Authenticator, BaseAuthentication
from fastapi_users.authentication import Authenticator
from fastapi_users.db import BaseUserDatabase
from fastapi_users.password import get_password_hash
from fastapi_users.router.common import ErrorCode, Event, EventHandlersRouter
from fastapi_users.utils import JWT_ALGORITHM, generate_jwt
from fastapi_users.router.common import run_handler
def _add_login_route(
router: EventHandlersRouter,
user_db: BaseUserDatabase,
auth_backend: BaseAuthentication,
):
@router.post(f"/login/{auth_backend.name}")
async def login(
response: Response, credentials: OAuth2PasswordRequestForm = Depends()
):
user = await user_db.authenticate(credentials)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.LOGIN_BAD_CREDENTIALS,
)
return await auth_backend.get_login_response(user, response)
def _add_logout_route(
router: EventHandlersRouter,
authenticator: Authenticator,
auth_backend: BaseAuthentication,
):
@router.post(f"/logout/{auth_backend.name}")
async def logout(
response: Response, user=Depends(authenticator.get_current_active_user)
):
try:
return await auth_backend.get_logout_response(user, response)
except NotImplementedError:
response.status_code = status.HTTP_202_ACCEPTED
def get_user_router(
def get_users_router(
user_db: BaseUserDatabase[models.BaseUserDB],
user_model: Type[models.BaseUser],
user_create_model: Type[models.BaseUserCreate],
user_update_model: Type[models.BaseUserUpdate],
user_db_model: Type[models.BaseUserDB],
authenticator: Authenticator,
reset_password_token_secret: str,
reset_password_token_lifetime_seconds: int = 3600,
) -> EventHandlersRouter:
after_update: Optional[Callable[[models.UD, Dict[str, Any], Request], None]] = None,
) -> APIRouter:
"""Generate a router with the authentication routes."""
router = EventHandlersRouter()
reset_password_token_audience = "fastapi-users:reset"
router = APIRouter()
get_current_active_user = authenticator.get_current_active_user
get_current_superuser = authenticator.get_current_superuser
@@ -75,99 +30,19 @@ def get_user_router(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return user
async def _update_user(user: models.BaseUserDB, update_dict: Dict[str, Any]):
async def _update_user(
user: models.BaseUserDB, update_dict: Dict[str, Any], request: Request
):
for field in update_dict:
if field == "password":
hashed_password = get_password_hash(update_dict[field])
user.hashed_password = hashed_password
else:
setattr(user, field, update_dict[field])
return await user_db.update(user)
for auth_backend in authenticator.backends:
_add_login_route(router, user_db, auth_backend)
_add_logout_route(router, authenticator, auth_backend)
@router.post(
"/register", response_model=user_model, status_code=status.HTTP_201_CREATED
)
async def register(request: Request, user: user_create_model): # type: ignore
user = cast(models.BaseUserCreate, user) # Prevent mypy complain
existing_user = await user_db.get_by_email(user.email)
if existing_user is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.REGISTER_USER_ALREADY_EXISTS,
)
hashed_password = get_password_hash(user.password)
db_user = user_db_model(
**user.create_update_dict(), hashed_password=hashed_password
)
created_user = await user_db.create(db_user)
await router.run_handlers(Event.ON_AFTER_REGISTER, created_user, request)
return created_user
@router.post("/forgot-password", status_code=status.HTTP_202_ACCEPTED)
async def forgot_password(
request: Request, email: EmailStr = Body(..., embed=True)
):
user = await user_db.get_by_email(email)
if user is not None and user.is_active:
token_data = {"user_id": str(user.id), "aud": reset_password_token_audience}
token = generate_jwt(
token_data,
reset_password_token_lifetime_seconds,
reset_password_token_secret,
)
await router.run_handlers(
Event.ON_AFTER_FORGOT_PASSWORD, user, token, request
)
return None
@router.post("/reset-password")
async def reset_password(token: str = Body(...), password: str = Body(...)):
try:
data = jwt.decode(
token,
reset_password_token_secret,
audience=reset_password_token_audience,
algorithms=[JWT_ALGORITHM],
)
user_id = data.get("user_id")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
)
try:
user_uiid = UUID4(user_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
)
user = await user_db.get(user_uiid)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
)
user.hashed_password = get_password_hash(password)
await user_db.update(user)
except jwt.PyJWTError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
)
updated_user = await user_db.update(user)
if after_update:
await run_handler(after_update, updated_user, update_dict, request)
return updated_user
@router.get("/me", response_model=user_model)
async def me(
@@ -185,11 +60,7 @@ def get_user_router(
models.BaseUserUpdate, updated_user,
) # Prevent mypy complain
updated_user_data = updated_user.create_update_dict()
updated_user = await _update_user(user, updated_user_data)
await router.run_handlers(
Event.ON_AFTER_UPDATE, updated_user, updated_user_data, request
)
updated_user = await _update_user(user, updated_user_data, request)
return updated_user
@@ -207,14 +78,14 @@ def get_user_router(
dependencies=[Depends(get_current_superuser)],
)
async def update_user(
id: UUID4, updated_user: user_update_model, # type: ignore
id: UUID4, updated_user: user_update_model, request: Request # type: ignore
):
updated_user = cast(
models.BaseUserUpdate, updated_user,
) # Prevent mypy complain
user = await _get_or_404(id)
updated_user_data = updated_user.create_update_dict_superuser()
return await _update_user(user, updated_user_data)
return await _update_user(user, updated_user_data, request)
@router.delete(
"/{id}",

View File

@@ -38,7 +38,12 @@ nav:
- Introduction: configuration/authentication/index.md
- configuration/authentication/jwt.md
- configuration/authentication/cookie.md
- configuration/router.md
- Routers:
- Introduction: configuration/routers/index.md
- configuration/routers/auth.md
- configuration/routers/register.md
- configuration/routers/reset.md
- configuration/routers/users.md
- configuration/full_example.md
- configuration/oauth.md
- Usage:

View File

@@ -26,6 +26,7 @@ requires = [
"email-validator ==1.1.0",
"pyjwt ==1.7.1",
"python-multipart ==0.0.5",
"makefun >=1.9.2,<1.10",
]
[tool.flit.metadata.requires-extra]

View File

@@ -1,17 +1,14 @@
import asyncio
from typing import Any, List, Mapping, Optional, Tuple
from typing import List, Optional
import http.cookies
import httpx
import pytest
from asgi_lifespan import LifespanManager
from fastapi import Depends, FastAPI
from fastapi import Depends, Response, FastAPI
from fastapi.security import OAuth2PasswordBearer
from httpx_oauth.oauth2 import OAuth2
from pydantic import UUID4
from starlette.applications import ASGIApp
from starlette.requests import Request
from starlette.responses import Response
from fastapi_users import models
from fastapi_users.authentication import Authenticator, BaseAuthentication
@@ -229,16 +226,15 @@ def mock_user_db_oauth(
return MockUserDatabase(UserDBOAuth)
class MockAuthentication(BaseAuthentication):
class MockAuthentication(BaseAuthentication[str]):
def __init__(self, name: str = "mock"):
super().__init__(name)
super().__init__(name, logout=True)
self.scheme = OAuth2PasswordBearer("/users/login", auto_error=False)
async def __call__(self, request: Request, user_db: BaseUserDatabase):
token = await self.scheme.__call__(request)
if token is not None:
async def __call__(self, credentials: Optional[str], user_db: BaseUserDatabase):
if credentials is not None:
try:
token_uuid = UUID4(token)
token_uuid = UUID4(credentials)
return await user_db.get(token_uuid)
except ValueError:
return None
@@ -247,41 +243,15 @@ class MockAuthentication(BaseAuthentication):
async def get_login_response(self, user: BaseUserDB, response: Response):
return {"token": user.id}
async def get_logout_response(self, user: BaseUserDB, response: Response):
return None
@pytest.fixture
def mock_authentication():
return MockAuthentication()
@pytest.fixture
def request_builder():
def _request_builder(
headers: Mapping[str, Any] = None, cookies: Mapping[str, str] = None
) -> Request:
encoded_headers: List[Tuple[bytes, bytes]] = []
if headers is not None:
encoded_headers += [
(key.lower().encode("latin-1"), headers[key].encode("latin-1"))
for key in headers
]
if cookies is not None:
for key in cookies:
cookie = http.cookies.SimpleCookie() # type: http.cookies.BaseCookie
cookie[key] = cookies[key]
cookie_val = cookie.output(header="").strip()
encoded_headers.append((b"cookie", cookie_val.encode("latin-1")))
scope = {
"type": "http",
"headers": encoded_headers,
}
return Request(scope)
return _request_builder
@pytest.fixture
def get_test_client():
async def _get_test_client(app: ASGIApp) -> httpx.AsyncClient:
@@ -304,7 +274,7 @@ def get_test_auth_client(mock_user_db, get_test_client):
authenticator = Authenticator(backends, mock_user_db)
@app.get("/test-current-user")
def test_current_user(user: UserDB = Depends(authenticator.get_current_user),):
def test_current_user(user: UserDB = Depends(authenticator.get_current_user)):
return user
@app.get("/test-current-active-user")

View File

@@ -1,49 +1,63 @@
from typing import Optional
import pytest
from starlette import status
from starlette.requests import Request
from fastapi import Request, status
from fastapi.security.base import SecurityBase
from fastapi_users.authentication import BaseAuthentication
from fastapi_users.authentication import (
BaseAuthentication,
DuplicateBackendNamesError,
)
from fastapi_users.db import BaseUserDatabase
from fastapi_users.models import BaseUserDB
@pytest.fixture
def auth_backend_none():
class BackendNone(BaseAuthentication):
async def __call__(
self, request: Request, user_db: BaseUserDatabase
) -> Optional[BaseUserDB]:
return None
return BackendNone()
class MockSecurityScheme(SecurityBase):
def __call__(self, request: Request) -> Optional[str]:
return "mock"
@pytest.fixture
def auth_backend_user(user):
class BackendUser(BaseAuthentication):
async def __call__(
self, request: Request, user_db: BaseUserDatabase
) -> Optional[BaseUserDB]:
return user
class BackendNone(BaseAuthentication[str]):
def __init__(self, name="none"):
super().__init__(name, logout=False)
self.scheme = MockSecurityScheme()
return BackendUser()
async def __call__(
self, credentials: Optional[str], user_db: BaseUserDatabase
) -> Optional[BaseUserDB]:
return None
class BackendUser(BaseAuthentication[str]):
def __init__(self, user: BaseUserDB, name="user"):
super().__init__(name, logout=False)
self.scheme = MockSecurityScheme()
self.user = user
async def __call__(
self, credentials: Optional[str], user_db: BaseUserDatabase
) -> Optional[BaseUserDB]:
return self.user
@pytest.mark.authentication
@pytest.mark.asyncio
async def test_authenticator(
get_test_auth_client, auth_backend_none, auth_backend_user
):
client = await get_test_auth_client([auth_backend_none, auth_backend_user])
async def test_authenticator(get_test_auth_client, user):
client = await get_test_auth_client([BackendNone(), BackendUser(user)])
response = await client.get("/test-current-user")
assert response.status_code == status.HTTP_200_OK
@pytest.mark.authentication
@pytest.mark.asyncio
async def test_authenticator_none(get_test_auth_client, auth_backend_none):
client = await get_test_auth_client([auth_backend_none, auth_backend_none])
async def test_authenticator_none(get_test_auth_client):
client = await get_test_auth_client([BackendNone(), BackendNone(name="none-bis")])
response = await client.get("/test-current-user")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.authentication
@pytest.mark.asyncio
async def test_authenticators_with_same_name(get_test_auth_client):
with pytest.raises(DuplicateBackendNamesError):
await get_test_auth_client([BackendNone(), BackendNone()])

View File

@@ -1,5 +1,5 @@
import pytest
from starlette.responses import Response
from fastapi import Response
from fastapi_users.authentication import BaseAuthentication
@@ -12,12 +12,9 @@ def base_authentication():
@pytest.mark.authentication
class TestAuthenticate:
@pytest.mark.asyncio
async def test_not_implemented(
self, base_authentication, mock_user_db, request_builder
):
request = request_builder({})
async def test_not_implemented(self, base_authentication, mock_user_db):
with pytest.raises(NotImplementedError):
await base_authentication(request, mock_user_db)
await base_authentication(None, mock_user_db)
@pytest.mark.authentication

View File

@@ -2,7 +2,7 @@ import re
import jwt
import pytest
from starlette.responses import Response
from fastapi import Response
from fastapi_users.authentication.cookie import CookieAuthentication
from fastapi_users.utils import JWT_ALGORITHM, generate_jwt
@@ -28,10 +28,10 @@ cookie_authentication_httponly = CookieAuthentication(
@pytest.fixture
def token():
def _token(user=None, lifetime=LIFETIME):
def _token(user_id=None, lifetime=LIFETIME):
data = {"aud": "fastapi-users:auth"}
if user is not None:
data["user_id"] = str(user.id)
if user_id is not None:
data["user_id"] = str(user_id)
return generate_jwt(data, lifetime, SECRET, JWT_ALGORITHM)
return _token
@@ -45,35 +45,28 @@ def test_default_name():
@pytest.mark.authentication
class TestAuthenticate:
@pytest.mark.asyncio
async def test_missing_token(self, mock_user_db, request_builder):
request = request_builder()
authenticated_user = await cookie_authentication(request, mock_user_db)
async def test_missing_token(self, mock_user_db):
authenticated_user = await cookie_authentication(None, mock_user_db)
assert authenticated_user is None
@pytest.mark.asyncio
async def test_invalid_token(self, mock_user_db, request_builder):
cookies = {}
cookies[COOKIE_NAME] = "foo"
request = request_builder(cookies=cookies)
authenticated_user = await cookie_authentication(request, mock_user_db)
async def test_invalid_token(self, mock_user_db):
authenticated_user = await cookie_authentication("foo", mock_user_db)
assert authenticated_user is None
@pytest.mark.asyncio
async def test_valid_token_missing_user_payload(
self, mock_user_db, request_builder, token
):
cookies = {}
cookies[COOKIE_NAME] = token()
request = request_builder(cookies=cookies)
authenticated_user = await cookie_authentication(request, mock_user_db)
async def test_valid_token_missing_user_payload(self, mock_user_db, token):
authenticated_user = await cookie_authentication(token(), mock_user_db)
assert authenticated_user is None
@pytest.mark.asyncio
async def test_valid_token(self, mock_user_db, request_builder, token, user):
cookies = {}
cookies[COOKIE_NAME] = token(user)
request = request_builder(cookies=cookies)
authenticated_user = await cookie_authentication(request, mock_user_db)
async def test_valid_token_invalid_uuid(self, mock_user_db, token):
authenticated_user = await cookie_authentication(token("foo"), mock_user_db)
assert authenticated_user is None
@pytest.mark.asyncio
async def test_valid_token(self, mock_user_db, token, user):
authenticated_user = await cookie_authentication(token(user.id), mock_user_db)
assert authenticated_user.id == user.id

View File

@@ -1,6 +1,6 @@
import jwt
import pytest
from starlette.responses import Response
from fastapi import Response
from fastapi_users.authentication.jwt import JWTAuthentication
from fastapi_users.utils import JWT_ALGORITHM, generate_jwt
@@ -34,43 +34,32 @@ def test_default_name(jwt_authentication):
@pytest.mark.authentication
class TestAuthenticate:
@pytest.mark.asyncio
async def test_missing_token(
self, jwt_authentication, mock_user_db, request_builder
):
request = request_builder(headers={})
authenticated_user = await jwt_authentication(request, mock_user_db)
async def test_missing_token(self, jwt_authentication, mock_user_db):
authenticated_user = await jwt_authentication(None, mock_user_db)
assert authenticated_user is None
@pytest.mark.asyncio
async def test_invalid_token(
self, jwt_authentication, mock_user_db, request_builder
):
request = request_builder(headers={"Authorization": "Bearer foo"})
authenticated_user = await jwt_authentication(request, mock_user_db)
async def test_invalid_token(self, jwt_authentication, mock_user_db):
authenticated_user = await jwt_authentication("foo", mock_user_db)
assert authenticated_user is None
@pytest.mark.asyncio
async def test_valid_token_missing_user_payload(
self, jwt_authentication, mock_user_db, request_builder, token
self, jwt_authentication, mock_user_db, token
):
request = request_builder(headers={"Authorization": f"Bearer {token()}"})
authenticated_user = await jwt_authentication(request, mock_user_db)
authenticated_user = await jwt_authentication(token(), mock_user_db)
assert authenticated_user is None
@pytest.mark.asyncio
async def test_valid_token_invalid_uuid(
self, jwt_authentication, mock_user_db, request_builder, token
self, jwt_authentication, mock_user_db, token
):
request = request_builder(headers={"Authorization": f"Bearer {token('foo')}"})
authenticated_user = await jwt_authentication(request, mock_user_db)
authenticated_user = await jwt_authentication(token("foo"), mock_user_db)
assert authenticated_user is None
@pytest.mark.asyncio
async def test_valid_token(
self, jwt_authentication, mock_user_db, request_builder, token, user
):
request = request_builder(headers={"Authorization": f"Bearer {token(user.id)}"})
authenticated_user = await jwt_authentication(request, mock_user_db)
async def test_valid_token(self, jwt_authentication, mock_user_db, token, user):
authenticated_user = await jwt_authentication(token(user.id), mock_user_db)
assert authenticated_user.id == user.id

View File

@@ -1,58 +1,26 @@
import pytest
import httpx
from fastapi import Depends, FastAPI
from httpx_oauth.oauth2 import OAuth2
from starlette import status
from fastapi import Depends, FastAPI, status
from fastapi_users import FastAPIUsers
from fastapi_users.router import Event, EventHandlersRouter
from tests.conftest import User, UserCreate, UserUpdate, UserDB
def sync_event_handler():
return None
async def async_event_handler():
return None
@pytest.fixture(params=[sync_event_handler, async_event_handler])
def fastapi_users(
request, mock_user_db, mock_authentication, oauth_client
) -> FastAPIUsers:
fastapi_users = FastAPIUsers(
mock_user_db,
[mock_authentication],
User,
UserCreate,
UserUpdate,
UserDB,
"SECRET",
)
fastapi_users.get_oauth_router(oauth_client, "SECRET")
@fastapi_users.on_after_register()
def on_after_register():
return request.param()
@fastapi_users.on_after_forgot_password()
def on_after_forgot_password():
return request.param()
@fastapi_users.on_after_update()
def on_after_update():
return request.param()
return fastapi_users
@pytest.fixture
@pytest.mark.asyncio
async def test_app_client(fastapi_users, get_test_client) -> httpx.AsyncClient:
async def test_app_client(
mock_user_db, mock_authentication, oauth_client, get_test_client
) -> httpx.AsyncClient:
fastapi_users = FastAPIUsers(
mock_user_db, [mock_authentication], User, UserCreate, UserUpdate, UserDB,
)
app = FastAPI()
app.include_router(fastapi_users.router, prefix="/users")
app.include_router(fastapi_users.get_register_router())
app.include_router(fastapi_users.get_reset_password_router("SECRET"))
app.include_router(fastapi_users.get_auth_router(mock_authentication))
app.include_router(fastapi_users.get_oauth_router(oauth_client, "SECRET"))
app.include_router(fastapi_users.get_users_router(), prefix="/users")
@app.get("/current-user")
def current_user(user=Depends(fastapi_users.get_current_user)):
@@ -69,35 +37,51 @@ async def test_app_client(fastapi_users, get_test_client) -> httpx.AsyncClient:
return await get_test_client(app)
@pytest.mark.fastapi_users
class TestFastAPIUsers:
def test_event_handlers(self, fastapi_users):
event_handlers = fastapi_users.router.event_handlers
assert len(event_handlers[Event.ON_AFTER_REGISTER]) == 1
assert len(event_handlers[Event.ON_AFTER_FORGOT_PASSWORD]) == 1
@pytest.mark.fastapi_users
@pytest.mark.asyncio
class TestRouter:
class TestRoutes:
async def test_routes_exist(self, test_app_client: httpx.AsyncClient):
response = await test_app_client.post("/users/register")
assert response.status_code != status.HTTP_404_NOT_FOUND
response = await test_app_client.post("/register")
assert response.status_code not in (
status.HTTP_404_NOT_FOUND,
status.HTTP_405_METHOD_NOT_ALLOWED,
)
response = await test_app_client.post("/users/login")
assert response.status_code != status.HTTP_404_NOT_FOUND
response = await test_app_client.post("/forgot-password")
assert response.status_code not in (
status.HTTP_404_NOT_FOUND,
status.HTTP_405_METHOD_NOT_ALLOWED,
)
response = await test_app_client.post("/users/forgot-password")
assert response.status_code != status.HTTP_404_NOT_FOUND
response = await test_app_client.post("/reset-password")
assert response.status_code not in (
status.HTTP_404_NOT_FOUND,
status.HTTP_405_METHOD_NOT_ALLOWED,
)
response = await test_app_client.post("/users/reset-password")
assert response.status_code != status.HTTP_404_NOT_FOUND
response = await test_app_client.post("/login")
assert response.status_code not in (
status.HTTP_404_NOT_FOUND,
status.HTTP_405_METHOD_NOT_ALLOWED,
)
response = await test_app_client.post("/logout")
assert response.status_code not in (
status.HTTP_404_NOT_FOUND,
status.HTTP_405_METHOD_NOT_ALLOWED,
)
response = await test_app_client.get("/users/aaa")
assert response.status_code != status.HTTP_404_NOT_FOUND
assert response.status_code not in (
status.HTTP_404_NOT_FOUND,
status.HTTP_405_METHOD_NOT_ALLOWED,
)
response = await test_app_client.patch("/users/aaa")
assert response.status_code != status.HTTP_404_NOT_FOUND
assert response.status_code not in (
status.HTTP_404_NOT_FOUND,
status.HTTP_405_METHOD_NOT_ALLOWED,
)
@pytest.mark.fastapi_users
@@ -177,21 +161,3 @@ class TestGetCurrentSuperuser:
"/current-superuser", headers={"Authorization": f"Bearer {superuser.id}"}
)
assert response.status_code == status.HTTP_200_OK
@pytest.mark.fastapi_users
def test_get_oauth_router(mocker, fastapi_users: FastAPIUsers, oauth_client: OAuth2):
# Check that existing OAuth router declared
# before the handlers decorators is correctly binded
existing_oauth_router = fastapi_users.oauth_routers[0]
event_handlers = existing_oauth_router.event_handlers
assert len(event_handlers[Event.ON_AFTER_REGISTER]) == 1
assert len(event_handlers[Event.ON_AFTER_FORGOT_PASSWORD]) == 1
# Check that OAuth router declared
# after the handlers decorators is correctly binded
oauth_router = fastapi_users.get_oauth_router(oauth_client, "SECRET")
assert isinstance(oauth_router, EventHandlersRouter)
event_handlers = oauth_router.event_handlers
assert len(event_handlers[Event.ON_AFTER_REGISTER]) == 1
assert len(event_handlers[Event.ON_AFTER_FORGOT_PASSWORD]) == 1

96
tests/test_router_auth.py Normal file
View File

@@ -0,0 +1,96 @@
from typing import cast, Dict, Any
import httpx
import pytest
from fastapi import FastAPI, status
from fastapi_users.authentication import Authenticator
from fastapi_users.router import ErrorCode, get_auth_router
from tests.conftest import MockAuthentication, UserDB
@pytest.fixture
@pytest.mark.asyncio
async def test_app_client(
mock_user_db, mock_authentication, get_test_client
) -> httpx.AsyncClient:
mock_authentication_bis = MockAuthentication(name="mock-bis")
authenticator = Authenticator(
[mock_authentication, mock_authentication_bis], mock_user_db
)
mock_auth_router = get_auth_router(mock_authentication, mock_user_db, authenticator)
mock_bis_auth_router = get_auth_router(
mock_authentication_bis, mock_user_db, authenticator
)
app = FastAPI()
app.include_router(mock_auth_router, prefix="/mock")
app.include_router(mock_bis_auth_router, prefix="/mock-bis")
return await get_test_client(app)
@pytest.mark.router
@pytest.mark.parametrize("path", ["/mock/login", "/mock-bis/login"])
@pytest.mark.asyncio
class TestLogin:
async def test_empty_body(self, path, test_app_client: httpx.AsyncClient):
response = await test_app_client.post(path, data={})
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_missing_username(self, path, test_app_client: httpx.AsyncClient):
data = {"password": "guinevere"}
response = await test_app_client.post(path, data=data)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_missing_password(self, path, test_app_client: httpx.AsyncClient):
data = {"username": "king.arthur@camelot.bt"}
response = await test_app_client.post(path, data=data)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_not_existing_user(self, path, test_app_client: httpx.AsyncClient):
data = {"username": "lancelot@camelot.bt", "password": "guinevere"}
response = await test_app_client.post(path, data=data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.LOGIN_BAD_CREDENTIALS
async def test_wrong_password(self, path, test_app_client: httpx.AsyncClient):
data = {"username": "king.arthur@camelot.bt", "password": "percival"}
response = await test_app_client.post(path, data=data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.LOGIN_BAD_CREDENTIALS
async def test_valid_credentials(
self, path, test_app_client: httpx.AsyncClient, user: UserDB
):
data = {"username": "king.arthur@camelot.bt", "password": "guinevere"}
response = await test_app_client.post(path, data=data)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"token": str(user.id)}
async def test_inactive_user(self, path, test_app_client: httpx.AsyncClient):
data = {"username": "percival@camelot.bt", "password": "angharad"}
response = await test_app_client.post(path, data=data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.LOGIN_BAD_CREDENTIALS
@pytest.mark.router
@pytest.mark.parametrize("path", ["/mock/logout", "/mock-bis/logout"])
@pytest.mark.asyncio
class TestLogout:
async def test_missing_token(self, path, test_app_client: httpx.AsyncClient):
response = await test_app_client.post(path)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
async def test_valid_credentials(
self, mocker, path, test_app_client: httpx.AsyncClient, user: UserDB
):
response = await test_app_client.post(
path, headers={"Authorization": f"Bearer {user.id}"}
)
assert response.status_code == status.HTTP_200_OK

View File

@@ -4,12 +4,10 @@ from typing import Dict, Any, cast
import asynctest
import httpx
import pytest
from fastapi import FastAPI
from starlette import status
from starlette.requests import Request
from fastapi import FastAPI, status, Request
from fastapi_users.authentication import Authenticator
from fastapi_users.router.common import ErrorCode, Event
from fastapi_users.router.common import ErrorCode
from fastapi_users.router.oauth import generate_state_token, get_oauth_router
from tests.conftest import MockAuthentication, UserDB
@@ -17,16 +15,16 @@ from tests.conftest import MockAuthentication, UserDB
SECRET = "SECRET"
def event_handler_sync():
def after_register_sync():
return MagicMock(return_value=None)
def event_handler_async():
def after_register_async():
return asynctest.CoroutineMock(return_value=None)
@pytest.fixture(params=[event_handler_sync, event_handler_async])
def event_handler(request):
@pytest.fixture(params=[after_register_sync, after_register_async])
def after_register(request):
return request.param()
@@ -35,7 +33,7 @@ def get_test_app_client(
mock_user_db_oauth,
mock_authentication,
oauth_client,
event_handler,
after_register,
get_test_client,
):
async def _get_test_app_client(redirect_url: str = None) -> httpx.AsyncClient:
@@ -51,10 +49,9 @@ def get_test_app_client(
authenticator,
SECRET,
redirect_url,
after_register,
)
oauth_router.add_event_handler(Event.ON_AFTER_REGISTER, event_handler)
app = FastAPI()
app.include_router(oauth_router)
@@ -151,7 +148,7 @@ class TestCallback:
test_app_client: httpx.AsyncClient,
oauth_client,
user_oauth,
event_handler,
after_register,
):
with asynctest.patch.object(
oauth_client, "get_access_token"
@@ -171,7 +168,7 @@ class TestCallback:
get_id_email_mock.assert_awaited_once_with("TOKEN")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert event_handler.called is False
assert after_register.called is False
async def test_existing_user_with_oauth(
self,
@@ -179,7 +176,7 @@ class TestCallback:
test_app_client: httpx.AsyncClient,
oauth_client,
user_oauth,
event_handler,
after_register,
):
state_jwt = generate_state_token({"authentication_backend": "mock"}, "SECRET")
with asynctest.patch.object(
@@ -206,7 +203,7 @@ class TestCallback:
assert data["token"] == str(user_oauth.id)
assert event_handler.called is False
assert after_register.called is False
async def test_existing_user_without_oauth(
self,
@@ -214,7 +211,7 @@ class TestCallback:
test_app_client: httpx.AsyncClient,
oauth_client,
superuser_oauth,
event_handler,
after_register,
):
state_jwt = generate_state_token({"authentication_backend": "mock"}, "SECRET")
with asynctest.patch.object(
@@ -244,14 +241,14 @@ class TestCallback:
assert data["token"] == str(superuser_oauth.id)
assert event_handler.called is False
assert after_register.called is False
async def test_unknown_user(
self,
mock_user_db_oauth,
test_app_client: httpx.AsyncClient,
oauth_client,
event_handler,
after_register,
):
state_jwt = generate_state_token({"authentication_backend": "mock"}, "SECRET")
with asynctest.patch.object(
@@ -281,10 +278,10 @@ class TestCallback:
assert "token" in data
assert event_handler.called is True
actual_user = event_handler.call_args[0][0]
assert after_register.called is True
actual_user = after_register.call_args[0][0]
assert str(actual_user.id) == data["token"]
request = event_handler.call_args[0][1]
request = after_register.call_args[0][1]
assert isinstance(request, Request)
async def test_inactive_user(
@@ -293,7 +290,7 @@ class TestCallback:
test_app_client: httpx.AsyncClient,
oauth_client,
inactive_user_oauth,
event_handler,
after_register,
):
state_jwt = generate_state_token({"authentication_backend": "mock"}, "SECRET")
with asynctest.patch.object(
@@ -318,7 +315,7 @@ class TestCallback:
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.LOGIN_BAD_CREDENTIALS
assert event_handler.called is False
assert after_register.called is False
async def test_redirect_url_router(
self,

View File

@@ -0,0 +1,122 @@
from typing import cast, Dict, Any
from unittest.mock import MagicMock
import asynctest
import httpx
import pytest
from fastapi import FastAPI, status, Request
from fastapi_users.router import ErrorCode, get_register_router
from tests.conftest import User, UserCreate, UserDB
SECRET = "SECRET"
LIFETIME = 3600
def after_register_sync():
return MagicMock(return_value=None)
def after_register_async():
return asynctest.CoroutineMock(return_value=None)
@pytest.fixture(params=[after_register_sync, after_register_async])
def after_register(request):
return request.param()
@pytest.fixture
@pytest.mark.asyncio
async def test_app_client(
mock_user_db, mock_authentication, after_register, get_test_client
) -> httpx.AsyncClient:
register_router = get_register_router(
mock_user_db, User, UserCreate, UserDB, after_register,
)
app = FastAPI()
app.include_router(register_router)
return await get_test_client(app)
@pytest.mark.router
@pytest.mark.asyncio
class TestRegister:
async def test_empty_body(self, test_app_client: httpx.AsyncClient, after_register):
response = await test_app_client.post("/register", json={})
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert after_register.called is False
async def test_missing_password(
self, test_app_client: httpx.AsyncClient, after_register
):
json = {"email": "king.arthur@camelot.bt"}
response = await test_app_client.post("/register", json=json)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert after_register.called is False
async def test_wrong_email(
self, test_app_client: httpx.AsyncClient, after_register
):
json = {"email": "king.arthur", "password": "guinevere"}
response = await test_app_client.post("/register", json=json)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert after_register.called is False
async def test_existing_user(
self, test_app_client: httpx.AsyncClient, after_register
):
json = {"email": "king.arthur@camelot.bt", "password": "guinevere"}
response = await test_app_client.post("/register", json=json)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.REGISTER_USER_ALREADY_EXISTS
assert after_register.called is False
async def test_valid_body(self, test_app_client: httpx.AsyncClient, after_register):
json = {"email": "lancelot@camelot.bt", "password": "guinevere"}
response = await test_app_client.post("/register", json=json)
assert response.status_code == status.HTTP_201_CREATED
assert after_register.called is True
data = cast(Dict[str, Any], response.json())
assert "hashed_password" not in data
assert "password" not in data
assert data["id"] is not None
actual_user = after_register.call_args[0][0]
assert str(actual_user.id) == data["id"]
request = after_register.call_args[0][1]
assert isinstance(request, Request)
async def test_valid_body_is_superuser(
self, test_app_client: httpx.AsyncClient, after_register
):
json = {
"email": "lancelot@camelot.bt",
"password": "guinevere",
"is_superuser": True,
}
response = await test_app_client.post("/register", json=json)
assert response.status_code == status.HTTP_201_CREATED
assert after_register.called is True
data = cast(Dict[str, Any], response.json())
assert data["is_superuser"] is False
async def test_valid_body_is_active(
self, test_app_client: httpx.AsyncClient, after_register
):
json = {
"email": "lancelot@camelot.bt",
"password": "guinevere",
"is_active": False,
}
response = await test_app_client.post("/register", json=json)
assert response.status_code == status.HTTP_201_CREATED
assert after_register.called is True
data = cast(Dict[str, Any], response.json())
assert data["is_active"] is True

198
tests/test_router_reset.py Normal file
View File

@@ -0,0 +1,198 @@
from typing import cast, Dict, Any
from unittest.mock import MagicMock
import asynctest
import httpx
import jwt
import pytest
from fastapi import FastAPI, status, Request
from fastapi_users.router import ErrorCode, get_reset_password_router
from fastapi_users.utils import JWT_ALGORITHM, generate_jwt
from tests.conftest import UserDB
SECRET = "SECRET"
LIFETIME = 3600
@pytest.fixture
def forgot_password_token():
def _forgot_password_token(user_id=None, lifetime=LIFETIME):
data = {"aud": "fastapi-users:reset"}
if user_id is not None:
data["user_id"] = str(user_id)
return generate_jwt(data, lifetime, SECRET, JWT_ALGORITHM)
return _forgot_password_token
def after_forgot_password_sync():
return MagicMock(return_value=None)
def after_forgot_password_async():
return asynctest.CoroutineMock(return_value=None)
@pytest.fixture(params=[after_forgot_password_sync, after_forgot_password_async])
def after_forgot_password(request):
return request.param()
@pytest.fixture
@pytest.mark.asyncio
async def test_app_client(
mock_user_db, mock_authentication, after_forgot_password, get_test_client
) -> httpx.AsyncClient:
reset_router = get_reset_password_router(
mock_user_db, SECRET, LIFETIME, after_forgot_password
)
app = FastAPI()
app.include_router(reset_router)
return await get_test_client(app)
@pytest.mark.router
@pytest.mark.asyncio
class TestForgotPassword:
async def test_empty_body(
self, test_app_client: httpx.AsyncClient, after_forgot_password
):
response = await test_app_client.post("/forgot-password", json={})
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert after_forgot_password.called is False
async def test_not_existing_user(
self, test_app_client: httpx.AsyncClient, after_forgot_password
):
json = {"email": "lancelot@camelot.bt"}
response = await test_app_client.post("/forgot-password", json=json)
assert response.status_code == status.HTTP_202_ACCEPTED
assert after_forgot_password.called is False
async def test_inactive_user(
self, test_app_client: httpx.AsyncClient, after_forgot_password
):
json = {"email": "percival@camelot.bt"}
response = await test_app_client.post("/forgot-password", json=json)
assert response.status_code == status.HTTP_202_ACCEPTED
assert after_forgot_password.called is False
async def test_existing_user(
self, test_app_client: httpx.AsyncClient, after_forgot_password, user
):
json = {"email": "king.arthur@camelot.bt"}
response = await test_app_client.post("/forgot-password", json=json)
assert response.status_code == status.HTTP_202_ACCEPTED
assert after_forgot_password.called is True
actual_user = after_forgot_password.call_args[0][0]
assert actual_user.id == user.id
actual_token = after_forgot_password.call_args[0][1]
decoded_token = jwt.decode(
actual_token,
SECRET,
audience="fastapi-users:reset",
algorithms=[JWT_ALGORITHM],
)
assert decoded_token["user_id"] == str(user.id)
request = after_forgot_password.call_args[0][2]
assert isinstance(request, Request)
@pytest.mark.router
@pytest.mark.asyncio
class TestResetPassword:
async def test_empty_body(self, test_app_client: httpx.AsyncClient):
response = await test_app_client.post("/reset-password", json={})
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_missing_token(self, test_app_client: httpx.AsyncClient):
json = {"password": "guinevere"}
response = await test_app_client.post("/reset-password", json=json)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_missing_password(self, test_app_client: httpx.AsyncClient):
json = {"token": "foo"}
response = await test_app_client.post("/reset-password", json=json)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_invalid_token(self, test_app_client: httpx.AsyncClient):
json = {"token": "foo", "password": "guinevere"}
response = await test_app_client.post("/reset-password", json=json)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.RESET_PASSWORD_BAD_TOKEN
async def test_valid_token_missing_user_id_payload(
self,
mocker,
mock_user_db,
test_app_client: httpx.AsyncClient,
forgot_password_token,
):
mocker.spy(mock_user_db, "update")
json = {"token": forgot_password_token(), "password": "holygrail"}
response = await test_app_client.post("/reset-password", json=json)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.RESET_PASSWORD_BAD_TOKEN
assert mock_user_db.update.called is False
async def test_valid_token_invalid_uuid(
self,
mocker,
mock_user_db,
test_app_client: httpx.AsyncClient,
forgot_password_token,
):
mocker.spy(mock_user_db, "update")
json = {"token": forgot_password_token("foo"), "password": "holygrail"}
response = await test_app_client.post("/reset-password", json=json)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.RESET_PASSWORD_BAD_TOKEN
assert mock_user_db.update.called is False
async def test_inactive_user(
self,
mocker,
mock_user_db,
test_app_client: httpx.AsyncClient,
forgot_password_token,
inactive_user: UserDB,
):
mocker.spy(mock_user_db, "update")
json = {
"token": forgot_password_token(inactive_user.id),
"password": "holygrail",
}
response = await test_app_client.post("/reset-password", json=json)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.RESET_PASSWORD_BAD_TOKEN
assert mock_user_db.update.called is False
async def test_existing_user(
self,
mocker,
mock_user_db,
test_app_client: httpx.AsyncClient,
forgot_password_token,
user: UserDB,
):
mocker.spy(mock_user_db, "update")
current_hashed_passord = user.hashed_password
json = {"token": forgot_password_token(user.id), "password": "holygrail"}
response = await test_app_client.post("/reset-password", json=json)
assert response.status_code == status.HTTP_200_OK
assert mock_user_db.update.called is True
updated_user = mock_user_db.update.call_args[0][0]
assert updated_user.hashed_password != current_hashed_passord

View File

@@ -3,365 +3,50 @@ from unittest.mock import MagicMock
import asynctest
import httpx
import jwt
import pytest
from fastapi import FastAPI
from starlette import status
from starlette.requests import Request
from fastapi import FastAPI, status, Request
from fastapi_users.authentication import Authenticator
from fastapi_users.router import ErrorCode, Event, get_user_router
from fastapi_users.utils import JWT_ALGORITHM, generate_jwt
from tests.conftest import MockAuthentication, User, UserCreate, UserUpdate, UserDB
from fastapi_users.router import get_users_router
from tests.conftest import MockAuthentication, User, UserUpdate, UserDB
SECRET = "SECRET"
LIFETIME = 3600
@pytest.fixture
def forgot_password_token():
def _forgot_password_token(user_id=None, lifetime=LIFETIME):
data = {"aud": "fastapi-users:reset"}
if user_id is not None:
data["user_id"] = str(user_id)
return generate_jwt(data, lifetime, SECRET, JWT_ALGORITHM)
return _forgot_password_token
def event_handler_sync():
def after_update_sync():
return MagicMock(return_value=None)
def event_handler_async():
def after_update_async():
return asynctest.CoroutineMock(return_value=None)
@pytest.fixture(params=[event_handler_sync, event_handler_async])
def event_handler(request):
@pytest.fixture(params=[after_update_sync, after_update_async])
def after_update(request):
return request.param()
@pytest.fixture
@pytest.mark.asyncio
async def test_app_client(
mock_user_db, mock_authentication, event_handler, get_test_client
mock_user_db, mock_authentication, after_update, get_test_client
) -> httpx.AsyncClient:
mock_authentication_bis = MockAuthentication(name="mock-bis")
authenticator = Authenticator(
[mock_authentication, mock_authentication_bis], mock_user_db
)
user_router = get_user_router(
mock_user_db,
User,
UserCreate,
UserUpdate,
UserDB,
authenticator,
SECRET,
LIFETIME,
user_router = get_users_router(
mock_user_db, User, UserUpdate, UserDB, authenticator, after_update,
)
user_router.add_event_handler(Event.ON_AFTER_REGISTER, event_handler)
user_router.add_event_handler(Event.ON_AFTER_FORGOT_PASSWORD, event_handler)
user_router.add_event_handler(Event.ON_AFTER_UPDATE, event_handler)
app = FastAPI()
app.include_router(user_router)
return await get_test_client(app)
@pytest.mark.router
@pytest.mark.asyncio
class TestRegister:
async def test_empty_body(self, test_app_client: httpx.AsyncClient, event_handler):
response = await test_app_client.post("/register", json={})
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert event_handler.called is False
async def test_missing_password(
self, test_app_client: httpx.AsyncClient, event_handler
):
json = {"email": "king.arthur@camelot.bt"}
response = await test_app_client.post("/register", json=json)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert event_handler.called is False
async def test_wrong_email(self, test_app_client: httpx.AsyncClient, event_handler):
json = {"email": "king.arthur", "password": "guinevere"}
response = await test_app_client.post("/register", json=json)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert event_handler.called is False
async def test_existing_user(
self, test_app_client: httpx.AsyncClient, event_handler
):
json = {"email": "king.arthur@camelot.bt", "password": "guinevere"}
response = await test_app_client.post("/register", json=json)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.REGISTER_USER_ALREADY_EXISTS
assert event_handler.called is False
async def test_valid_body(self, test_app_client: httpx.AsyncClient, event_handler):
json = {"email": "lancelot@camelot.bt", "password": "guinevere"}
response = await test_app_client.post("/register", json=json)
assert response.status_code == status.HTTP_201_CREATED
assert event_handler.called is True
data = cast(Dict[str, Any], response.json())
assert "hashed_password" not in data
assert "password" not in data
assert data["id"] is not None
actual_user = event_handler.call_args[0][0]
assert str(actual_user.id) == data["id"]
request = event_handler.call_args[0][1]
assert isinstance(request, Request)
async def test_valid_body_is_superuser(
self, test_app_client: httpx.AsyncClient, event_handler
):
json = {
"email": "lancelot@camelot.bt",
"password": "guinevere",
"is_superuser": True,
}
response = await test_app_client.post("/register", json=json)
assert response.status_code == status.HTTP_201_CREATED
assert event_handler.called is True
data = cast(Dict[str, Any], response.json())
assert data["is_superuser"] is False
async def test_valid_body_is_active(
self, test_app_client: httpx.AsyncClient, event_handler
):
json = {
"email": "lancelot@camelot.bt",
"password": "guinevere",
"is_active": False,
}
response = await test_app_client.post("/register", json=json)
assert response.status_code == status.HTTP_201_CREATED
assert event_handler.called is True
data = cast(Dict[str, Any], response.json())
assert data["is_active"] is True
@pytest.mark.router
@pytest.mark.parametrize("path", ["/login/mock", "/login/mock-bis"])
@pytest.mark.asyncio
class TestLogin:
async def test_empty_body(self, path, test_app_client: httpx.AsyncClient):
response = await test_app_client.post(path, data={})
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_missing_username(self, path, test_app_client: httpx.AsyncClient):
data = {"password": "guinevere"}
response = await test_app_client.post(path, data=data)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_missing_password(self, path, test_app_client: httpx.AsyncClient):
data = {"username": "king.arthur@camelot.bt"}
response = await test_app_client.post(path, data=data)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_not_existing_user(self, path, test_app_client: httpx.AsyncClient):
data = {"username": "lancelot@camelot.bt", "password": "guinevere"}
response = await test_app_client.post(path, data=data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.LOGIN_BAD_CREDENTIALS
async def test_wrong_password(self, path, test_app_client: httpx.AsyncClient):
data = {"username": "king.arthur@camelot.bt", "password": "percival"}
response = await test_app_client.post(path, data=data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.LOGIN_BAD_CREDENTIALS
async def test_valid_credentials(
self, path, test_app_client: httpx.AsyncClient, user: UserDB
):
data = {"username": "king.arthur@camelot.bt", "password": "guinevere"}
response = await test_app_client.post(path, data=data)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"token": str(user.id)}
async def test_inactive_user(self, path, test_app_client: httpx.AsyncClient):
data = {"username": "percival@camelot.bt", "password": "angharad"}
response = await test_app_client.post(path, data=data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.LOGIN_BAD_CREDENTIALS
@pytest.mark.router
@pytest.mark.parametrize("path", ["/logout/mock", "/logout/mock-bis"])
@pytest.mark.asyncio
class TestLogout:
async def test_missing_token(self, path, test_app_client: httpx.AsyncClient):
response = await test_app_client.post(path)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
async def test_unimplemented_logout(
self, mocker, path, test_app_client: httpx.AsyncClient, user: UserDB
):
get_logout_response_spy = mocker.spy(MockAuthentication, "get_logout_response")
response = await test_app_client.post(
path, headers={"Authorization": f"Bearer {user.id}"}
)
assert response.status_code == status.HTTP_202_ACCEPTED
get_logout_response_spy.assert_called_once()
@pytest.mark.router
@pytest.mark.asyncio
class TestForgotPassword:
async def test_empty_body(self, test_app_client: httpx.AsyncClient, event_handler):
response = await test_app_client.post("/forgot-password", json={})
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert event_handler.called is False
async def test_not_existing_user(
self, test_app_client: httpx.AsyncClient, event_handler
):
json = {"email": "lancelot@camelot.bt"}
response = await test_app_client.post("/forgot-password", json=json)
assert response.status_code == status.HTTP_202_ACCEPTED
assert event_handler.called is False
async def test_inactive_user(
self, test_app_client: httpx.AsyncClient, event_handler
):
json = {"email": "percival@camelot.bt"}
response = await test_app_client.post("/forgot-password", json=json)
assert response.status_code == status.HTTP_202_ACCEPTED
assert event_handler.called is False
async def test_existing_user(
self, test_app_client: httpx.AsyncClient, event_handler, user
):
json = {"email": "king.arthur@camelot.bt"}
response = await test_app_client.post("/forgot-password", json=json)
assert response.status_code == status.HTTP_202_ACCEPTED
assert event_handler.called is True
actual_user = event_handler.call_args[0][0]
assert actual_user.id == user.id
actual_token = event_handler.call_args[0][1]
decoded_token = jwt.decode(
actual_token,
SECRET,
audience="fastapi-users:reset",
algorithms=[JWT_ALGORITHM],
)
assert decoded_token["user_id"] == str(user.id)
request = event_handler.call_args[0][2]
assert isinstance(request, Request)
@pytest.mark.router
@pytest.mark.asyncio
class TestResetPassword:
async def test_empty_body(self, test_app_client: httpx.AsyncClient):
response = await test_app_client.post("/reset-password", json={})
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_missing_token(self, test_app_client: httpx.AsyncClient):
json = {"password": "guinevere"}
response = await test_app_client.post("/reset-password", json=json)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_missing_password(self, test_app_client: httpx.AsyncClient):
json = {"token": "foo"}
response = await test_app_client.post("/reset-password", json=json)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_invalid_token(self, test_app_client: httpx.AsyncClient):
json = {"token": "foo", "password": "guinevere"}
response = await test_app_client.post("/reset-password", json=json)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.RESET_PASSWORD_BAD_TOKEN
async def test_valid_token_missing_user_id_payload(
self,
mocker,
mock_user_db,
test_app_client: httpx.AsyncClient,
forgot_password_token,
):
mocker.spy(mock_user_db, "update")
json = {"token": forgot_password_token(), "password": "holygrail"}
response = await test_app_client.post("/reset-password", json=json)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.RESET_PASSWORD_BAD_TOKEN
assert mock_user_db.update.called is False
async def test_valid_token_invalid_uuid(
self,
mocker,
mock_user_db,
test_app_client: httpx.AsyncClient,
forgot_password_token,
):
mocker.spy(mock_user_db, "update")
json = {"token": forgot_password_token("foo"), "password": "holygrail"}
response = await test_app_client.post("/reset-password", json=json)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.RESET_PASSWORD_BAD_TOKEN
assert mock_user_db.update.called is False
async def test_inactive_user(
self,
mocker,
mock_user_db,
test_app_client: httpx.AsyncClient,
forgot_password_token,
inactive_user: UserDB,
):
mocker.spy(mock_user_db, "update")
json = {
"token": forgot_password_token(inactive_user.id),
"password": "holygrail",
}
response = await test_app_client.post("/reset-password", json=json)
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.RESET_PASSWORD_BAD_TOKEN
assert mock_user_db.update.called is False
async def test_existing_user(
self,
mocker,
mock_user_db,
test_app_client: httpx.AsyncClient,
forgot_password_token,
user: UserDB,
):
mocker.spy(mock_user_db, "update")
current_hashed_passord = user.hashed_password
json = {"token": forgot_password_token(user.id), "password": "holygrail"}
response = await test_app_client.post("/reset-password", json=json)
assert response.status_code == status.HTTP_200_OK
assert mock_user_db.update.called is True
updated_user = mock_user_db.update.call_args[0][0]
assert updated_user.hashed_password != current_hashed_passord
@pytest.mark.router
@pytest.mark.asyncio
class TestMe:
@@ -392,23 +77,23 @@ class TestMe:
@pytest.mark.asyncio
class TestUpdateMe:
async def test_missing_token(
self, test_app_client: httpx.AsyncClient, event_handler
self, test_app_client: httpx.AsyncClient, after_update
):
response = await test_app_client.patch("/me")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert event_handler.called is False
assert after_update.called is False
async def test_inactive_user(
self, test_app_client: httpx.AsyncClient, inactive_user: UserDB, event_handler
self, test_app_client: httpx.AsyncClient, inactive_user: UserDB, after_update
):
response = await test_app_client.patch(
"/me", headers={"Authorization": f"Bearer {inactive_user.id}"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert event_handler.called is False
assert after_update.called is False
async def test_empty_body(
self, test_app_client: httpx.AsyncClient, user: UserDB, event_handler
self, test_app_client: httpx.AsyncClient, user: UserDB, after_update
):
response = await test_app_client.patch(
"/me", json={}, headers={"Authorization": f"Bearer {user.id}"}
@@ -418,16 +103,16 @@ class TestUpdateMe:
data = cast(Dict[str, Any], response.json())
assert data["email"] == user.email
assert event_handler.called is True
actual_user = event_handler.call_args[0][0]
assert after_update.called is True
actual_user = after_update.call_args[0][0]
assert actual_user.id == user.id
updated_fields = event_handler.call_args[0][1]
updated_fields = after_update.call_args[0][1]
assert updated_fields == {}
request = event_handler.call_args[0][2]
request = after_update.call_args[0][2]
assert isinstance(request, Request)
async def test_valid_body(
self, test_app_client: httpx.AsyncClient, user: UserDB, event_handler
self, test_app_client: httpx.AsyncClient, user: UserDB, after_update
):
json = {"email": "king.arthur@tintagel.bt"}
response = await test_app_client.patch(
@@ -438,16 +123,16 @@ class TestUpdateMe:
data = cast(Dict[str, Any], response.json())
assert data["email"] == "king.arthur@tintagel.bt"
assert event_handler.called is True
actual_user = event_handler.call_args[0][0]
assert after_update.called is True
actual_user = after_update.call_args[0][0]
assert actual_user.id == user.id
updated_fields = event_handler.call_args[0][1]
updated_fields = after_update.call_args[0][1]
assert updated_fields == {"email": "king.arthur@tintagel.bt"}
request = event_handler.call_args[0][2]
request = after_update.call_args[0][2]
assert isinstance(request, Request)
async def test_valid_body_is_superuser(
self, test_app_client: httpx.AsyncClient, user: UserDB, event_handler
self, test_app_client: httpx.AsyncClient, user: UserDB, after_update
):
json = {"is_superuser": True}
response = await test_app_client.patch(
@@ -458,16 +143,16 @@ class TestUpdateMe:
data = cast(Dict[str, Any], response.json())
assert data["is_superuser"] is False
assert event_handler.called is True
actual_user = event_handler.call_args[0][0]
assert after_update.called is True
actual_user = after_update.call_args[0][0]
assert actual_user.id == user.id
updated_fields = event_handler.call_args[0][1]
updated_fields = after_update.call_args[0][1]
assert updated_fields == {}
request = event_handler.call_args[0][2]
request = after_update.call_args[0][2]
assert isinstance(request, Request)
async def test_valid_body_is_active(
self, test_app_client: httpx.AsyncClient, user: UserDB, event_handler
self, test_app_client: httpx.AsyncClient, user: UserDB, after_update
):
json = {"is_active": False}
response = await test_app_client.patch(
@@ -478,12 +163,12 @@ class TestUpdateMe:
data = cast(Dict[str, Any], response.json())
assert data["is_active"] is True
assert event_handler.called is True
actual_user = event_handler.call_args[0][0]
assert after_update.called is True
actual_user = after_update.call_args[0][0]
assert actual_user.id == user.id
updated_fields = event_handler.call_args[0][1]
updated_fields = after_update.call_args[0][1]
assert updated_fields == {}
request = event_handler.call_args[0][2]
request = after_update.call_args[0][2]
assert isinstance(request, Request)
async def test_valid_body_password(
@@ -492,7 +177,7 @@ class TestUpdateMe:
mock_user_db,
test_app_client: httpx.AsyncClient,
user: UserDB,
event_handler,
after_update,
):
mocker.spy(mock_user_db, "update")
current_hashed_passord = user.hashed_password
@@ -507,12 +192,12 @@ class TestUpdateMe:
updated_user = mock_user_db.update.call_args[0][0]
assert updated_user.hashed_password != current_hashed_passord
assert event_handler.called is True
actual_user = event_handler.call_args[0][0]
assert after_update.called is True
actual_user = after_update.call_args[0][0]
assert actual_user.id == user.id
updated_fields = event_handler.call_args[0][1]
updated_fields = after_update.call_args[0][1]
assert updated_fields == {"password": "merlin"}
request = event_handler.call_args[0][2]
request = after_update.call_args[0][2]
assert isinstance(request, Request)