Skip to content

Commit 22d60a5

Browse files
authored
Merge pull request #33 from Sheldenburg/feature/add-oauth
Feature/add oauth
2 parents 1423c09 + bf48b64 commit 22d60a5

File tree

13 files changed

+388
-59
lines changed

13 files changed

+388
-59
lines changed

.github/workflows/fastapi-to-gcr.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,6 @@ jobs:
4343
gcloud run deploy $SERVICE_NAME \
4444
--image ${{ env.DOCKER_IMAGE_URL }}:latest \
4545
--platform managed \
46-
--set-env-vars POSTGRES_SERVER=${{ secrets.POSTGRES_SERVER }},POSTGRES_PORT=${{ secrets.POSTGRES_PORT }},POSTGRES_DB=${{ secrets.POSTGRES_DB }},POSTGRES_USER=${{ secrets.POSTGRES_USER }},POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }},ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }},PROJECT_NAME=${{ env.PROJECT_ID }},FIRST_SUPERUSER=${{ secrets.FIRST_SUPERUSER }},FIRST_SUPERUSER_PASSWORD=${{ secrets.FIRST_SUPERUSER_PASSWORD }},USERS_OPEN_REGISTRATION=${{ env.USERS_OPEN_REGISTRATION }} \
46+
--set-env-vars POSTGRES_SERVER=${{ secrets.POSTGRES_SERVER }},POSTGRES_PORT=${{ secrets.POSTGRES_PORT }},POSTGRES_DB=${{ secrets.POSTGRES_DB }},POSTGRES_USER=${{ secrets.POSTGRES_USER }},POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }},ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }},PROJECT_NAME=${{ env.PROJECT_ID }},FIRST_SUPERUSER=${{ secrets.FIRST_SUPERUSER }},FIRST_SUPERUSER_PASSWORD=${{ secrets.FIRST_SUPERUSER_PASSWORD }},USERS_OPEN_REGISTRATION=${{ env.USERS_OPEN_REGISTRATION }},GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }},GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }},GOOGLE_REDIRECT_URI=${{ secrets.GOOGLE_REDIRECT_URI }},GH_CLIENT_ID=${{ secrets.GH_CLIENT_ID }},GH_CLIENT_SECRET=${{ secrets.GH_CLIENT_SECRET }} \
4747
--region australia-southeast1 \
4848
--allow-unauthenticated

backend/src/.env.example

