├── .gitignore
├── .pre-commit-config.yaml
├── Dockerfile
├── LICENSE
├── README.md
├── backend
├── .dockerignore
├── .env
├── Dockerfile
├── api
│ ├── __init__.py
│ ├── auth.py
│ ├── me.py
│ └── user.py
├── app.py
├── auth
│ ├── __init__.py
│ ├── action.py
│ └── utils.py
├── crud
│ ├── __init__.py
│ ├── dependencies.py
│ └── user.py
├── database
│ ├── __init__.py
│ └── config.py
├── models
│ ├── __init__.py
│ └── user.py
├── production.env
├── pyproject.toml
├── requirements.txt
├── run.py
├── schemas
│ ├── __init__.py
│ ├── token.py
│ └── user.py
└── setting
│ └── config.py
├── docker-compose.yml
├── docs
├── README_zh.md
├── banner.png
└── demo.png
├── frontend
├── .env
├── .env.production
├── .gitignore
├── Dockerfile
├── README.md
├── index.html
├── package.json
├── public
│ └── oauth.png
├── src
│ ├── App.vue
│ ├── api
│ │ ├── auth.js
│ │ ├── me.js
│ │ ├── req.js
│ │ └── user.js
│ ├── assets
│ │ └── vue.svg
│ ├── components
│ │ ├── Dialog.vue
│ │ ├── Loading.vue
│ │ └── NavBar.vue
│ ├── main.js
│ ├── router
│ │ └── index.js
│ ├── store
│ │ ├── auth.js
│ │ ├── dialog.js
│ │ ├── loading.js
│ │ ├── me.js
│ │ └── user.js
│ ├── style.css
│ └── views
│ │ ├── HomeView.vue
│ │ ├── LoginView.vue
│ │ ├── LogoutView.vue
│ │ ├── ProfileView.vue
│ │ ├── RefreshView.vue
│ │ └── RegisterView.vue
├── vite.config.js
└── yarn.lock
├── nginx
└── nginx.conf
└── template.env
/.gitignore:
--------------------------------------------------------------------------------
1 | venv
2 | __pycache__
3 | poetry.lock
4 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | - repo: https://github.com/astral-sh/ruff-pre-commit
2 | # Ruff version.
3 | rev: v0.1.5
4 | hooks:
5 | # Run the linter.
6 | - id: ruff
7 | args: [ --fix ]
8 | # Run the formatter.
9 | - id: ruff-format
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Dockerfile for the frontend
2 | # nginx is used to serve the frontend and forward requests to the backend
3 | FROM node:lts-alpine as build-stage
4 | WORKDIR /app
5 | ENV PATH /app/node_modules/.bin:$PATH
6 | COPY ./frontend/ .
7 | RUN yarn install
8 | RUN yarn build
9 |
10 | # copy the build output to nginx
11 | FROM nginx:stable-alpine as production-stage
12 | WORKDIR /app
13 | COPY --from=build-stage /app/dist /usr/share/nginx/html
14 | COPY ./nginx/* /etc/nginx/conf.d/
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 LIU ZHE YOU
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # FastAPI Vue OAuth2 Boilerplate
2 |
3 | [](https://opensource.org/licenses/MIT)
4 | [](https://www.postgresql.org/)
5 | [](https://fastapi.tiangolo.com/)
6 | [](https://v3.vuejs.org/)
7 |
8 |
9 |
10 | [中文說明](https://github.com/jason810496/FastAPI-Vue-OAuth2/blob/main/docs/README_zh.md)
11 |
12 | This boilerplate is a starting point for building a FastAPI backend using PostgreSQL with a Vue3 frontend.
13 | It includes OAuth2 authentication with JWT tokens, and a simple user CRUD.
14 |
15 | > **Note :** For `Vue: Option API` + `VueX` version, please check out [archived-2023-11-22](https://github.com/jason810496/FastAPI-Vue-OAuth2/tree/archived-2023-11-22) branch
16 |
17 | ## Demo
18 | `localhost` for frontend
19 | `localhost:5001/docs` for backend swagger docs
20 |
21 |
22 |
23 |
24 |
25 | Click image to watch demo video on YouTube ☝️
26 |
27 |
28 | ## Features
29 | - FastAPI backend with PostgreSQL database
30 | - SQLAlchemy CRUD with async support
31 | - Simple User CRUD
32 | - OAuth2 authentication with JWT tokens
33 | - Store refresh token in `httpOnly` cookie, access token in memory ( Pinia store )
34 | - Vue3 frontend with Pinia store
35 | - Docker Compose for development and production
36 |
37 | ## Project Structure & Details
38 | ### Backend
39 | - `app.py` FastAPI application files
40 | - `/api` API endpoints
41 | - `/auth`
42 | - OAuth2 authentication
43 | - `get_current_user` dependency
44 | - `/crud`
45 | - user related CRUD utilities
46 | - database session dependency
47 | - `/database` Database configuration files
48 | - `/models` SQLAlchemy models using `declarative_base`
49 | - `/schemas` Pydantic schemas
50 |
51 | ### Database
52 | - `PostgreSQL 15.1` image from Docker Hub
53 | - exposed on port `5432`
54 | - volume `postgres_data` for persistent data
55 |
56 | ### Frontend
57 | - `Vite` Frontend build tool
58 | - `/views` Frontend page views
59 | - use `RefreshView.vue` as middleware to refresh JWT tokens
60 | - `/store` Pinia store ( using `Data Provider Patten` )
61 | - `/router` Vue router
62 | - `/api` API endpoints
63 | - `req.js`
64 | - `axios` request wrapper , handle `401` unauthorized error to refresh JWT tokens
65 | - use `import.meta.env.VITE_APP_API_URL` to load API url from `.env` file
66 |
67 | ## Environment Variables
68 | - `.env` for postgres database
69 | - `POSTGRES_USER`
70 | - `POSTGRES_PASSWORD`
71 | - `POSTGRES_DB`
72 | - `backend/.env` for backend
73 | - `PORT`
74 | - `RELOAD`
75 | - `DATABASE_URL` **Should be same as above setting dot file**
76 | - `JWT_ALGORITHM`
77 | - `ACCESS_TOKEN_SECRET`
78 | - `REFRESH_TOKEN_SECRET`
79 | - `ACCESS_TOKEN_EXPIRE_MINUTES`
80 | - `REFRESH_TOKEN_EXPIRE_MINUTES`
81 |
82 | - `nginx/nginx.conf` for nginx server
83 | - **Note :** backend hostname should be same as `docker-compose.yml` service name
84 | - `frontend/.env` for development API url
85 | - `frontend/.env.production` for production API url
86 |
87 |
88 | ## Deployment
89 |
90 | ### Containerization
91 | - `docker-compose.yml` Docker Compose configuration file
92 | - `Dockerfile` Dockerfile for frontend nginx server with production build
93 | - `backend/Dockerfile` Dockerfile for backend with hot reload
94 |
95 | ### Production
96 | - `docker-compose up -d --build`
97 |
98 | ## Development
99 | - Database
100 | ```
101 | docker run --name fastapi_vue_oauth2_postgresql -e POSTGRES_USER=fastapi_vue_user -e POSTGRES_PASSWORD=fastapi_vue_password -e POSTGRES_DB=fastapi_vue_dev -p 5432:5432 -d -v postgres_data_dev:/var/lib/postgresql/data postgres:15.1
102 | ```
103 | - Backend
104 |
105 | **Note** : shuold change in change `DATABASE_URL` to `DEV_DATABASE_URL` in `backend/.env`
106 | - Poetry
107 | ```
108 | cd backend
109 |
110 | poetry install
111 | poetry shell
112 |
113 | python3 run.py
114 | ```
115 | - Create virtual environment
116 | ```
117 | cd backend
118 |
119 | python3 -m venv venv
120 | source venv/bin/activate
121 | pip3 install -r requirements.txt
122 |
123 | python3 run.py
124 | ```
125 | - Frontend
126 | ```
127 | cd frontend
128 |
129 | yarn dev
130 | ```
131 |
132 | ### Advanced : Kubernetes
133 |
134 | ```
135 | Still working on it on features/k8s branch !
136 | ```
137 |
138 | ## Issues & PR
139 | Feel free to open an issue !
140 |
141 | Pull requests are welcome.
142 | Any contributions you make are **greatly appreciated**.
143 |
144 |
--------------------------------------------------------------------------------
/backend/.dockerignore:
--------------------------------------------------------------------------------
1 | *cache*
2 | venv
3 | Dockerfile
4 | .dockerignore
5 | poetry.lock
6 | pyproject.toml
7 | *.env
--------------------------------------------------------------------------------
/backend/.env:
--------------------------------------------------------------------------------
1 | # the setting should be same as the one in `.env` file in the root directory
2 | PORT=5001
3 | RELOAD=True
4 | # database url for sqlalchemy
5 | # the `db` hostname should be the same as the service name in docker-compose.yml
6 | # DATABASE_URL=postgresql+asyncpg://fastapi_vue_user:fastapi_vue_password@db:5432/fastapi_vue_prod
7 |
8 | # for local development
9 | DATABASE_URL=postgresql+asyncpg://fastapi_vue_user:fastapi_vue_password@localhost:5432/fastapi_vue_dev
10 |
11 | # use `openssl rand -hex 32` to generate a random secret
12 | ACCESS_TOKEN_SECRET=YOUR_ACCESS_TOKEN_SECRET
13 | REFRESH_TOKEN_SECRET=YOUR_REFRESH_TOKEN_SECRET
14 | # ACCESS_TOKEN_EXPIRE_MINUTES=60 * 3 # three hours
15 | # REFRESH_TOKEN_EXPIRE_MINUTES=60 * 24 * 3 # three days
16 | # ACCESS_TOKEN_EXPIRE_MINUTES=180
17 | # REFRESH_TOKEN_EXPIRE_MINUTES=4320
18 |
19 | ACCESS_TOKEN_EXPIRE_MINUTES=1
20 | REFRESH_TOKEN_EXPIRE_MINUTES=1
21 |
--------------------------------------------------------------------------------
/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | # pull official base image
2 | FROM python:3.11.1-slim
3 |
4 | # set work directory
5 | WORKDIR /usr/backend
6 |
7 | # set environment variables
8 | ENV PYTHONDONTWRITEBYTECODE 1
9 | ENV PYTHONUNBUFFERED 1
10 |
11 | # copy requirements file
12 | COPY ./requirements.txt /usr/backend/requirements.txt
13 |
14 | # install dependencies
15 | # RUN set -eux \
16 | # && apk add --no-cache --virtual .build-deps build-base \
17 | # libressl-dev libffi-dev gcc musl-dev python3-dev \
18 | # postgresql-dev \
19 | # && pip install --upgrade pip setuptools wheel \
20 | # && pip install -r /usr/server/requirements.txt \
21 | # && rm -rf /root/.cache/pip
22 | RUN pip install -r requirements.txt
23 |
24 | # copy project
25 | COPY . /usr/backend/
--------------------------------------------------------------------------------
/backend/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jason810496/FastAPI-Vue-OAuth2/ecd50b03e0bef21c1fd34f7a6b0c5e1ff279fd82/backend/api/__init__.py
--------------------------------------------------------------------------------
/backend/api/auth.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta, timezone
2 |
3 | from jose import jwt
4 | from fastapi import APIRouter, Depends, HTTPException, status, Cookie, Response, Request
5 | from fastapi.security import OAuth2PasswordRequestForm
6 |
7 | from auth.action import validate_user
8 | from auth.utils import create_access_token, create_refresh_token
9 | from crud.dependencies import get_user_crud
10 | from crud.user import UserCRUD
11 | from schemas.token import Token
12 | from setting.config import get_settings
13 |
14 | settings = get_settings()
15 | router = APIRouter(prefix="/auth", tags=["auth"])
16 |
17 |
18 | @router.post("/login", response_model=Token)
19 | async def login(
20 | response: Response,
21 | form_data: OAuth2PasswordRequestForm = Depends(),
22 | db: UserCRUD = Depends(get_user_crud),
23 | ):
24 | user = await validate_user(form_data.username, form_data.password)
25 | if not user:
26 | raise HTTPException(
27 | status_code=status.HTTP_401_UNAUTHORIZED,
28 | detail="Incorrect username or password",
29 | headers={"WWW-Authenticate": "Bearer"},
30 | )
31 |
32 | access_token = await create_access_token(data={"username": form_data.username})
33 | refresh_token = await create_refresh_token(data={"username": form_data.username})
34 |
35 | await db.update_user_login(username=form_data.username)
36 | expired_time = (
37 | int(datetime.now(tz=timezone.utc).timestamp() * 1000)
38 | + timedelta(minutes=settings.access_token_expire_minutes).seconds * 1000
39 | )
40 |
41 | response.set_cookie(
42 | "refresh_token",
43 | refresh_token,
44 | httponly=True,
45 | samesite="strict",
46 | secure=False,
47 | expires=timedelta(settings.refresh_token_expire_minutes),
48 | )
49 |
50 | return Token(
51 | access_token=access_token,
52 | expires_in=expired_time,
53 | token_type="Bearer",
54 | )
55 |
56 |
57 | @router.post("/refresh", response_model=Token)
58 | async def refresh(
59 | request: Request,
60 | response: Response,
61 | db: UserCRUD = Depends(get_user_crud),
62 | ):
63 | credentials_exception = HTTPException(
64 | status_code=401,
65 | detail="Could not validate credentials",
66 | headers={"WWW-Authenticate": "Bearer"},
67 | )
68 | try:
69 | refresh_token = request.cookies.get("refresh_token")
70 | if not refresh_token:
71 | raise credentials_exception
72 |
73 | payload = jwt.decode(
74 | refresh_token,
75 | settings.refresh_token_secret,
76 | algorithms=["HS256"],
77 | )
78 |
79 | username: str = payload.get("username")
80 | if username is None:
81 | raise credentials_exception
82 |
83 | except Exception as e:
84 | credentials_exception.detail = str(e)
85 | raise credentials_exception
86 |
87 | access_token = await create_access_token(data={"username": username})
88 | refresh_token = await create_refresh_token(data={"username": username})
89 |
90 | db.update_user_login(username)
91 | expired_time = (
92 | int(datetime.now(tz=timezone.utc).timestamp() * 1000)
93 | + timedelta(minutes=settings.access_token_expire_minutes).seconds * 1000
94 | )
95 |
96 | response.set_cookie(
97 | "refresh_token",
98 | refresh_token,
99 | httponly=True,
100 | samesite="strict",
101 | secure=False,
102 | expires=timedelta(settings.refresh_token_expire_minutes),
103 | )
104 |
105 | return Token(
106 | access_token=access_token,
107 | expires_in=expired_time,
108 | token_type="Bearer",
109 | )
110 |
111 |
112 | @router.post("/logout")
113 | async def logout(response: Response):
114 | response.delete_cookie("refresh_token")
115 | return {"message": "Logout successfully"}
116 |
--------------------------------------------------------------------------------
/backend/api/me.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends
2 |
3 | from auth.action import get_current_user
4 | from crud.user import UserCRUD
5 | from crud.dependencies import get_user_crud
6 | import schemas.user as user_schema
7 |
8 | router = APIRouter(prefix="/me", tags=["me"])
9 |
10 |
11 | @router.delete("")
12 | async def delete_user(
13 | current_user: user_schema.Base = Depends(get_current_user),
14 | db: UserCRUD = Depends(get_user_crud),
15 | ):
16 | return await db.delete_user(username=current_user.username)
17 |
18 |
19 | @router.put("/password")
20 | async def update_password(
21 | request: user_schema.Password,
22 | current_user: user_schema.Base = Depends(get_current_user),
23 | db: UserCRUD = Depends(get_user_crud),
24 | ):
25 | return await db.update_password(
26 | username=current_user.username, password=request.password
27 | )
28 |
29 |
30 | @router.put("/birthday")
31 | async def update_birthday(
32 | request: user_schema.Birthday,
33 | current_user: user_schema.Base = Depends(get_current_user),
34 | db: UserCRUD = Depends(get_user_crud),
35 | ):
36 | return await db.update_birthday(
37 | username=current_user.username, birthday=request.birthday
38 | )
39 |
40 |
41 | @router.get("", response_model=user_schema.Base)
42 | async def protected(current_user: user_schema.Base = Depends(get_current_user)):
43 | return current_user
44 |
--------------------------------------------------------------------------------
/backend/api/user.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends, HTTPException, status
2 | from typing import List
3 |
4 | from auth.action import get_current_user
5 | from crud.user import UserCRUD
6 | from crud.dependencies import get_user_crud
7 | import schemas.user as user_schema
8 |
9 | router = APIRouter(prefix="/users", tags=["users"])
10 |
11 |
12 | @router.get("", response_model=List[user_schema.Base])
13 | async def get_users(db: UserCRUD = Depends(get_user_crud)):
14 | return await db.get_users()
15 |
16 |
17 | @router.post("")
18 | async def register(
19 | new_user: user_schema.Register, db: UserCRUD = Depends(get_user_crud)
20 | ):
21 | db_user = await db.get_user_by_username(username=new_user.username)
22 | if db_user:
23 | raise HTTPException(status_code=409, detail="Username already registered")
24 | await db.create_user(new_user)
25 | return status.HTTP_201_CREATED
26 |
27 |
28 | @router.delete("", deprecated=True)
29 | async def delete_user(
30 | current_user: user_schema.Base = Depends(get_current_user),
31 | db: UserCRUD = Depends(get_user_crud),
32 | ):
33 | # return await db.delete_user(username=current_user.username)
34 | return "deprecated"
35 |
36 |
37 | @router.put("/password", deprecated=True)
38 | async def update_password(
39 | request: user_schema.Password,
40 | current_user: user_schema.Base = Depends(get_current_user),
41 | db: UserCRUD = Depends(get_user_crud),
42 | ):
43 | # return await db.update_password( username=current_user.username , password=request.password )
44 | return "deprecated"
45 |
46 |
47 | @router.put("/birthday", deprecated=True)
48 | async def update_birthday(
49 | request: user_schema.Birthday,
50 | current_user: user_schema.Base = Depends(get_current_user),
51 | db: UserCRUD = Depends(get_user_crud),
52 | ):
53 | # return await db.update_birthday( username=current_user.username ,birthday=request.birthday )
54 | return "deprecated"
55 |
56 |
57 | @router.get("/me", response_model=user_schema.Base, deprecated=True)
58 | async def protected(current_user: user_schema.Base = Depends(get_current_user)):
59 | # return current_user
60 | return "deprecated"
61 |
--------------------------------------------------------------------------------
/backend/app.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 | from fastapi.middleware.cors import CORSMiddleware
3 |
4 | from api import user, auth, me
5 | from database.config import engine, database, Base
6 |
7 |
8 | app = FastAPI()
9 | app.include_router(auth.router, prefix="/api")
10 | app.include_router(user.router, prefix="/api")
11 | app.include_router(me.router, prefix="/api")
12 |
13 | origins = [
14 | "http://localhost:5173",
15 | ]
16 |
17 | methods = [
18 | "DELETE",
19 | "GET",
20 | "POST",
21 | "PUT",
22 | ]
23 |
24 | app.add_middleware(
25 | CORSMiddleware,
26 | allow_origins=origins,
27 | allow_credentials=True,
28 | allow_methods=methods,
29 | allow_headers=["*"],
30 | )
31 |
32 |
33 | @app.on_event("startup")
34 | async def startup():
35 | await database.connect()
36 | async with engine.begin() as conn:
37 | await conn.run_sync(Base.metadata.create_all)
38 |
39 |
40 | @app.on_event("shutdown")
41 | async def shutdown():
42 | if database.is_connected:
43 | await database.disconnect()
44 |
--------------------------------------------------------------------------------
/backend/auth/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jason810496/FastAPI-Vue-OAuth2/ecd50b03e0bef21c1fd34f7a6b0c5e1ff279fd82/backend/auth/__init__.py
--------------------------------------------------------------------------------
/backend/auth/action.py:
--------------------------------------------------------------------------------
1 | from fastapi import Depends, HTTPException
2 | from jose import jwt
3 | from typing import Annotated
4 |
5 | from database.config import async_session
6 | from crud.user import UserCRUD
7 | from .utils import verify_password, oauth2_scheme
8 | from setting.config import get_settings
9 |
10 | settings = get_settings()
11 |
12 |
13 | async def validate_user(username: str, password: str):
14 | async with async_session() as session:
15 | async with session.begin():
16 | db = UserCRUD(session)
17 | user = await db.get_user_by_username(username=username)
18 | if not user:
19 | return False
20 |
21 | if not verify_password(password, user.password):
22 | return False
23 | return user
24 |
25 |
26 | async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
27 | credentials_exception = HTTPException(
28 | status_code=401,
29 | detail="Could not validate credentials",
30 | headers={"WWW-Authenticate": "Bearer"},
31 | )
32 | try:
33 | payload = jwt.decode(
34 | token,
35 | settings.access_token_secret,
36 | algorithms=["HS256"],
37 | )
38 | username: str = payload.get("username")
39 | if username is None:
40 | raise credentials_exception
41 |
42 | except Exception as e:
43 | credentials_exception.detail = str(e)
44 | raise credentials_exception
45 |
46 | async with async_session() as session:
47 | async with session.begin():
48 | db = UserCRUD(session)
49 | user = await db.get_user_by_username(username=username)
50 | if user is None:
51 | raise credentials_exception
52 | return user
53 |
--------------------------------------------------------------------------------
/backend/auth/utils.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 |
3 | from fastapi.security import OAuth2PasswordBearer
4 | from jose import jwt
5 | from passlib.context import CryptContext
6 | from setting.config import get_settings
7 |
8 | settings = get_settings()
9 |
10 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
11 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
12 |
13 |
14 | def verify_password(plain_password, hashed_password):
15 | return pwd_context.verify(plain_password, hashed_password)
16 |
17 |
18 | def get_password_hash(password):
19 | return pwd_context.hash(password)
20 |
21 |
22 | async def create_access_token(data: dict):
23 | to_encode = data.copy()
24 | expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
25 |
26 | to_encode.update({"exp": expire})
27 | encoded_jwt = jwt.encode(
28 | to_encode,
29 | settings.access_token_secret,
30 | algorithm="HS256",
31 | )
32 | return encoded_jwt
33 |
34 |
35 | async def create_refresh_token(data: dict):
36 | to_encode = data.copy()
37 | expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
38 |
39 | to_encode.update({"exp": expire})
40 | encoded_jwt = jwt.encode(
41 | to_encode,
42 | settings.refresh_token_secret,
43 | algorithm="HS256",
44 | )
45 | return encoded_jwt
46 |
--------------------------------------------------------------------------------
/backend/crud/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jason810496/FastAPI-Vue-OAuth2/ecd50b03e0bef21c1fd34f7a6b0c5e1ff279fd82/backend/crud/__init__.py
--------------------------------------------------------------------------------
/backend/crud/dependencies.py:
--------------------------------------------------------------------------------
1 | from typing import Generator
2 |
3 | from database.config import async_session
4 | from crud.user import UserCRUD
5 |
6 |
7 | async def get_db() -> Generator:
8 | async with async_session() as session:
9 | async with session.begin():
10 | yield session
11 |
12 |
13 | async def get_user_crud() -> Generator:
14 | async with async_session() as session:
15 | async with session.begin():
16 | yield UserCRUD(session)
17 |
--------------------------------------------------------------------------------
/backend/crud/user.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from sqlalchemy import select, update, delete
4 | from sqlalchemy.ext.asyncio import AsyncSession
5 | from typing import List
6 |
7 | from auth.utils import get_password_hash
8 | from models.user import UserModels
9 | import schemas.user as user_schema
10 |
11 |
12 | class UserCRUD:
13 | db_session = None
14 |
15 | def __init__(self, db_session: AsyncSession = None):
16 | self.db_session = db_session
17 |
18 | async def get_user_by_username(self, username: str):
19 | stmt = select(UserModels).where(UserModels.username == username)
20 | result = await self.db_session.execute(stmt)
21 | user = result.scalars().first()
22 | return user
23 |
24 | async def get_users(self) -> List[user_schema.Base]:
25 | stmt = select(UserModels)
26 | result = await self.db_session.execute(stmt)
27 | users = result.scalars().all()
28 | return users
29 |
30 | async def create_user(self, user: user_schema.Register) -> user_schema.Base:
31 | db_user = UserModels(
32 | username=user.username,
33 | password=get_password_hash(user.password),
34 | birthday=user.birthday,
35 | )
36 | self.db_session.add(db_user)
37 | await self.db_session.commit()
38 | return db_user
39 |
40 | async def update_user_login(self, username: str):
41 | db_user = await self.get_user_by_username(username)
42 | db_user.last_login = datetime.utcnow()
43 | await self.db_session.refresh(db_user)
44 | return db_user
45 |
46 | async def update_birthday(self, username: str, birthday: datetime):
47 | stmt = (
48 | update(UserModels)
49 | .where(UserModels.username == username)
50 | .values(birthday=birthday)
51 | )
52 | stmt.execution_options(synchronize_session="fetch")
53 | await self.db_session.execute(stmt)
54 |
55 | async def update_password(self, username: str, password: str):
56 | stmt = (
57 | update(UserModels)
58 | .where(UserModels.username == username)
59 | .values(password=get_password_hash(password))
60 | )
61 | stmt.execution_options(synchronize_session="fetch")
62 | await self.db_session.execute(stmt)
63 |
64 | async def delete_user(self, username: str):
65 | stmt = delete(UserModels).where(UserModels.username == username)
66 | stmt.execution_options(synchronize_session="fetch")
67 | await self.db_session.execute(stmt)
68 |
--------------------------------------------------------------------------------
/backend/database/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jason810496/FastAPI-Vue-OAuth2/ecd50b03e0bef21c1fd34f7a6b0c5e1ff279fd82/backend/database/__init__.py
--------------------------------------------------------------------------------
/backend/database/config.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from dotenv import load_dotenv
4 | from databases import Database
5 | from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
6 | from sqlalchemy.ext.declarative import declarative_base
7 | from sqlalchemy.orm import sessionmaker
8 |
9 | from setting.config import get_settings
10 |
11 | settings = get_settings()
12 |
13 | # Create engine
14 | engine = create_async_engine(settings.database_url, echo=True)
15 |
16 | # Create session
17 | async_session = sessionmaker(
18 | engine, expire_on_commit=False, autocommit=False, class_=AsyncSession
19 | )
20 |
21 | Base = declarative_base()
22 |
23 | database = Database(settings.database_url)
24 |
--------------------------------------------------------------------------------
/backend/models/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jason810496/FastAPI-Vue-OAuth2/ecd50b03e0bef21c1fd34f7a6b0c5e1ff279fd82/backend/models/__init__.py
--------------------------------------------------------------------------------
/backend/models/user.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from sqlalchemy import Column, VARCHAR, DATE, DateTime
4 |
5 | from database.config import Base
6 |
7 |
8 | # Create User class
9 | class UserModels(Base):
10 | __tablename__ = "users"
11 | username = Column(VARCHAR, unique=True, primary_key=True)
12 | password = Column(VARCHAR)
13 | birthday = Column(DATE)
14 | create_time = Column(DateTime, default=datetime.utcnow())
15 | last_login = Column(DateTime, default=datetime.utcnow())
16 |
17 | def __init__(self, username: str, password: str, birthday: datetime):
18 | self.username = username
19 | self.password = password
20 | self.birthday = birthday
21 |
22 | def __repr__(self) -> str:
23 | return f""
24 |
--------------------------------------------------------------------------------
/backend/production.env:
--------------------------------------------------------------------------------
1 | # the setting should be same as the one in `.env` file in the root directory
2 | PORT=5001
3 | RELOAD=False
4 | # database url for sqlalchemy
5 | # the `db` hostname should be the same as the service name in docker-compose.yml
6 | DATABASE_URL=postgresql+asyncpg://fastapi_vue_user:fastapi_vue_password@db:5432/fastapi_vue_prod
7 |
8 | # for local development
9 | # DATABASE_URL=postgresql+asyncpg://fastapi_vue_user:fastapi_vue_password@localhost:5432/fastapi_vue_dev
10 |
11 |
12 | # jwt settings
13 | JWT_ALGORITHM=HS256
14 | # use `openssl rand -hex 32` to generate a random secret
15 | ACCESS_TOKEN_SECRET=YOUR_ACCESS_TOKEN_SECRET
16 | REFRESH_TOKEN_SECRET=YOUR_REFRESH_TOKEN_SECRET
17 | # ACCESS_TOKEN_EXPIRE_MINUTES=60 * 3 # three hours
18 | # REFRESH_TOKEN_EXPIRE_MINUTES=60 * 24 * 3 # three days
19 | ACCESS_TOKEN_EXPIRE_MINUTES=180
20 | REFRESH_TOKEN_EXPIRE_MINUTES=4320
21 |
--------------------------------------------------------------------------------
/backend/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "fastapi-vue-oauth2-backend"
3 | version = "0.1.0"
4 | description = ""
5 | authors = ["jason810496 <810496@email.wlsh.tyc.edu.tw>"]
6 | license = "MIT"
7 | readme = "README.md"
8 | packages = [{include = "fastapi_vue_oauth2_backend"}]
9 |
10 | [tool.poetry.dependencies]
11 | python = "^3.9"
12 | python-dotenv = "1.0.0"
13 | uvicorn = "0.23.1"
14 | fastapi = "0.100.1"
15 | sqlalchemy = "1.4.49"
16 | databases = "0.7.0"
17 | asyncpg = "0.28.0"
18 | greenlet = "2.0.2"
19 | python-multipart = "0.0.6"
20 | bcrypt = "4.0.1"
21 | passlib = "1.7.4"
22 | python-jose = "3.3.0"
23 | ruff = "^0.1.5"
24 |
25 |
26 | [build-system]
27 | requires = ["poetry-core"]
28 | build-backend = "poetry.core.masonry.api"
29 |
--------------------------------------------------------------------------------
/backend/requirements.txt:
--------------------------------------------------------------------------------
1 | python-dotenv==1.0.0
2 | uvicorn==0.23.1
3 | fastapi==0.100.1
4 | SQLAlchemy==1.4.49
5 | databases==0.7.0
6 | asyncpg==0.28.0
7 | greenlet==2.0.2
8 | python-multipart==0.0.6
9 | bcrypt==4.0.1
10 | passlib==1.7.4
11 | python-jose==3.3.0
12 |
13 | # alembic==1.11.3
14 | # annotated-types==0.5.0
15 | # anyio==3.7.1
16 | # asyncio==3.4.3
17 | # asyncpg==0.28.0
18 | # bcrypt==4.0.1
19 | # click==8.1.6
20 | # databases==0.7.0
21 | # ecdsa==0.18.0
22 | # exceptiongroup==1.1.2
23 | # fastapi==0.100.1
24 | # greenlet==2.0.2
25 | # h11==0.14.0
26 | # idna==3.4
27 | # Mako==1.2.4
28 | # MarkupSafe==2.1.3
29 | # passlib==1.7.4
30 | # psycopg2-binary==2.9.6
31 | # pyasn1==0.5.0
32 | # pydantic==2.1.1
33 | # pydantic_core==2.4.0
34 | # python-dotenv==1.0.0
35 | # python-jose==3.3.0
36 | # python-multipart==0.0.6
37 | # rsa==4.9
38 | # six==1.16.0
39 | # sniffio==1.3.0
40 | # SQLAlchemy==1.4.49
41 | # starlette==0.27.0
42 | # typing_extensions==4.7.1
43 | # uvicorn==0.23.1
44 |
--------------------------------------------------------------------------------
/backend/run.py:
--------------------------------------------------------------------------------
1 | import argparse
2 | import os
3 |
4 | from dotenv import load_dotenv
5 | import uvicorn
6 |
7 | if __name__ == "__main__":
8 | parser = argparse.ArgumentParser(description="Run the server in different modes.")
9 |
10 | app_mode = parser.add_argument_group(
11 | title="App Mode", description="Run the server in different modes."
12 | )
13 | app_mode.add_argument(
14 | "--prod", action="store_true", help="Run the server in production mode."
15 | )
16 | app_mode.add_argument(
17 | "--dev", action="store_true", help="Run the server in development mode."
18 | )
19 |
20 | args = parser.parse_args()
21 | print("args", args.__dict__)
22 |
23 | if args.prod:
24 | # for production mode
25 | # the secret key is set in the environment variable
26 | pass
27 | else:
28 | # check out setting/config.py for `ENV_PREFIX` use case
29 | load_dotenv(".env")
30 |
31 | uvicorn.run(
32 | "app:app",
33 | host="0.0.0.0",
34 | port=int(os.getenv("PORT")),
35 | reload=bool(os.getenv("RELOAD")),
36 | )
37 |
--------------------------------------------------------------------------------
/backend/schemas/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jason810496/FastAPI-Vue-OAuth2/ecd50b03e0bef21c1fd34f7a6b0c5e1ff279fd82/backend/schemas/__init__.py
--------------------------------------------------------------------------------
/backend/schemas/token.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 |
3 | # `refresh_token` should be stored in HttpOnly cookie
4 |
5 |
6 | class Token(BaseModel):
7 | access_token: str
8 | expires_in: int
9 | token_type: str
10 | # refresh_token: str
11 |
12 |
13 | # class RefreshToken(BaseModel):
14 | # refresh_token: str
15 |
--------------------------------------------------------------------------------
/backend/schemas/user.py:
--------------------------------------------------------------------------------
1 | from datetime import date
2 |
3 | from pydantic import BaseModel
4 |
5 | # User Schema
6 |
7 |
8 | class Base(BaseModel):
9 | username: str
10 | birthday: date
11 |
12 |
13 | class Register(Base):
14 | password: str
15 |
16 |
17 | class Password(BaseModel):
18 | password: str
19 |
20 |
21 | class Birthday(BaseModel):
22 | birthday: date
23 |
--------------------------------------------------------------------------------
/backend/setting/config.py:
--------------------------------------------------------------------------------
1 | import os
2 | from functools import lru_cache
3 |
4 |
5 | class Settings:
6 | app_name: str = "FastAPI Vue3 OAuth2"
7 | author: str = "Jason Liu"
8 |
9 | database_url: str = os.getenv("DATABASE_URL")
10 |
11 | access_token_secret: str = os.getenv("ACCESS_TOKEN_SECRET")
12 | access_token_expire_minutes: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES"))
13 |
14 | refresh_token_secret: str = os.getenv("REFRESH_TOKEN_SECRET")
15 | refresh_token_expire_minutes: int = int(os.getenv("REFRESH_TOKEN_EXPIRE_MINUTES"))
16 |
17 |
18 | @lru_cache()
19 | def get_settings():
20 | return Settings()
21 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '1.0'
2 |
3 | services:
4 | db:
5 | image: postgres:15.1
6 | expose:
7 | - 5432
8 | env_file:
9 | - .env
10 | restart: always
11 | volumes:
12 | - postgres_data:/var/lib/postgresql/data/
13 |
14 | backend:
15 | build: ./backend
16 | # image: jasonbigcow/oauth2-fastapi
17 | ports:
18 | - 5001:5001
19 | volumes:
20 | - ./backend/:/usr/backend/
21 | env_file:
22 | - ./backend/production.env
23 | command: python3 run.py --prod
24 | restart: always
25 | depends_on:
26 | - db
27 |
28 | nginx:
29 | build: ./
30 | # image: jasonbigcow/oauth2-vue3-nginx
31 | ports:
32 | - 80:8080
33 | restart: always
34 | depends_on:
35 | - backend
36 | volumes:
37 | - ./nginx:/etc/nginx/conf.d
38 |
39 | volumes:
40 | postgres_data:
41 |
42 | networks:
43 | default:
44 | name: fastapi_vue_network
--------------------------------------------------------------------------------
/docs/README_zh.md:
--------------------------------------------------------------------------------
1 | # FastAPI Vue OAuth2 Boilerplate
2 | [](https://opensource.org/licenses/MIT)
3 | [](https://www.postgresql.org/)
4 | [](https://fastapi.tiangolo.com/)
5 | [](https://v3.vuejs.org/)
6 |
7 | [English description](https://github.com/jason810496/FastAPI-Vue-OAuth2)
8 |
9 | 這個模板使用 FastAPI 與 PostgreSQL 的後端以及 Vue3 作為前端。
10 | 專案包含 OAuth2 認證與 JWT tokens,以及簡單的使用者 CRUD。
11 |
12 | ## Demo
13 | `localhost` 瀏覽前端
14 | `localhost:5001/docs` 瀏覽後端 swagger
15 |
16 |
17 |
18 |
19 |
20 | 點擊圖片觀看 YouTube Demo ☝️
21 |
22 |
23 | ## 功能
24 | - FastAPI 後端與 PostgreSQL 資料庫
25 | - SQLAlchemy CRUD 支援 async
26 | - 簡單的使用者 CRUD
27 | - OAuth2 認證與 JWT tokens
28 | - Vue3 前端與 Vuex store
29 | - Docker Compose 開發與正式環境
30 |
31 |
32 | ## 專案結構與細節
33 | ### 後端
34 | - `app.py` FastAPI 進入點
35 | - `/api` API endpoints
36 | - `/auth`
37 | - OAuth2 認證
38 | - `get_current_user` 依賴
39 | - `/crud`
40 | - 使用者相關 CRUD
41 | - 資料庫 session 依賴
42 | - `/database` 資料庫設定檔案
43 | - `/models` SQLAlchemy models ,使用 `declarative_base`
44 | - `/schemas` Pydantic schemas
45 |
46 | ### 資料庫
47 | - `PostgreSQL 15.1` Docker Hub 的 image
48 | - 開放在 `5432` port
49 | - 設定 `postgres_data` volume 做資料持久化
50 |
51 | ### 前端
52 | - `Vite` 前端建置工具
53 | - `/views` 前端頁面
54 | - 使用 `RefreshView.vue` 作為 middleware 來刷新 JWT tokens
55 | - `/store` Vuex store
56 | - `/modules` Vuex modules ,包含 `auth.js` 與 `user.js`
57 | - `/router` Vue router
58 | - `/api` API endpoints
59 | - `req.js`
60 | - `axios` 請求攔截器 ,處理 `401` 未授權錯誤來刷新 JWT tokens
61 | - 使用 `import.meta.env.VITE_APP_API_URL` 從 `.env` 檔案載入 API url
62 |
63 | ## 環境變數
64 | - `.env` 資料庫設定
65 | - `POSTGRES_USER`
66 | - `POSTGRES_PASSWORD`
67 | - `POSTGRES_DB`
68 | - `backend/.env` 後端設定
69 | - `DATABASE_URL` **要與上面的 .env 設定為一樣**
70 | - `JWT_ALGORITHM`
71 | - `ACCESS_TOKEN_SECRET`
72 | - `REFRESH_TOKEN_SECRET`
73 | - `ACCESS_TOKEN_EXPIRE_MINUTES`
74 | - `REFRESH_TOKEN_EXPIRE_MINUTES`
75 | - `nginx/nginx.conf` nginx server 設定
76 | - **注意 :** 後端的 hostname 要與 `docker-compose.yml` 的 service name 一樣
77 | - `frontend/.env` 開發環境 API url
78 | - `frontend/.env.production` 正式環境 API url
79 |
80 | ## 部署
81 |
82 | ### 容器化
83 | - `docker-compose.yml` Docker Compose 設定檔案
84 | - `Dockerfile` 前端 nginx server 的 Dockerfile ,使用 production build
85 | - `backend/Dockerfile` 後端的 Dockerfile ,有提供 hot reload
86 |
87 | ### 正式環境
88 | - `docker-compose up -d --build`
89 |
90 | ### 開發環境
91 | - 資料庫
92 | ```
93 | docker run --name fastapi_vue_oauth2_postgresql -e POSTGRES_USER=fastapi_vue_user -e POSTGRES_PASSWORD=fastapi_vue_password -e POSTGRES_DB=fastapi_vue_dev -p 5432:5432 -d -v postgres_data_dev:/var/lib/postgresql/data postgres:15.1
94 | ```
95 | - 後端
96 |
97 | **注意** : 需要在 `backend/.env` 中把 `DATABASE_URL` 換成 `DEV_DATABASE_URL`
98 | - Poetry
99 | ```
100 | cd backend
101 |
102 | poetry install
103 | poetry shell
104 | uvicorn app:app uvicorn app:app --reload --host 0.0.0.0 --port 5001
105 | ```
106 | - Create virtual environment
107 | ```
108 | cd backend
109 |
110 | python3 -m venv venv
111 | source venv/bin/activate
112 | pip3 install -r requirements.txt
113 |
114 | python3 -m uvicorn app:app --reload --host 0.0.0.0 --port 5001
115 | ```
116 | - 前端
117 | ```
118 | cd frontend
119 |
120 | yarn dev
121 | ```
122 |
123 | ## Issues & PR
124 |
125 | 有任何問題歡迎開 issue !
126 |
127 | 歡迎發送 pull requests 。
128 | 任何貢獻都很感謝。
--------------------------------------------------------------------------------
/docs/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jason810496/FastAPI-Vue-OAuth2/ecd50b03e0bef21c1fd34f7a6b0c5e1ff279fd82/docs/banner.png
--------------------------------------------------------------------------------
/docs/demo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jason810496/FastAPI-Vue-OAuth2/ecd50b03e0bef21c1fd34f7a6b0c5e1ff279fd82/docs/demo.png
--------------------------------------------------------------------------------
/frontend/.env:
--------------------------------------------------------------------------------
1 | VITE_APP_API_URL=http://localhost:5001/api
2 |
--------------------------------------------------------------------------------
/frontend/.env.production:
--------------------------------------------------------------------------------
1 | VITE_APP_API_URL=http://localhost/api
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | # deveploment docker file
2 | # in production, we will use nginx to serve the static files
3 | FROM node:lts-alpine
4 |
5 | WORKDIR /app
6 |
7 | ENV PATH /app/node_modules/.bin:$PATH
8 |
9 | COPY package.json .
10 | COPY yarn.lock .
11 | RUN yarn install
12 |
13 | CMD ["yarn", "preview" , "--port" , "8080" , "--host"]
14 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # Vue 3 + Vite
2 |
3 | This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `
13 |
14 |