diff --git a/Makefile b/Makefile index f0da842d..b34dc770 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,12 @@ PIPENV_RUN := pipenv run -format: - $(PIPENV_RUN) isort -rc . +isort-src: + $(PIPENV_RUN) isort -rc ./fastapi_users + +isort-docs: + $(PIPENV_RUN) isort -rc ./docs/src -o fastapi_users + +format: isort-src isort-docs $(PIPENV_RUN) black . test: diff --git a/Pipfile b/Pipfile index 42a5103c..f63250e6 100644 --- a/Pipfile +++ b/Pipfile @@ -20,6 +20,9 @@ pytest-cov = "*" pytest-mock = "*" asynctest = "*" flit = "*" +markdown-include = "*" +pygments = "*" +pymdown-extensions = "*" [packages] fastapi = "==0.42.0" diff --git a/Pipfile.lock b/Pipfile.lock index 92e2c357..c1a3b6b0 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "93805b5493ec5fc7944440e833ad352af9f780705817990ef6bb96230aff601a" + "sha256": "1985abdc79db7e7cbe29782026b32ae0099107ed8ac4ec22687848f14c04a512" }, "pipfile-spec": 6, "requires": { @@ -379,6 +379,13 @@ ], "version": "==3.1.1" }, + "markdown-include": { + "hashes": [ + "sha256:72a45461b589489a088753893bc95c5fa5909936186485f4ed55caa57d10250f" + ], + "index": "pypi", + "version": "==0.5.1" + }, "markupsafe": { "hashes": [ "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", @@ -471,9 +478,10 @@ }, "mypy-extensions": { "hashes": [ - "sha256:a161e3b917053de87dbe469987e173e49fb454eca10ef28b48b384538cc11458" + "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", + "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" ], - "version": "==0.4.2" + "version": "==0.4.3" }, "packaging": { "hashes": [ @@ -529,6 +537,7 @@ "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297" ], + "index": "pypi", "version": "==2.4.2" }, "pymdown-extensions": { @@ -536,6 +545,7 @@ "sha256:24c1a0afbae101c4e2b2675ff4dd936470a90133f93398b9cbe0c855e2d2ec10", "sha256:960486dea995f1759dfd517aa140b3d851cd7b44d4c48d276fd2c74fc4e1bce9" ], + "index": "pypi", "version": "==6.1" }, "pyparsing": { diff --git a/README.md b/README.md index 7c2e55f4..30b6435e 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ This library is currently in early stage development. First working version soon ## Features * Extensible base user model -* Ready-to-use register, login, *forgot and reset password routes ([#3](https://github.com/frankie567/fastapi-users/issues/3))* +* Ready-to-use register, login, forgot and reset password routes. * Customizable database backend * SQLAlchemy backend included * *MongoDB backend included ([#4](https://github.com/frankie567/fastapi-users/issues/4))* diff --git a/docs/configuration/_next_router.md b/docs/configuration/_next_router.md new file mode 100644 index 00000000..b647b5e3 --- /dev/null +++ b/docs/configuration/_next_router.md @@ -0,0 +1,3 @@ +## Next steps + +We will now configure the main **FastAPI Users** object that will expose the [API router](/configuration/router). diff --git a/docs/configuration/authentication/_next_authentication.md b/docs/configuration/authentication/_next_authentication.md new file mode 100644 index 00000000..1643e22f --- /dev/null +++ b/docs/configuration/authentication/_next_authentication.md @@ -0,0 +1,3 @@ +## Next steps + +We will now configure an [authentication method](/configuration/authentication). diff --git a/docs/configuration/authentication/index.md b/docs/configuration/authentication/index.md new file mode 100644 index 00000000..48af84f0 --- /dev/null +++ b/docs/configuration/authentication/index.md @@ -0,0 +1,7 @@ +# Authentication + +**FastAPI Users** allows you to plug in several authentication methods. + +## Provided methods + +* [JWT authentication](jwt.md) diff --git a/docs/configuration/authentication/jwt.md b/docs/configuration/authentication/jwt.md new file mode 100644 index 00000000..a9a417d2 --- /dev/null +++ b/docs/configuration/authentication/jwt.md @@ -0,0 +1,25 @@ +# JWT + +JSON Web Token (JWT) is an internet standard for creating access tokens based on JSON. + +## Configuration + +```py +from fastapi_users.authentication import JWTAuthentication + +SECRET = "SECRET" + +auth = JWTAuthentication(secret=SECRET, lifetime_seconds=3600) +``` + +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). + +## Authentication + +This method expects that you provide a `Bearer` authentication with a valid JWT. + +```bash +curl http://localhost:9000/protected-route -H'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiOTIyMWZmYzktNjQwZi00MzcyLTg2ZDMtY2U2NDJjYmE1NjAzIiwiYXVkIjoiZmFzdGFwaS11c2VyczphdXRoIiwiZXhwIjoxNTcxNTA0MTkzfQ.M10bjOe45I5Ncu_uXvOmVV8QxnL-nZfcH96U90JaocI' +``` + +{!./configuration/_next_router.md!} diff --git a/docs/configuration/databases/mongodb.md b/docs/configuration/databases/mongodb.md new file mode 100644 index 00000000..90fd221d --- /dev/null +++ b/docs/configuration/databases/mongodb.md @@ -0,0 +1,5 @@ +# MongoDB + +**Coming soon**. Track the progress of this feature in [ticket #4](https://github.com/frankie567/fastapi-users/issues/4). + +{!./configuration/authentication/_next_authentication.md!} diff --git a/docs/configuration/databases/sqlalchemy.md b/docs/configuration/databases/sqlalchemy.md new file mode 100644 index 00000000..ff7aad85 --- /dev/null +++ b/docs/configuration/databases/sqlalchemy.md @@ -0,0 +1,58 @@ +# SQLAlchemy + +**FastAPI Users** provides the necessary tools to work with SQL databases thanks to [SQLAlchemy Core](https://docs.sqlalchemy.org/en/13/core/) and [encode/databases](https://www.encode.io/databases/) package for full async support. + +## Installation + +Install the database driver that corresponds to your DBMS: + +```sh +pip install databases[postgresql] +``` + +```sh +pip install databases[mysql] +``` + +```sh +pip install databases[sqlite] +``` + +For the sake of this tutorial from now on, we'll use a simple SQLite databse. + +## Setup User table + +Let's create a `metadata` object and declare our User table. + +```py hl_lines="4 14 15" +{!./src/sqlalchemy.py!} +``` + +As you can see, **FastAPI Users** provides a mixin that will include base fields for our User table. You can of course add you own fields there to fit to your needs! + +## Create the tables + +We'll now create an SQLAlchemy enigne and ask it to create all the defined tables. + +```py hl_lines="18 19 20 21 22" +{!./src/sqlalchemy.py!} +``` + +!!!tip + In production, you would probably want to create the tables with Alembic, integrated with migrations, etc. + +## Create the database adapter + +The database adapter of **FastAPI Users** makes the link between your database configuration and the users logic. Create it like this. + +```py hl_lines="24 25" +{!./src/sqlalchemy.py!} +``` + +Notice that we declare the `users` variable, which is the actual SQLAlchemy table behind the table class. We also use our `database` instance, which allows us to do asynchronous request to the database. + +{!./configuration/authentication/_next_authentication.md!} + +## What about SQLAlchemy ORM? + +The primary objective was to use pure async approach as much as possible. However, we understand that ORM is convenient and useful for many developers. If this feature becomes very demanded, we will add a database adapter for SQLAlchemy ORM. diff --git a/docs/configuration/full_example.md b/docs/configuration/full_example.md new file mode 100644 index 00000000..f39074f7 --- /dev/null +++ b/docs/configuration/full_example.md @@ -0,0 +1,15 @@ +# Full example + +Here is a full working example with JWT authentication to help get you started. + +``` py tab="SQLAlchemy" +{!./src/full_sqlalchemy.py!} +``` + +```py tab="MongoDB" +# Coming soon +``` + +## What now? + +You're ready to go! Be sure to check the [Usage](../usage/routes.md) section to understand how yo work with **FastAPI Users**. diff --git a/docs/configuration/model.md b/docs/configuration/model.md new file mode 100644 index 00000000..22af146d --- /dev/null +++ b/docs/configuration/model.md @@ -0,0 +1,30 @@ +# User model + +**FastAPI Users** defines a minimal User model for authentication purposes. It is structured like this: + +* `id` (`str`) – Unique identifier of the user. Default to a **UUID4**. +* `email` (`str`) – Email of the user. Validated by [`email-validator`](https://github.com/JoshData/python-email-validator). +* `is_active` (`bool`) – Whether or not the user is active. If not, login and forgot password requests will be denied. Default to `True`. +* `is_active` (`bool`) – Whether or not the user is a superuser. Useful to implement administration logic. Default to `False`. + +## Use the model + +The model is exposed as a Pydantic model mixin. + +```py +from fastapi_users import BaseUser + + +class User(BaseUser): + pass +``` + +You can of course add you own properties there to fit to your needs! + +## Next steps + +Depending on your database backend, database configuration will differ a bit. + +[I'm using SQLAlchemy](databases/sqlalchemy.md) + +[I'm using MongoDB](databases/mongodb.md) diff --git a/docs/configuration/router.md b/docs/configuration/router.md new file mode 100644 index 00000000..6c271c6d --- /dev/null +++ b/docs/configuration/router.md @@ -0,0 +1,56 @@ +# 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 model to expose the FastAPI router. + +## Hooks + +In order to be as unopinionated as possible, you'll have to define your logic after some actions. + +### After forgot password + +This hook is called after a successful forgot password request. It is called with **two arguments**: the **user** which has requested to reset their password and a ready-to-use **JWT token** that will be accepted by the reset password route. + +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, token): + print(f'User {user.id} has forgot their password. Reset token: {token}') +``` + +## Configure `FastAPIUsers` + +The last step is to instantiate `FastAPIUsers` object with all the elements we defined before. More precisely: + +* `db`: Database adapter instance. +* `auth`: Authentication logic instance. +* `user_model`: Pydantic model of a user. +* `on_after_forgot_password`: Hook called after a forgot password request. +* `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, + User, + on_after_forgot_password, + SECRET, +) +``` + +And then, include the router in the FastAPI app: + +```py +app = FastAPI() +app.include_router(fastapi_users.router, prefix="/users", tags=["users"]) +``` + +## Next steps + +Check out a [full example](full_example.md) that will show you the big picture. diff --git a/docs/favicon.png b/docs/favicon.png new file mode 100644 index 00000000..29ac488c Binary files /dev/null and b/docs/favicon.png differ diff --git a/docs/index.md b/docs/index.md index 3d0f42c0..18b07f4b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@

- Ready-to-use and customizable users management for FastAPI + Ready-to-use and customizable users management for FastAPI

--- @@ -16,14 +16,12 @@ --- -## Work in progress 🚧 - -This library is currently in early stage development. First working version soon! +Add quickly a registration and authentication system to your [FastAPI](https://fastapi.tiangolo.com/) project. **FastAPI Users** is designed to be as customizable and adaptable as possible. ## Features * Extensible base user model -* Ready-to-use register, login, *forgot and reset password routes ([#3](https://github.com/frankie567/fastapi-users/issues/3))* +* Ready-to-use register, login, forgot and reset password routes. * Customizable database backend * SQLAlchemy backend included * *MongoDB backend included ([#4](https://github.com/frankie567/fastapi-users/issues/4))* diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 00000000..f4bbdb6c --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,17 @@ +# Installation + +You can add **FastAPI Users** to your FastAPI project in a few easy steps. First of all, install the dependency: + +```sh +pip install fastapi-users +``` + +...or if you're already in the future: + +```sh +pipenv install fastapi-users +``` + +--- + +That's it! Now, let's have a look at our [User model](./configuration/model.md). diff --git a/docs/src/full_sqlalchemy.py b/docs/src/full_sqlalchemy.py new file mode 100644 index 00000000..9e8a62bc --- /dev/null +++ b/docs/src/full_sqlalchemy.py @@ -0,0 +1,55 @@ +import databases +import sqlalchemy +from fastapi import FastAPI +from fastapi_users import BaseUser, FastAPIUsers +from fastapi_users.authentication import JWTAuthentication +from fastapi_users.db import SQLAlchemyBaseUserTable, SQLAlchemyUserDatabase +from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base + +DATABASE_URL = "sqlite:///./test.db" +SECRET = "SECRET" + + +database = databases.Database(DATABASE_URL) + +Base: DeclarativeMeta = declarative_base() + + +class UserTable(Base, SQLAlchemyBaseUserTable): + pass + + +engine = sqlalchemy.create_engine( + DATABASE_URL, connect_args={"check_same_thread": False} +) + +Base.metadata.create_all(engine) + +users = UserTable.__table__ +user_db = SQLAlchemyUserDatabase(database, users) + + +class User(BaseUser): + pass + + +auth = JWTAuthentication(secret=SECRET, lifetime_seconds=3600) + + +def on_after_forgot_password(user, token): + print(f"User {user.id} has forgot their password. Reset token: {token}") + + +app = FastAPI() +fastapi_users = FastAPIUsers(user_db, auth, User, on_after_forgot_password, SECRET) +app.include_router(fastapi_users.router, prefix="/users", tags=["users"]) + + +@app.on_event("startup") +async def startup(): + await database.connect() + + +@app.on_event("shutdown") +async def shutdown(): + await database.disconnect() diff --git a/docs/src/sqlalchemy.py b/docs/src/sqlalchemy.py new file mode 100644 index 00000000..e9204585 --- /dev/null +++ b/docs/src/sqlalchemy.py @@ -0,0 +1,37 @@ +import databases +import sqlalchemy +from fastapi import FastAPI +from fastapi_users.db import SQLAlchemyBaseUserTable, SQLAlchemyUserDatabase +from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base + +DATABASE_URL = "sqlite:///./test.db" + +database = databases.Database(DATABASE_URL) + +Base: DeclarativeMeta = declarative_base() + + +class UserTable(Base, SQLAlchemyBaseUserTable): + pass + + +engine = sqlalchemy.create_engine( + DATABASE_URL, connect_args={"check_same_thread": False} +) + +Base.metadata.create_all(engine) + +users = UserTable.__table__ +user_db = SQLAlchemyUserDatabase(database, users) + +app = FastAPI() + + +@app.on_event("startup") +async def startup(): + await database.connect() + + +@app.on_event("shutdown") +async def shutdown(): + await database.disconnect() diff --git a/docs/usage/dependency-callables.md b/docs/usage/dependency-callables.md new file mode 100644 index 00000000..edcc7466 --- /dev/null +++ b/docs/usage/dependency-callables.md @@ -0,0 +1,36 @@ +# Dependency callables + +**FastAPI Users** provides dependency callables to easily inject users in your routes. They are available from your `FastAPIUsers` instance. + +!!! tip + For more information about how to make an authenticated request to your API, check the documentation of your [Authentication method](../configuration/authentication/index.md). + +## `get_current_user` + +Get the current user (**active or not**). Will throw a `401 Unauthorized` if missing or wrong credentials. + +```py +@app.get('/protected-route') +def protected_route(user: User = Depends(fastapi_users.get_current_user)): + return f'Hello, {user.email}' +``` + +## `get_current_active_user` + +Get the current active user. Will throw a `401 Unauthorized` if missing or wrong credentials or if the user is not active. + +```py +@app.get('/protected-route') +def protected_route(user: User = Depends(fastapi_users.get_current_active_user)): + return f'Hello, {user.email}' +``` + +## `get_current_superuser` + +Get the current superuser. Will throw a `401 Unauthorized` if missing or wrong credentials or if the user is not active. Will throw a `403 Forbidden` if the user is not a superuser. + +```py +@app.get('/protected-route') +def protected_route(user: User = Depends(fastapi_users.get_current_superuser)): + return f'Hello, {user.email}' +``` diff --git a/docs/usage/routes.md b/docs/usage/routes.md new file mode 100644 index 00000000..81ef3e06 --- /dev/null +++ b/docs/usage/routes.md @@ -0,0 +1,82 @@ +# Routes + +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). + +## `POST /register` + +Register a new user. + +!!! abstract "Payload" + ```json + { + "email": "king.arthur@camelot.bt", + "password": "guinevere" + } + ``` + +!!! success "`200 OK`" + ```json + { + "id": "57cbb51a-ab71-4009-8802-3f54b4f2e23", + "email": "king.arthur@camelot.bt", + "is_active": true, + "is_superuser": false + } + ``` + +!!! fail "`422 Validation Error`" + +## `POST /login` + +Login a user. + +!!! abstract "Payload (`application/x-www-form-urlencoded`)" + ``` + username=king.arthur@camelot.bt&password=guinevere + ``` + +!!! success "`200 OK`" + ```json + { + "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiOTIyMWZmYzktNjQwZi00MzcyLTg2ZDMtY2U2NDJjYmE1NjAzIiwiYXVkIjoiZmFzdGFwaS11c2VyczphdXRoIiwiZXhwIjoxNTcxNTA0MTkzfQ.M10bjOe45I5Ncu_uXvOmVV8QxnL-nZfcH96U90JaocI" + } + ``` + +!!! fail "`422 Validation Error`" + +!!! fail "`400 Bad Request`" + Bad credentials or the user is inactive. + +## `POST /forgot-password` + +Request a reset password procedure. Will generate a temporary token and call the defined `on_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. + +!!! abstract "Payload" + ```json + { + "email": "king.arthur@camelot.bt" + } + ``` + +!!! success "`202 Accepted`" + +## `POST /reset-password` + +Reset a password. Requires the token generated by the `/forgot-password` route. + +!!! abstract "Payload" + ```json + { + "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiOTIyMWZmYzktNjQwZi00MzcyLTg2ZDMtY2U2NDJjYmE1NjAzIiwiYXVkIjoiZmFzdGFwaS11c2VyczphdXRoIiwiZXhwIjoxNTcxNTA0MTkzfQ.M10bjOe45I5Ncu_uXvOmVV8QxnL-nZfcH96U90JaocI", + "password": "merlin" + } + ``` + +!!! success "`200 Accepted`" + +!!! fail "`422 Validation Error`" + +!!! fail "`400 Bad Request`" + Bad or expired token. diff --git a/fastapi_users/__init__.py b/fastapi_users/__init__.py index 389592f2..b24a8c92 100644 --- a/fastapi_users/__init__.py +++ b/fastapi_users/__init__.py @@ -1,6 +1,6 @@ """Ready-to-use and customizable users management for FastAPI.""" -__version__ = '0.0.2' +__version__ = "0.0.2" from fastapi_users.fastapi_users import FastAPIUsers # noqa: F401 from fastapi_users.models import BaseUser # noqa: F401 diff --git a/mkdocs.yml b/mkdocs.yml index 6675b64f..f01c4a32 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,7 +8,34 @@ theme: accent: 'red' logo: icon: 'supervisor_account' + favicon: 'favicon.png' repo_name: frankie567/fastapi-users repo_url: https://github.com/frankie567/fastapi-users edit_uri: "" + +markdown_extensions: + - markdown_include.include: + base_path: docs + - toc: + permalink: true + - admonition + - codehilite + - pymdownx.superfences + +nav: + - index.md + - installation.md + - Configuration: + - configuration/model.md + - Databases: + - configuration/databases/sqlalchemy.md + - configuration/databases/mongodb.md + - Authentication: + - configuration/authentication/index.md + - configuration/authentication/jwt.md + - configuration/router.md + - configuration/full_example.md + - Usage: + - usage/routes.md + - usage/dependency-callables.md