+5
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,8 @@ SENTRY_DSN=
3737
# Configure these with your own Docker registry images
3838
DOCKER_IMAGE_BACKEND=backend
3939
DOCKER_IMAGE_FRONTEND=frontend
40+
41+
# Google auth
42+
GOOGLE_CLIENT_ID=
43+
GOOGLE_CLIENT_SECRET=
44+
GOOGLE_REDIRECT_URI=
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""add oauth provider to user table
2+
3+
Revision ID: 0bab391507d0
4+
Revises: ae5a34f2895e
5+
Create Date: 2024-10-21 18:13:27.236901
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
import sqlmodel
13+
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = '0bab391507d0'
17+
down_revision: Union[str, None] = 'ae5a34f2895e'
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.add_column('user', sa.Column('provider', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
25+
op.alter_column('user', 'hashed_password',
26+
existing_type=sa.VARCHAR(),
27+
nullable=True)
28+
# ### end Alembic commands ###
29+
30+
31+
def downgrade() -> None:
32+
# ### commands auto generated by Alembic - please adjust! ###
33+
op.alter_column('user', 'hashed_password',
34+
existing_type=sa.VARCHAR(),
35+
nullable=False)
36+
op.drop_column('user', 'provider')
37+
# ### end Alembic commands ###

backend/src/app/api/routes/login.py

+156-3
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
from datetime import timedelta
22
from typing import Annotated, Any
33

4-
from fastapi import APIRouter, Depends, HTTPException
5-
from fastapi.responses import HTMLResponse
4+
import requests
5+
from fastapi import APIRouter, Depends, HTTPException, Response
6+
from fastapi.responses import HTMLResponse, RedirectResponse
67
from fastapi.security import OAuth2PasswordRequestForm
78

89
from app import crud
910
from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser
1011
from app.core import security
1112
from app.core.config import settings
1213
from app.core.security import get_password_hash
13-
from app.models import Message, NewPassword, Token, UserPublic
14+
from app.models import Message, NewPassword, Token, UserCreateOauth, UserPublic
1415
from app.utils import (
1516
generate_password_reset_token,
1617
generate_reset_password_email,
@@ -123,3 +124,155 @@ def recover_password_html_content(email: str, session: SessionDep) -> Any:
123124
return HTMLResponse(
124125
content=email_data.html_content, headers={"subject:": email_data.subject}
125126
)
127+
128+
129+
# Redirect user to Google's OAuth2 login page
130+
@router.get("/login/google")
131+
def login_google():
132+
google_auth_url = (
133+
"https://accounts.google.com/o/oauth2/v2/auth?"
134+
f"client_id={settings.GOOGLE_CLIENT_ID}&"
135+
"redirect_uri={settings.GOOGLE_REDIRECT_URI}&"
136+
"response_type=code&"
137+
"scope=email profile"
138+
)
139+
return RedirectResponse(google_auth_url)
140+
141+
142+
# Handle Google OAuth callback
143+
@router.get("/auth/google")
144+
def google_oauth(session: SessionDep, code: str, response: Response):
145+
token_url = "https://oauth2.googleapis.com/token"
146+
token_data = {
147+
"code": code,
148+
"client_id": settings.GOOGLE_CLIENT_ID,
149+
"client_secret": settings.GOOGLE_CLIENT_SECRET,
150+
"redirect_uri": settings.GOOGLE_REDIRECT_URI,
151+
"grant_type": "authorization_code",
152+
}
153+
154+
token_r = requests.post(token_url, data=token_data)
155+
token_json = token_r.json()
156+
157+
if "error" in token_json:
158+
raise HTTPException(
159+
status_code=400, detail="Failed to retrieve token from Google"
160+
)
161+
162+
access_token = token_json["access_token"]
163+
164+
# Get user info from Google
165+
user_info = requests.get(
166+
"https://www.googleapis.com/oauth2/v2/userinfo",
167+
headers={"Authorization": f"Bearer {access_token}"},
168+
).json()
169+
170+
# Check if the user already exists
171+
user = crud.get_user_by_email(session=session, email=user_info["email"])
172+
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
173+
if user:
174+
response = RedirectResponse("http://localhost:3000/dashboard")
175+
response.set_cookie(
176+
key="access_token",
177+
value=security.create_access_token(
178+
user.id, expires_delta=access_token_expires
179+
),
180+
httponly=True,
181+
)
182+
return response
183+
184+
# Check if the app is open for new user registration
185+
if not settings.USERS_OPEN_REGISTRATION:
186+
raise HTTPException(
187+
status_code=403,
188+
detail="Open user registration is forbidden on this server",
189+
)
190+
# Create a new user
191+
user_create = UserCreateOauth.model_validate(
192+
{
193+
"email": user_info["email"],
194+
"is_active": True,
195+
"is_superuser": False,
196+
"full_name": user_info["name"],
197+
"provider": "google",
198+
}
199+
)
200+
user = crud.create_user_oauth(session=session, user_create=user_create)
201+
202+
response = RedirectResponse("http://localhost:3000/dashboard")
203+
response.set_cookie(
204+
key="access_token",
205+
value=security.create_access_token(user.id, expires_delta=access_token_expires),
206+
httponly=True,
207+
)
208+
return response
209+
210+
211+
# Handle Github OAuth callback
212+
@router.get("/auth/github")
213+
def github_oauth(session: SessionDep, code: str, response: Response):
214+
token_url = "https://github.com/login/oauth/access_token"
215+
token_data = {
216+
"code": code,
217+
"client_id": settings.GITHUB_CLIENT_ID,
218+
"client_secret": settings.GITHUB_CLIENT_SECRET,
219+
}
220+
headers = {"Accept": "application/json"}
221+
222+
token_r = requests.post(token_url, data=token_data, headers=headers)
223+
token_json = token_r.json()
224+
if "error" in token_json:
225+
raise HTTPException(
226+
status_code=400, detail="Failed to retrieve token from Github"
227+
)
228+
access_token = token_json["access_token"]
229+
# Get user info from Github
230+
user_info = requests.get(
231+
"https://api.github.com/user",
232+
headers={"Authorization": f"Bearer {access_token}"},
233+
).json()
234+
235+
user_emails = requests.get(
236+
"https://api.github.com/user/emails",
237+
headers={"Authorization": f"Bearer {access_token}"},
238+
).json()
239+
240+
# Check if the user already exists
241+
user = crud.get_user_by_email(session=session, email=user_emails[0]["email"])
242+
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
243+
if user:
244+
response = RedirectResponse("http://localhost:3000/dashboard")
245+
response.set_cookie(
246+
key="access_token",
247+
value=security.create_access_token(
248+
user.id, expires_delta=access_token_expires
249+
),
250+
httponly=True,
251+
)
252+
return response
253+
254+
# Check if the app is open for new user registration
255+
if not settings.USERS_OPEN_REGISTRATION:
256+
raise HTTPException(
257+
status_code=403,
258+
detail="Open user registration is forbidden on this server",
259+
)
260+
# Create a new user
261+
user_create = UserCreateOauth.model_validate(
262+
{
263+
"email": user_emails[0]["email"],
264+
"is_active": True,
265+
"is_superuser": False,
266+
"full_name": user_info["login"],
267+
"provider": "github",
268+
}
269+
)
270+
user = crud.create_user_oauth(session=session, user_create=user_create)
271+
272+
response = RedirectResponse("http://localhost:3000/dashboard")
273+
response.set_cookie(
274+
key="access_token",
275+
value=security.create_access_token(user.id, expires_delta=access_token_expires),
276+
httponly=True,
277+
)
278+
return response

backend/src/app/api/routes/users.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,9 @@ def register_user(session: SessionDep, user_in: UserRegister) -> Any:
169169
status_code=400,
170170
detail="The user with this email already exists in the system",
171171
)
172-
user_create = UserCreate.model_validate(user_in)
172+
user_create = UserCreate.model_validate(
173+
{"email": user_in.email, "password": user_in.password}
174+
)
173175
user = crud.create_user(session=session, user_create=user_create)
174176
return user
175177

backend/src/app/core/config.py

+7
Original file line numberDiff line numberDiff line change
@@ -118,5 +118,12 @@ def _enforce_non_default_secrets(self) -> Self:
118118

119119
return self
120120

121+
GOOGLE_CLIENT_ID: str
122+
GOOGLE_CLIENT_SECRET: str
123+
GOOGLE_REDIRECT_URI: str
124+
125+
GH_CLIENT_ID: str
126+
GH_CLIENT_SECRET: str
127+
121128

122129
settings = Settings() # type: ignore

backend/src/app/crud.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from sqlmodel import Session, select
44

55
from app.core.security import get_password_hash, verify_password
6-
from app.models import Item, ItemCreate, User, UserCreate, UserUpdate
6+
from app.models import Item, ItemCreate, User, UserCreate, UserCreateOauth, UserUpdate
77

88

99
def create_user(*, session: Session, user_create: UserCreate) -> User:
@@ -16,6 +16,14 @@ def create_user(*, session: Session, user_create: UserCreate) -> User:
1616
return db_obj
1717

1818

19+
def create_user_oauth(*, session: Session, user_create: UserCreateOauth) -> User:
20+
db_obj = User.model_validate(user_create)
21+
session.add(db_obj)
22+
session.commit()
23+
session.refresh(db_obj)
24+
return db_obj
25+
26+
1927
def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any:
2028
user_data = user_in.model_dump(exclude_unset=True)
2129
extra_data = {}

backend/src/app/models.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ class UserCreate(UserBase):
1919
password: str
2020

2121

22+
class UserCreateOauth(UserBase):
23+
provider: str
24+
25+
2226
# TODO replace email str with EmailStr when sqlmodel supports it
2327
class UserRegister(SQLModel):
2428
email: str
@@ -47,8 +51,9 @@ class UpdatePassword(SQLModel):
4751
# Database model, database table inferred from class name
4852
class User(UserBase, table=True):
4953
id: int | None = Field(default=None, primary_key=True)
50-
hashed_password: str
54+
hashed_password: str | None = None
5155
items: list["Item"] = Relationship(back_populates="owner")
56+
provider: str | None = None
5257

5358

5459
# Properties to return via API, id is always required

frontend/.env.example

+2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
API_BASE_URL=http://localhost:8000
22
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
3+
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
4+
NEXT_PUBLIC_GOOGLE_REDIRECT_URI=

0 commit comments

Comments
 (0)