diff --git a/src/api/internal.py b/src/api/internal.py index bcee551..272d10b 100644 --- a/src/api/internal.py +++ b/src/api/internal.py @@ -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 @@ -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") diff --git a/src/api/login.py b/src/api/login.py index 409fce7..20efba6 100644 --- a/src/api/login.py +++ b/src/api/login.py @@ -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 @@ -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", diff --git a/src/api/profile.py b/src/api/profile.py index f3ab3aa..9ff4694 100644 --- a/src/api/profile.py +++ b/src/api/profile.py @@ -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("") @@ -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 @@ -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) @@ -75,8 +104,8 @@ 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 @@ -84,11 +113,12 @@ async def get_profile(user_id: str): 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 diff --git a/src/api/profile_test.py b/src/api/profile_test.py index f1ccb61..34d706f 100644 --- a/src/api/profile_test.py +++ b/src/api/profile_test.py @@ -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": "wrongemail@gmail.com"}) - 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 diff --git a/src/api/signup_test.py b/src/api/signup_test.py index 48d3736..da9bbb8 100644 --- a/src/api/signup_test.py +++ b/src/api/signup_test.py @@ -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", diff --git a/src/crud/user.py b/src/crud/user.py index 889d5a4..0d9a70a 100644 --- a/src/crud/user.py +++ b/src/crud/user.py @@ -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 @@ -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: @@ -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)}, diff --git a/src/tools/conf/conf.py b/src/tools/conf/conf.py index 739e17f..812616b 100644 --- a/src/tools/conf/conf.py +++ b/src/tools/conf/conf.py @@ -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 diff --git a/src/tools/confirmation_codes.py b/src/tools/confirmation_codes.py index 4cd0bcd..35ca155 100644 --- a/src/tools/confirmation_codes.py +++ b/src/tools/confirmation_codes.py @@ -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)