Skip to content

Commit

Permalink
Merge pull request #9 from JohnGrubba/dev
Browse files Browse the repository at this point in the history
Google OAuth
  • Loading branch information
JohnGrubba authored Jul 9, 2024
2 parents a7ca98a + 839a7a2 commit 559faa7
Show file tree
Hide file tree
Showing 14 changed files with 209 additions and 12 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
**.venv
**.env
**config.json
**.pyc
**.pyc

**.env**
8 changes: 7 additions & 1 deletion config/configtemplate.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
"enable_conf_email": false,
"conf_code_expiry": 5,
"conf_code_complexity": 1,
"enable_welcome_email": false
"enable_welcome_email": false,
"oauth": {
"providers_enabled": [
"google"
],
"base_url": "http://localhost:3250/"
}
},
"email": {
"login_usr": "",
Expand Down
14 changes: 14 additions & 0 deletions docs/advanced/oauth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## Google OAuth
### Setup Google OAuth
1. Go to the [Google Cloud Console](https://console.cloud.google.com/).
2. Create a new project.
3. Go to the [APIs & Services -> Credentials](https://console.cloud.google.com/apis/credentials) section.
4. Click on `Create credentials` and select `OAuth client ID`.
5. Select `Web application` as the application type.
6. Add the following URIs to the `Authorized redirect URIs`:
- `http://localhost:3250/oauth/google/callback`
7. Add the following scopes
<img src="/assets/scopes_google.png" style='margin-top: 10px;' />

8. Click on `Create` and download the credentials as JSON and place them in the `config` folder.
Make sure the name of the file is `client_secret.env.json`.
Binary file added docs/assets/scopes_google.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions docs/configuration/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Make sure that all parameters are set correctly before starting the service.
| `signup.conf_code_expiry` | **Datatype:** Integer <br> **Default:** `5` <br> The time in minutes until the confirmation code expires. |
| `signup.conf_code_complexity` | **Datatype:** Integer <br> **Default:** `1` <br> The complexity of the confirmation code. <br> **Possible Values** <br> <ul><li>**1**: `4 Digit Numeric`</li><li>**2**: `6 Digit Numeric`</li><li>**3**: `4 Characters`</li><li>**4**: `6 Characters`</li></ul> |
| `signup.enable_welcome_email` | **Datatype:** Boolean <br> **Default:** `false` <br> Enable or disable the welcome E-Mail for new users. |
| `signup.oauth.providers_enabled` | **Datatype:** List <br> **Default:** `[]` <br> Enabled OAuth Providers. <br> **Possible Providers**<ul><li>[**google**](../advanced/oauth.md#google-oauth)</li></ul> |
| `signup.oauth.base_url` | **Datatype:** String <br> **Default:** `"http://localhost:3250/"` <br> The Base URL for the callback URL from OAuth Providers. When you host the service somewhere, you may want to change this to the official Domain instead of an IP. This is also the value you set when setting up your OAuth Providers. Make sure those values match. |


### E-Mail Configuration
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ nav:
- Advanced:
- Advanced E-Mail Templating: advanced/email_templates.md
- Further Customization: advanced/further_custom.md
- OAuth: advanced/oauth.md
theme:
name: material
logo: "ezauth_logo.png"
Expand Down
6 changes: 6 additions & 0 deletions src/api/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ async def login(login_form: LoginRequest, response: Response):
user = get_user_email_or_username(login_form.identifier)
if user is None:
raise HTTPException(status_code=404)
# Check if Password Exists (or if OAuth SignIn)
if not user.get("password", None):
raise HTTPException(
detail="You created your Account with OAuth. Please Reset your Password once logged in.",
status_code=406,
)
# Check Password
if not bcrypt.checkpw(
login_form.password.get_secret_value().encode("utf-8"),
Expand Down
2 changes: 2 additions & 0 deletions src/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from api.internal import router as internalRouter
from api.profile import router as profileRouter
from api.twofactor import router as twofactorRouter
from api.oauth_providers import router as oauthRouter
import logging

logging.basicConfig(format="%(asctime)s - %(message)s", level=logging.INFO)
Expand Down Expand Up @@ -41,3 +42,4 @@ async def root():
app.include_router(internalRouter)
app.include_router(profileRouter)
app.include_router(twofactorRouter)
app.include_router(oauthRouter)
15 changes: 15 additions & 0 deletions src/api/oauth_providers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from fastapi import APIRouter
import importlib
from tools.conf import SignupConfig

router = APIRouter(
prefix="/oauth",
tags=["OAuth"],
dependencies=[],
)


if "google" in SignupConfig.oauth_providers:
from .google import router as ggl

router.include_router(ggl)
112 changes: 112 additions & 0 deletions src/api/oauth_providers/google.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from fastapi import APIRouter, Request, BackgroundTasks, Response, HTTPException
from fastapi.responses import RedirectResponse
from google_auth_oauthlib.flow import Flow
from tools.conf import SignupConfig
import os, re
from crud.user import (
create_user,
get_user_by_google_uid,
get_user_email_or_username,
link_google_account,
)
from api.model import LoginResponse
import jwt
from crud.sessions import create_login_session
from tools import SignupConfig, SessionConfig

# Required for HTTP
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"

router = APIRouter(
prefix="/google",
dependencies=[],
)

# Initialize Googles OAuth Flow
flow = Flow.from_client_secrets_file(
client_secrets_file="/src/app/config/client_secret.env.json",
scopes=[
"https://www.googleapis.com/auth/userinfo.email",
"openid",
"https://www.googleapis.com/auth/userinfo.profile",
],
redirect_uri=SignupConfig.oauth_base_url + "/oauth/google/callback",
)


@router.get("/login")
async def oauth_login():
"""
# OAuth Login
## Description
This endpoint is used to initiate the OAuth login flow.
"""
auth_url, _ = flow.authorization_url()
return RedirectResponse(auth_url)


def login_usr(response: Response, usr: dict) -> LoginResponse:
# User already exists
session_token = create_login_session(usr["_id"])
if SessionConfig.auto_cookie:
response.set_cookie(
SessionConfig.auto_cookie_name,
session_token,
expires=SessionConfig.session_expiry_seconds,
)
return LoginResponse(session_token=session_token)


@router.get("/callback")
async def oauth_callback(
request: Request, background_tasks: BackgroundTasks, response: Response
):
"""
# OAuth Callback
## Description
This endpoint is used to handle the OAuth callback.
"""
# Get Information about user from Google
try:
token = flow.fetch_token(authorization_response=request.url.__str__())
except:
raise HTTPException(status_code=401, detail="Invalid OAuth Token")
jwt_id = token["id_token"]
jwt_decoded = jwt.decode(
jwt_id, algorithms=["RS256"], options={"verify_signature": False}
)

username = jwt_decoded["name"].replace(" ", "")
# Validate Username
if len(username) < 4 or re.search("[^a-zA-Z0-9]", username) is not None:
username = jwt_decoded["email"].split("@")[0]

# Check if SignIn Possible
usr = get_user_by_google_uid(jwt_decoded["sub"])
if usr:
return login_usr(response, usr)

# If users email already exists, link the google account
usr = get_user_email_or_username(jwt_decoded["email"])
if usr:
link_google_account(usr["_id"], jwt_decoded["sub"])
return login_usr(response, usr)

# Custom SignUp Form (Password Field missing etc.)
signup_form = {
"email": jwt_decoded["email"],
"username": username,
"password": "",
"google_uid": jwt_decoded["sub"],
}
# Persist user in DB
session_token = create_user(signup_form, background_tasks, signup_form)
if SessionConfig.auto_cookie:
response.set_cookie(
SessionConfig.auto_cookie_name,
session_token,
expires=SessionConfig.session_expiry_seconds,
)
return LoginResponse(session_token=session_token)
48 changes: 40 additions & 8 deletions src/crud/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,30 @@
import datetime


def link_google_account(user_id: str, google_uid: str) -> None:
"""Link a Google Account to a User
Args:
user_id (str): User ID
google_uid (str): Google UID
"""
users_collection.update_one(
{"_id": bson.ObjectId(user_id)}, {"$set": {"google_uid": google_uid}}
)


def get_user_by_google_uid(google_uid: str) -> dict:
"""Get a user by Google UID
Args:
google_uid (str): Google UID
Returns:
dict: User Data
"""
return users_collection.find_one({"google_uid": google_uid})


def get_batch_users(user_ids: list) -> list:
"""Get a batch of users by ID
Expand Down Expand Up @@ -141,7 +165,9 @@ def check_unique_usr(email: str, username: str) -> bool:


def create_user(
signup_model: UserSignupRequest, background_tasks: BackgroundTasks
signup_model: UserSignupRequest,
background_tasks: BackgroundTasks,
additional_data: dict = {},
) -> str | HTTPException:
"""Creates a User in the Database
Expand All @@ -151,20 +177,26 @@ def create_user(
Returns:
str: Session Token
"""
data = {
**(
signup_model.model_dump() if type(signup_model) == UserSignupRequest else {}
),
**additional_data,
}
# Save the Account into the database
try:
user_db = users_collection.insert_one(
{**signup_model.model_dump(), "createdAt": datetime.datetime.now()}
{
**data,
"createdAt": datetime.datetime.now(),
}
)
except pymongo.errors.DuplicateKeyError:
raise HTTPException(detail="Email or Username already exists.", status_code=409)
# Drop password from data
data.pop("password")
# User Created (Create Session Token and send Welcome Email)
session_token = sessions.create_login_session(user_db.inserted_id)
if SignupConfig.enable_welcome_email:
background_tasks.add_task(
send_email,
"WelcomeEmail",
signup_model.email,
**signup_model.model_dump(exclude={"password"})
)
background_tasks.add_task(send_email, "WelcomeEmail", data["email"], **data)
return session_token
4 changes: 3 additions & 1 deletion src/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ fastapi==0.111.0
pymongo==4.7.3
expiring-dict==1.1.0
bcrypt==4.1.3
pyotp==2.9.0
pyotp==2.9.0
google-auth-oauthlib==1.2.1
pyjwt[crypto]==2.8.0
4 changes: 3 additions & 1 deletion src/tools/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
config = json.load(open("/src/app/config/config.json", "rb"))

# Columns that should never leave EZAuth (maybe get more in the future)
insecure_cols = {"password": 0, "2fa_secret": 0}
insecure_cols = {"password": 0, "2fa_secret": 0, "google_uid": 0}
# Columns that can leave EZAuth but should only be used internally can be defined in config


Expand All @@ -13,6 +13,8 @@ class SignupConfig:
conf_code_expiry: int = config["signup"]["conf_code_expiry"]
conf_code_complexity: int = config["signup"]["conf_code_complexity"]
enable_welcome_email: bool = config["signup"]["enable_welcome_email"]
oauth_providers: list = config["signup"]["oauth"]["providers_enabled"]
oauth_base_url: str = str(config["signup"]["oauth"]["base_url"]).removesuffix("/")


class EmailConfig:
Expand Down
1 change: 1 addition & 0 deletions src/tools/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# Find Users by email and username fast (id is already indexed)
users_collection.create_index("email", unique=True)
users_collection.create_index("username", unique=True)
users_collection.create_index("google_uid", unique=True)
# Find Sessions by session_token fast
sessions_collection.create_index("session_token", unique=True)

Expand Down

0 comments on commit 559faa7

Please sign in to comment.