#244: Integrate FastAPI Users Into the To-Do Application
A few weeks ago, we explored two approaches to add authentication to our FastAPI applications. In this post we go a different way and integrate a package that does the heavy lifting for us. I decided to go with FastAPI Users, even when it takes a big step to integrate it.
Installation
We can install FastAPI Users for SQLAlchemy and OAuth with this command:
FastAPI Users comes with everything we need, and one of the most obvious things is a table for our users. We can put these few lines to our data/entities.py file to get a basic user table:
If you want to add more fields, you can do that here. For the first round I suggest you start with the built-in parts and only add as soon as you have a working solution.
Create a migration file
With our new table in the entities.py file, we can create a migration file for Alembic with this command:
We need to use the same database file for the FastAPI Users configuration as we do for our data store. But as it stands, we only have a method to get the datastore. We can fix this with a small refactoring:
defdb_file():db_file=os.path.join(os.path.dirname(__file__),'.','db','todo_api.sqlite')print(f"DB file is: {db_file}")returndb_fileasyncdefget_db(db_file=Depends(db_file)):""" Creates the datastore """factory=awaitcreate_async_session_factory(db_file)db=DataStoreDb(factory)yielddb
We can run all our existing tests and they should pass.
Add an authentication.py file
I put all the authentication related code into the authentication.py file. There is a lot going on to glue this plug-in into our application. The main points you should know about are these:
The UserManager class contains all the logic to access the database, works with our User table and uses a GUID as the identifier.
The methods get_async_session() and get_user_db() glue our existing infrastructure together to finally use the get_user_manager() method to initialise the UserManager class.
importosfromtypingimportOptionalimportuuidfromfastapiimportDepends,Requestfromfastapi_usersimportBaseUserManager,FastAPIUsers,UUIDIDMixinfromfastapi_users.authenticationimport(AuthenticationBackend,BearerTransport,JWTStrategy,)fromfastapi_users_db_sqlalchemyimportSQLAlchemyUserDatabasefromsqlalchemy.ext.asyncioimportAsyncSessionfrom.data.databaseimportcreate_async_session_factoryfrom.data.entitiesimportUserfrom.dependenciesimportdb_filefromdotenvimportload_dotenvload_dotenv()SECRET=os.getenv('SECRET_KEY_ENV')classUserManager(UUIDIDMixin,BaseUserManager[User,uuid.UUID]):reset_password_token_secret=SECRETverification_token_secret=SECRETasyncdefon_after_register(self,user:User,request:Optional[Request]=None):print(f"User {user.id} has registered.")asyncdefon_after_forgot_password(self,user:User,token:str,request:Optional[Request]=None):print(f"User {user.id} has forgot their password. Reset token: {token}")asyncdefon_after_request_verify(self,user:User,token:str,request:Optional[Request]=None):print(f"Verification requested for user {user.id}. Verification token: {token}")asyncdefget_async_session(db_file=Depends(db_file)):factory=awaitcreate_async_session_factory(db_file)asyncwithfactory()assession:yieldsessionasyncdefget_user_db(session:AsyncSession=Depends(get_async_session)):yieldSQLAlchemyUserDatabase(session,User)asyncdefget_user_manager(user_db:SQLAlchemyUserDatabase=Depends(get_user_db)):yieldUserManager(user_db)bearer_transport=BearerTransport(tokenUrl="auth/jwt/login")defget_jwt_strategy()->JWTStrategy:returnJWTStrategy(secret=SECRET,lifetime_seconds=3600)auth_backend=AuthenticationBackend(name="jwt",transport=bearer_transport,get_strategy=get_jwt_strategy,)fastapi_users=FastAPIUsers[User,uuid.UUID](get_user_manager,[auth_backend])current_active_user=fastapi_users.current_user(active=True)
Prepare the endpoint tests
So far, we added all the new parts, but we did not integrate them in our application. Before we put everything together, we need to change our tests. If we are not careful, we can mess up our tests and create race conditions between our test files. To prevent that, we need to create a new fixture that we use for our existing endpoint tests where we assume we have a logged-in user:
@pytest.mark.asyncioasyncdeftest_create_task(test_client):...response=test_client.post("/api/todo/",json=data)@pytest.mark.asyncioasyncdefprepare_task(client,name,priority=4,due_date=None,done=False):...@pytest.mark.asyncioasyncdeftest_show_task(test_client):...id=awaitprepare_task(test_client,name)response=test_client.get(f"/api/todo/{id}")...# and so on
We can run all our tests and they should pass. We already mock a method we not yet use, but that will change with this new test:
This new code was the missing part for our failing test. We can now rerun all tests and they should pass.
Protect an endpoint
The authentication middleware only helps us when we use it in our application. We can now enforce that the user is logged-in before they can create a new task, update an existing one or delete it. All we need to do is to inject the current_active_user method from our authentication.py file into the endpoint:
from..authenticationimportcurrent_active_userfrom..data.entitiesimportUser...@router.post("/",status_code=status.HTTP_201_CREATED)asyncdefcreate_task(task:TaskInput,request:Request,db:DataStoreDb=Depends(get_db),user:User=Depends(current_active_user))->TaskOutput:result=awaitdb.add(task)headers={"Location":f"{request.base_url}api/todo/{result.id}"}returnJSONResponse(content=jsonable_encoder(result),status_code=status.HTTP_201_CREATED,headers=headers)@router.put("/{id}")asyncdefupdate_task(id:int,task:TaskInput,db:DataStoreDb=Depends(get_db),user:User=Depends(current_active_user))->TaskOutput:try:result=awaitdb.update(id,task)returnresultexceptValueError:raiseHTTPException(status_code=404,detail="Task not found")@router.delete("/{id}",status_code=status.HTTP_204_NO_CONTENT)asyncdefdelete_task(id:int,db:DataStoreDb=Depends(get_db),user:User=Depends(current_active_user))->None:awaitdb.delete(id)returnResponse(status_code=status.HTTP_204_NO_CONTENT)
We can rerun our tests and they will pass. That is because our mock simulates a logged-in user. If we start the application and try to add a task without logging in, we get a 401 error. Let us add some tests to make sure that the login works.
Add authentication tests
The whole logic to log in comes from FastAPI Users. So far, we have no tests to check if the behaviour works as we expect. We can fix that with this set of tests:
In the test_client we use in this file we are not logged in. Therefore, our attempt to add a task will fail. The same should happen when we call the /about/me endpoint without logging in.
The test test_user_can_register() shows us how we can add a new user. But that will not be enough to work with the API. To see what it takes to log in and use the bearer token, we have the test_user_can_login_and_sees_email_in_about_me() test.
We can now run all the tests and they should pass.
Attention: Uvicorn and .env
If you run the application in Uvicorn, you run it from one directory above the extended_todo folder. If you have the .env file with the SECRET_KEY_ENV variable inside the extended_todo folder, you will get an internal server error with the message "Expected a string value". Good luck with debugging that problem.
The solution to that error is to add the .env file in the parent directory of the extended_todo folder. Then everything works as expected.
Next
Integrating FastAPI Users in an application is not a quick task. As this post shows, there are many parts we need to put together before everything works. But as soon as this is done, we can add OAuth logins like Google or Okta to our application without much additional effort.