Skip to content

Commit 61dcf6d

Browse files
authored
Add OAuth2, JWT auth, and react-admin that uses same auth method (#21)
* add passing test for oauth2 post to token * run black and prettier on files, add true JWT creation method * refactor tests and put in real JWT creation method * add build script and create initial user/password in cookiecutter template * change sqlalchemy logo size * fix type error on react-admin page * add note about security * add API docs image to README and more on security
1 parent 57428d9 commit 61dcf6d

35 files changed

+437
-137
lines changed

README.md

+73-31
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,44 @@
11
# FastAPI + React Template · [![CircleCI](https://circleci.com/gh/Buuntu/fastapi-react.svg?style=shield)](https://circleci.com/gh/Buuntu/fastapi-react) [![license](https://img.shields.io/github/license/peaceiris/actions-gh-pages.svg)](LICENSE) [![Dependabot Status](https://img.shields.io/badge/Dependabot-active-brightgreen.svg)](https://dependabot.com)
22

3+
<div>
34
<img src="assets/fastapi-logo.png" alt="fastapi-logo" height="60" /> <img
45
src="assets/react-logo.png" alt="react-logo" height="60" /> &nbsp; &nbsp; <img
6+
src="assets/react-admin.png" alt="react-admin" height="60" /> &nbsp; &nbsp; <img
57
src="assets/typescript.png" alt="react-logo" height="60" /> &nbsp;&nbsp;&nbsp;
6-
<img src="assets/postgres.png" alt="react-logo" height="60" />
7-
&nbsp;&nbsp;
8-
<img src="assets/sql-alchemy.png" alt="sql-alchemy" height="60" />
8+
<img src="assets/postgres.png" alt="react-logo" height="60" /> <img
9+
src="assets/sql-alchemy.png" alt="sql-alchemy" height="60" />
10+
</div>
911

1012
This project serves as a template for bootstrapping a FastAPI and React project
1113
using a modern stack.
1214

1315
## Features
1416

15-
1. **[FastAPI](https://fastapi.tiangolo.com/)** (Python 3.8)
16-
2. **[React](https://reactjs.org/)** (with Typescript)
17-
3. **[PostgreSQL](https://www.postgresql.org/)** for the database
18-
4. **[SqlAlchemy](https://www.sqlalchemy.org/)** for ORM
19-
5. **[Alembic](https://alembic.sqlalchemy.org/en/latest/)** for database
20-
migrations
21-
6. **[Pytest](https://docs.pytest.org/en/latest/)** for backend tests
22-
7. **[Prettier](https://prettier.io/)**/**[ESLint](https://eslint.org/)**
23-
(Airbnb style guide)
24-
8. **[Docker Compose](https://docs.docker.com/compose/)** for development
25-
9. **[Nginx](https://www.nginx.com/)** as a reverse proxy to allow
26-
backend/frontend on the same port
27-
10. [**MaterialUI**](https://material-ui.com/) for styling
28-
11. [**react-admin**](https://github.com/marmelab/react-admin) for the admin
29-
dashboard
17+
- **[FastAPI](https://fastapi.tiangolo.com/)** (Python 3.8)
18+
- **[React](https://reactjs.org/)** (with Typescript)
19+
- **[PostgreSQL](https://www.postgresql.org/)** for the database
20+
- **[SqlAlchemy](https://www.sqlalchemy.org/)** for ORM
21+
- **[Alembic](https://alembic.sqlalchemy.org/en/latest/)** for database
22+
migrations
23+
- **[Pytest](https://docs.pytest.org/en/latest/)** for backend tests
24+
- Includes test database, client, and user fixtures
25+
- **[Prettier](https://prettier.io/)**/**[ESLint](https://eslint.org/)** (Airbnb
26+
style guide)
27+
- **[Docker Compose](https://docs.docker.com/compose/)** for development
28+
- **[Nginx](https://www.nginx.com/)** as a reverse proxy to allow
29+
backend/frontend on the same port
30+
- **[MaterialUI](https://material-ui.com/)** for styling
31+
- **[react-admin](https://github.com/marmelab/react-admin)** for the admin
32+
dashboard
33+
- Using JWT authentication and login/redirects configured based on status
34+
codes
35+
- **JWT** authentication using OAuth2 and PyJWT
3036

3137
## Background
3238

3339
This project is meant as a lightweight/React alternative to [FastAPI's official
3440
fullstack project](https://github.com/tiangolo/full-stack-fastapi-postgresql).
35-
If you want a fullstack, comprehensive project in Vue, I would suggest you start
41+
If you want a more comprehensive project in Vue, I would suggest you start
3642
there.
3743

3844
Most of the boilerplate backend code is taken from that project or the [FastAPI
@@ -60,6 +66,10 @@ This will ask for the following variables to be set:
6066
- port [default 8000]
6167
- postgres_user [default postgres]
6268
- postgres_password [default password]
69+
- postgres_database [default app]
70+
- initial_user_email [default [email protected]]
71+
- initial_user_password [default password]
72+
- secret_key [default super_secret]
6373

6474
and will create a directory called whatever you set for `project_slug`.
6575

@@ -68,23 +78,35 @@ and will create a directory called whatever you set for `project_slug`.
6878
Change into your project directory and run:
6979

7080
```bash
71-
docker-compose up -d
72-
docker-compose run --rm backend alembic upgrade head
81+
chmod +x scripts/build.sh
82+
./scripts/build.sh
7383
```
7484

75-
This will take a while to build the first time it's run since it needs to fetch
76-
all the docker images.
85+
This will build and run the docker containers, run the alembic migrations, and
86+
load the initial data (a test user).
87+
88+
It may take a while to build the first time it's run since it needs to fetch all
89+
the docker images.
90+
91+
Once you've built the images once, you can simply use regular `docker-compose`
92+
commands to manage your development environment, for example to start your
93+
containers:
94+
95+
```bash
96+
docker-compose up -d
97+
```
7798

7899
Once this finishes you can navigate to the port set during setup (default is
79100
`localhost:8000`), you should see the slightly modified create-react-app page:
80101

81102
![default create-react-app](assets/create-react-app.png)
82103

83-
*Note: If you see an Nginx error at first with a `502: Bad Gateway` page, you
84-
may have to wait for webpack to build the development server (the nginx
85-
container builds much more quickly).*
104+
_Note: If you see an Nginx error at first with a `502: Bad Gateway` page, you
105+
may have to wait for webpack to build the development server (the nginx
106+
container builds much more quickly)._
86107

87-
The backend docs will be at `http://localhost:8000/api/docs`.
108+
The backend docs will be at `http://localhost:8000/api/docs`. ![API
109+
Docs](assets/api-docs.png)
88110

89111
Backend routes will be at `http://localhost:8000/api`.
90112

@@ -93,16 +115,36 @@ Backend routes will be at `http://localhost:8000/api`.
93115
This project uses [react-admin](https://marmelab.com/react-admin/) for a highly
94116
configurable admin dashboard.
95117

96-
After starting the project, navigate to `http://localhost:8000/admin`. You
97-
should see a list of users, which you can edit, add, and delete. These are all
98-
based off of the `users` routes in the backend.
118+
After starting the project, navigate to `http://localhost:8000/admin`. You
119+
should see a login screen. Use the username/password you set for the initial
120+
user on project setup.
121+
122+
![React Adming Login](assets/login-screen.png)
123+
124+
You should now see a list of users which you can edit, add, and delete. The
125+
table is configured with the REST endpoints to the FastAPI `/users` routes in
126+
the backend.
99127

100128
![React Admin Dashboard](assets/admin-dashboard.png)
101129

102130
The admin dashboard is kept in the `frontend/src/admin` directory to keep it
103131
separate from the regular frontend.
104132

133+
## Security
134+
135+
To generate a secure key used for encrypting/decrypting the JSON Web Tokens, you can run this command:
136+
137+
```bash
138+
openssl rand -hex 32
139+
```
140+
141+
The default is fine for development but you will want something more secure for
142+
production.
143+
144+
You can either set this on project setup as `secret_key` or manually edit the
145+
Python `SECRET_KEY` variable in `backend/app/core/security.py`.
146+
105147
## Contributing
106148

107-
Contributing is more than welcome. Please read the [Contributing
149+
Contributing is more than welcome. Please read the [Contributing
108150
doc](CONTRIBUTING.md) to find out more.

assets/admin-dashboard.png

-266 KB
Loading

assets/api-docs.png

127 KB
Loading

assets/create-react-app.png

-37.5 KB
Loading

assets/login-screen.png

436 KB
Loading

assets/react-admin.png

4.21 KB
Loading

assets/sql-alchemy.png

5.52 KB
Loading

cookiecutter.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"port": "8000",
55
"postgres_user": "postgres",
66
"postgres_password": "password",
7-
"postgres_server": "postgres",
8-
"postgres_database": "app"
7+
"postgres_database": "app",
8+
"initial_user_email": "admin@{{cookiecutter.project_name}}.com",
9+
"initial_user_password": "password",
10+
"secret_key": "super_secret"
911
}

scripts/test_local.sh

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
#!/bin/bash
2+
3+
cd "$(dirname "$0")"
4+
cd ..
15
current_dir=`pwd`
26
cd ..
37
./fastapi-react/scripts/dev-project.sh
48
cd dev-fastapi-react
59
docker-compose down -v --remove-orphans
6-
docker-compose up -d
7-
docker-compose run --rm backend alembic upgrade head
10+
./scripts/build.sh
811
./scripts/test.sh
9-
cd $current_dir
12+
# cd $current_dir

{{cookiecutter.project_slug}}/backend/app/alembic/env.py

+4-8
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,13 @@ def run_migrations_online():
5959
and associate a connection with the context.
6060
"""
6161
configuration = config.get_section(config.config_ini_section)
62-
configuration['sqlalchemy.url'] = get_url()
62+
configuration["sqlalchemy.url"] = get_url()
6363
connectable = engine_from_config(
64-
configuration,
65-
prefix="sqlalchemy.",
66-
poolclass=pool.NullPool,
64+
configuration, prefix="sqlalchemy.", poolclass=pool.NullPool,
6765
)
6866

6967
with connectable.connect() as connection:
70-
context.configure(
71-
connection=connection, target_metadata=target_metadata
72-
)
68+
context.configure(connection=connection, target_metadata=target_metadata)
7369

7470
with context.begin_transaction():
7571
context.run_migrations()
@@ -78,4 +74,4 @@ def run_migrations_online():
7874
if context.is_offline_mode():
7975
run_migrations_offline()
8076
else:
81-
run_migrations_online()
77+
run_migrations_online()

{{cookiecutter.project_slug}}/backend/app/alembic/versions/91979b40eb38_create_users_table.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,21 @@
1010

1111

1212
# revision identifiers, used by Alembic.
13-
revision = '91979b40eb38'
13+
revision = "91979b40eb38"
1414
down_revision = None
1515
branch_labels = None
1616
depends_on = None
1717

1818

1919
def upgrade():
2020
op.create_table(
21-
'user',
22-
sa.Column('id', sa.Integer, primary_key=True),
23-
sa.Column('email', sa.String(50), nullable=False),
24-
sa.Column('hashed_password', sa.String(100), nullable=False),
25-
sa.Column('is_active', sa.Boolean),
21+
"user",
22+
sa.Column("id", sa.Integer, primary_key=True),
23+
sa.Column("email", sa.String(50), nullable=False),
24+
sa.Column("hashed_password", sa.String(100), nullable=False),
25+
sa.Column("is_active", sa.Boolean),
2626
)
2727

2828

2929
def downgrade():
30-
op.drop_table('user')
30+
op.drop_table("user")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from fastapi.security import OAuth2PasswordRequestForm
2+
from fastapi import APIRouter, Depends, HTTPException, status
3+
from datetime import timedelta
4+
5+
from app.db.session import get_db
6+
from app.core import security
7+
from app.core.auth import authenticate_user
8+
9+
auth_router = r = APIRouter()
10+
11+
12+
@r.post("/token")
13+
async def login(
14+
db=Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
15+
):
16+
user = authenticate_user(db, form_data.username, form_data.password)
17+
if not user:
18+
raise HTTPException(
19+
status_code=status.HTTP_401_UNAUTHORIZED,
20+
detail="Incorrect username or password",
21+
headers={"WWW-Authenticate": "Bearer"},
22+
)
23+
24+
access_token_expires = timedelta(
25+
minutes=security.ACCESS_TOKEN_EXPIRE_MINUTES
26+
)
27+
access_token = security.create_access_token(
28+
data={"sub": user.email}, expires_delta=access_token_expires
29+
)
30+
31+
return {"access_token": access_token, "token_type": "bearer"}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
2+
def test_login(client, test_db, test_user, test_password):
3+
response = client.post(
4+
"/api/token",
5+
data={"username": test_user.email, "password": test_password},
6+
)
7+
assert response.status_code == 200
8+
9+
10+
def test_wrong_password(client, test_db, test_user, test_password):
11+
response = client.post(
12+
"/api/token", data={"username": test_user.email, "password": "wrong"}
13+
)
14+
assert response.status_code == 401
15+
16+
17+
def test_wrong_login(client, test_db, test_user, test_password):
18+
response = client.post(
19+
"/api/token", data={"username": "fakeuser", "password": test_password}
20+
)
21+
assert response.status_code == 401
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,16 @@
1-
from sqlalchemy import create_engine, Boolean
2-
import json
3-
from sqlalchemy.orm import sessionmaker
4-
from app.db.session import Base, get_db
5-
6-
from app.core import config
7-
from app.main import app
81
from app.db import models
92

103

114
def test_get_users(client, test_db, test_user):
125
response = client.get("/api/v1/users")
136
assert response.status_code == 200
14-
assert response.json() == [{
15-
'id': test_user.id,
16-
'email': test_user.email,
17-
'is_active': bool(test_user.is_active),
18-
}]
7+
assert response.json() == [
8+
{
9+
"id": test_user.id,
10+
"email": test_user.email,
11+
"is_active": bool(test_user.is_active),
12+
}
13+
]
1914

2015

2116
def test_delete_user(client, test_user, test_db):
@@ -25,44 +20,54 @@ def test_delete_user(client, test_user, test_db):
2520

2621

2722
def test_delete_user_not_found(client, test_user, test_db):
28-
response = client.delete(f"/api/v1/users/4321")
23+
response = client.delete("/api/v1/users/4321")
2924
assert response.status_code == 404
3025

3126

3227
def test_edit_user(client, test_user, test_db):
3328
new_user = {
34-
'email': '[email protected]',
35-
'is_active': False,
36-
'password': 'new_password',
29+
"email": "[email protected]",
30+
"is_active": False,
31+
"password": "new_password",
3732
}
3833

3934
response = client.put(f"/api/v1/users/{test_user.id}", json=new_user)
4035
assert response.status_code == 200
41-
new_user['id'] = test_user.id
42-
new_user.pop('password')
36+
new_user["id"] = test_user.id
37+
new_user.pop("password")
4338
assert response.json() == new_user
4439

4540

4641
def test_edit_user_not_found(client, test_user, test_db):
4742
new_user = {
48-
'email': '[email protected]',
49-
'is_active': False,
50-
'password': 'new_password',
43+
"email": "[email protected]",
44+
"is_active": False,
45+
"password": "new_password",
5146
}
52-
response = client.put(f"/api/v1/users/1234", json=new_user)
47+
response = client.put("/api/v1/users/1234", json=new_user)
5348
assert response.status_code == 404
5449

5550

5651
def test_get_user(client, test_user, test_db):
5752
response = client.get(f"/api/v1/users/{test_user.id}")
5853
assert response.status_code == 200
5954
assert response.json() == {
60-
'id': test_user.id,
61-
'email': test_user.email,
62-
'is_active': bool(test_user.is_active),
55+
"id": test_user.id,
56+
"email": test_user.email,
57+
"is_active": bool(test_user.is_active),
6358
}
6459

6560

6661
def test_user_not_found(client, test_user, test_db):
6762
response = client.get("/api/v1/users/123")
6863
assert response.status_code == 404
64+
65+
66+
def test_unauthenticated_user(client):
67+
response = client.get("/api/v1/users/me")
68+
assert response.status_code == 401
69+
70+
71+
def test_authenticated_user(client):
72+
# TODO
73+
pass

0 commit comments

Comments
 (0)