Skip to content

Commit

Permalink
Merge branch 'main' of https://github.com/JohnGrubba/ezauth
Browse files Browse the repository at this point in the history
  • Loading branch information
JohnGrubba committed Aug 7, 2024
2 parents 4727469 + 604163a commit b51e868
Show file tree
Hide file tree
Showing 8 changed files with 145 additions and 48 deletions.
8 changes: 6 additions & 2 deletions src/api/internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ async def profile(internal_req: InternalProfileRequest):


@router.patch("/profile")
async def update_profile(internal_req: InternalProfileRequest, update_data: dict):
async def update_profile(
internal_req: InternalProfileRequest,
background_tasks: BackgroundTasks,
update_data: dict,
):
"""
# Update Profile Information
Expand All @@ -90,7 +94,7 @@ async def update_profile(internal_req: InternalProfileRequest, update_data: dict
get_session(internal_req.session_token) if internal_req.session_token else None
)
usr = get_user(sess["user_id"] if sess else internal_req.user_id)
return update_public_user(usr["_id"], update_data)
return update_public_user(usr["_id"], update_data, background_tasks)


@router.get("/batch-users")
Expand Down
5 changes: 4 additions & 1 deletion src/api/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
ConfirmEmailCodeRequest,
)
from crud.user import get_user_email_or_username, get_public_user, change_pswd
from crud.sessions import create_login_session, delete_session
from crud.sessions import create_login_session, delete_session, clear_sessions_for_user
import bcrypt
import pyotp
import json
Expand Down Expand Up @@ -117,6 +117,9 @@ async def confirm_reset(code: ConfirmEmailCodeRequest):
change_pswd(user["_id"], change_req["new_pswd"])
r.delete("reset_pswd:" + user["email"])

# Delete all sessions
clear_sessions_for_user(user["_id"])


@router.post(
"/login",
Expand Down
60 changes: 45 additions & 15 deletions src/api/profile.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi import APIRouter, Depends, HTTPException, Response, BackgroundTasks
from tools import AccountFeaturesConfig
from api.model import DeleteAccountRequest, ProfileUpdateRequest
from api.model import (
DeleteAccountRequest,
ProfileUpdateRequest,
ConfirmEmailCodeRequest,
)
import bcrypt
from crud.user import update_public_user, schedule_delete_user
from crud.user import update_public_user, schedule_delete_user, change_email
from api.dependencies.authenticated import (
get_pub_user_dep,
get_dangerous_user_dep,
get_user_dep,
)
from tools import SessionConfig
from crud.user import get_public_user
from tools import SessionConfig, r
from crud.user import get_public_user, get_user_email_or_username
import bson
import json

router = APIRouter(
prefix="/profile",
tags=["Profile"],
dependencies=[Depends(get_pub_user_dep)],
)
router = APIRouter(prefix="/profile", tags=["Profile"])


@router.get("")
Expand All @@ -32,7 +33,9 @@ async def profile(user: dict = Depends(get_pub_user_dep)):

@router.patch("", status_code=200)
async def update_profile(
update_data: ProfileUpdateRequest, user: dict = Depends(get_user_dep)
update_data: ProfileUpdateRequest,
background_tasks: BackgroundTasks,
user: dict = Depends(get_user_dep),
):
"""
# Update Profile Information
Expand All @@ -42,7 +45,33 @@ async def update_profile(
## Description
This endpoint is used to update the profile information of the user.
"""
return update_public_user(user["_id"], update_data.model_dump(exclude_none=True))
return update_public_user(
user["_id"], update_data.model_dump(exclude_none=True), background_tasks
)


@router.post("/confirm-email-change", status_code=204)
async def confirm_new_email(
payload: ConfirmEmailCodeRequest,
user: dict = Depends(get_user_dep),
):
"""
# Confirm New Email
## Description
This endpoint is used to confirm the new email of the user.
"""
acc = r.get("emailchange:" + str(user["_id"]))
if not acc:
raise HTTPException(detail="No Account Found with this code.", status_code=404)
acc = json.loads(acc)
if str(acc["code"]) != str(payload.code):
raise HTTPException(detail="Invalid Code", status_code=404)

# Update Email
change_email(user["_id"], acc["new-email"])

r.delete("emailchange:" + str(user["_id"]))


@router.delete("", status_code=204)
Expand Down Expand Up @@ -75,20 +104,21 @@ async def delete_account(
)


@router.get("/profile/{user_id}")
async def get_profile(user_id: str):
@router.get("/profile/{identifier}")
async def get_profile(identifier: str):
"""
# Get Profile Information
## Description
This endpoint is used to get the public profile information of the user.
"""
try:
usr = get_public_user(user_id)
usr = get_user_email_or_username(identifier)
if not usr:
raise HTTPException(status_code=404, detail="User not found.")
except bson.errors.InvalidId:
raise HTTPException(status_code=404, detail="User not found.")
# Hide email
usr = get_public_user(usr["_id"])
usr.pop("email")
return usr
11 changes: 0 additions & 11 deletions src/api/profile_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,6 @@ def test_update_username_valid(fixturesessiontoken_user):
assert resp_json.get("createdAt") is not None


def test_update_email(fixturesessiontoken_user):
client.cookies.set("session", fixturesessiontoken_user[0])
# E-Mail should not be updateable through this endpoint
response = client.patch("/profile", json={"email": "[email protected]"})
resp_json = response.json()
assert response.status_code == 200
assert resp_json.get("email") == fixturesessiontoken_user[1]["email"]
assert resp_json.get("username") == fixturesessiontoken_user[1]["username"]
assert resp_json.get("createdAt") is not None


def test_update_password(fixturesessiontoken_user):
client.cookies.set("session", fixturesessiontoken_user[0])
# Password should not be updateable through this endpoint
Expand Down
2 changes: 1 addition & 1 deletion src/api/signup_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ def test_create_account_duplicate_username(fixtureuser):
assert response.status_code == 409


def test_create_account_duplicate_username(fixtureuser):
def test_create_account_duplicate_email(fixtureuser):
# Collations don't work with mongomock, so we only check direct duplicate
response = client.post(
"/signup",
Expand Down
64 changes: 61 additions & 3 deletions src/crud/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@
AccountFeaturesConfig,
default_signup_fields,
case_insensitive_collation,
all_ids,
regenerate_ids,
r,
)
from fastapi import HTTPException, BackgroundTasks, Request
from api.model import UserSignupRequest
import pymongo
import bson
import datetime
import json
from crud.sessions import clear_sessions_for_user


Expand Down Expand Up @@ -81,7 +85,22 @@ def get_batch_users(user_ids: list) -> list:
return list(users_collection.find({"_id": {"$in": bson_ids}}, insecure_cols))


def update_public_user(user_id: str, data: dict) -> None:
def change_email(user_id: str, new_email: str) -> None:
"""Change the email of a user
Args:
user_id (str): User ID
new_email (str): New Email
"""
# Retrieve the existing user data
users_collection.update_one(
{"_id": bson.ObjectId(user_id)}, {"$set": {"email": new_email}}
)


def update_public_user(
user_id: str, data: dict, background_tasks: BackgroundTasks
) -> None:
"""Updates Public User Data
Args:
Expand All @@ -105,11 +124,50 @@ def update_public_user(user_id: str, data: dict) -> None:
else:
data = {k: v for k, v in data.items() if k in existing_user}

# Check if username in use
if data.get("username", "") and get_user_email_or_username(
# Check if username field is set and if user sends different one and if it is already in use
if (
data.get("username", "")
and existing_user["username"] != data.get("username", "")
and get_user_email_or_username(data.get("username", ""))
):
raise HTTPException(detail="Username already in use.", status_code=409)
# Check if email field is set and if user sends different one and if it is already in use
if data.get("email", "") and existing_user["email"] != data.get("email", ""):
# Check if someone else has this email already
if get_user_email_or_username(data["email"]):
raise HTTPException(detail="Email already in use.", status_code=409)
data["email"] = data["email"].lower()

if r.getex("emailchange:" + str(existing_user["_id"])):
raise HTTPException(
detail="Email Change already requested. Please confirm the email change.",
status_code=409,
)

existing_user.pop("password")
# Send Confirmation E-Mail for new email address
if not all_ids:
# Generate new ids
regenerate_ids()
# Get a unique ID for confirmation email
unique_id = all_ids.pop()
# Generate and send confirmation email
background_tasks.add_task(
send_email,
"ConfirmEmail",
data["email"],
**existing_user,
code=unique_id,
time=SignupConfig.conf_code_expiry,
)

r.setex(
"emailchange:" + str(existing_user["_id"]),
SignupConfig.conf_code_expiry * 60,
json.dumps({"new-email": data["email"], "code": unique_id}),
)

data.pop("email")

return users_collection.find_one_and_update(
{"_id": bson.ObjectId(user_id)},
Expand Down
2 changes: 1 addition & 1 deletion src/tools/conf/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@
# Columns that should never leave EZAuth (maybe get more in the future)
default_signup_fields = {"username", "email", "password"}
insecure_cols = {"password": 0, "2fa_secret": 0, "google_uid": 0, "github_uid": 0}
not_updateable_cols_internal = ["email", "createdAt", "expiresAfter"]
not_updateable_cols_internal = ["createdAt", "expiresAfter"]
# Columns that can leave EZAuth but should only be used internally can be defined in config
41 changes: 27 additions & 14 deletions src/tools/confirmation_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,46 @@

all_ids = []

# Has to stay below 9000
amount = 100

def regenerate_ids():

def regenerate_ids(complexity_test: int = None):
global all_ids
# Generate and shuffle 10000 unique IDs for confirmation email (Depending on complexity)
match (SignupConfig.conf_code_complexity):
match (complexity_test if complexity_test else SignupConfig.conf_code_complexity):
case 2:
# Random 6 Digit Numbers
all_ids = [str(random.randint(100000, 999999)) for _ in range(10000)]
random.shuffle(all_ids)
all_ids = random.sample(range(100000, 999999), amount)
case 3:
# Random 4 Character Strings
all_ids = [
"".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=4))
for _ in range(10000)
]
all_ids = list(
set(
[
"".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=4))
for _ in range(amount)
]
)
)
random.shuffle(all_ids)
case 4:
# Random 6 Character Strings
all_ids = [
"".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=6))
for _ in range(10000)
]
all_ids = list(
set(
[
"".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=6))
for _ in range(amount)
]
)
)
random.shuffle(all_ids)
case _:
# Default Case (1)
all_ids = [str(i) for i in range(10000)]
random.shuffle(all_ids)
all_ids = random.sample(range(1000, 10000), amount)


regenerate_ids()

if __name__ == "__main__":
regenerate_ids(4)
print(all_ids)

0 comments on commit b51e868

Please sign in to comment.