diff --git a/apps/api/src/routers/users.py b/apps/api/src/routers/users.py index 55365db2..e226f6b6 100644 --- a/apps/api/src/routers/users.py +++ b/apps/api/src/routers/users.py @@ -1,6 +1,11 @@ from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile +from pydantic import EmailStr from sqlmodel import Session +from src.services.users.password_reset import ( + change_password_with_reset_code, + send_reset_password_code, +) from src.services.orgs.orgs import get_org_join_mechanism from src.security.auth import get_current_user from src.core.events.database import get_db_session @@ -210,6 +215,42 @@ async def api_update_user_password( return await update_user_password(request, db_session, current_user, user_id, form) +@router.post("/reset_password/change_password/{email}", tags=["users"]) +async def api_change_password_with_reset_code( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + new_password: str, + email: EmailStr, + org_id: int, + reset_code: str, +): + """ + Update User Password with reset code + """ + return await change_password_with_reset_code( + request, db_session, current_user, new_password, org_id, email, reset_code + ) + + +@router.post("/reset_password/send_reset_code/{email}", tags=["users"]) +async def api_send_password_reset_email( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + email: EmailStr, + org_id: int, +): + """ + Update User Password + """ + return await send_reset_password_code( + request, db_session, current_user, org_id, email + ) + + @router.delete("/user_id/{user_id}", tags=["users"]) async def api_delete_user( *, diff --git a/apps/api/src/services/orgs/invites.py b/apps/api/src/services/orgs/invites.py index 8d68c0ce..cc5aae57 100644 --- a/apps/api/src/services/orgs/invites.py +++ b/apps/api/src/services/orgs/invites.py @@ -2,6 +2,7 @@ import json import random import string import uuid +from pydantic import EmailStr import redis from datetime import datetime, timedelta from sqlmodel import Session, select @@ -139,7 +140,7 @@ async def get_invite_codes( for invite_code in invite_codes: invite_code = r.get(invite_code) - invite_code = json.loads(invite_code) + invite_code = json.loads(invite_code) # type: ignore invite_codes_list.append(invite_code) return invite_codes_list @@ -258,7 +259,7 @@ def send_invite_email( org: OrganizationRead, invite_code_uuid: str, user: UserRead, - email: str, + email: EmailStr, ): LH_CONFIG = get_learnhouse_config() redis_conn_string = LH_CONFIG.redis_config.redis_connection_string @@ -284,7 +285,7 @@ def send_invite_email( # Send email if invite: invite = r.get(invite[0]) - invite = json.loads(invite) + invite = json.loads(invite) # type: ignore # send email send_email( diff --git a/apps/api/src/services/users/emails.py b/apps/api/src/services/users/emails.py new file mode 100644 index 00000000..880be532 --- /dev/null +++ b/apps/api/src/services/users/emails.py @@ -0,0 +1,45 @@ +from pydantic import EmailStr +from src.db.organizations import OrganizationRead +from src.db.users import UserRead +from src.services.email.utils import send_email + + +def send_account_creation_email( + user: UserRead, + email: EmailStr, +): + # send email + return send_email( + to=email, + subject=f"Welcome to LearnHouse, {user.username}!", + body=f""" + +
+Hello {user.username}
+Welcome to LearnHouse! , get started by creating your own organization or join a one.
+Need some help to get started ? LearnHouse Academy
+ + +""", + ) + + +def send_password_reset_email( + reset_email_invite_uuid: str, + user: UserRead, + organization: OrganizationRead, + email: EmailStr, +): + # send email + return send_email( + to=email, + subject="Reset your password", + body=f""" + + +Hello {user.username}
+Click here to reset your password.
+ + +""", + ) diff --git a/apps/api/src/services/users/password_reset.py b/apps/api/src/services/users/password_reset.py new file mode 100644 index 00000000..4c379fee --- /dev/null +++ b/apps/api/src/services/users/password_reset.py @@ -0,0 +1,203 @@ +from datetime import datetime, timedelta +import json +import random +import redis +import string +import uuid +from fastapi import HTTPException, Request +from pydantic import EmailStr +from sqlmodel import Session, select +from src.db.organizations import Organization, OrganizationRead +from src.security.security import security_hash_password +from config.config import get_learnhouse_config +from src.services.users.emails import ( + send_password_reset_email, +) +from src.db.users import ( + AnonymousUser, + PublicUser, + User, + UserRead, +) + + +async def send_reset_password_code( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + org_id: int, + email: EmailStr, +): + # Get user + statement = select(User).where(User.email == email) + user = db_session.exec(statement).first() + + if not user: + raise HTTPException( + status_code=400, + detail="User does not exist", + ) + + # Get org + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + + if not org: + raise HTTPException( + status_code=400, + detail="Organization not found", + ) + + # Redis init + LH_CONFIG = get_learnhouse_config() + redis_conn_string = LH_CONFIG.redis_config.redis_connection_string + + if not redis_conn_string: + raise HTTPException( + status_code=500, + detail="Redis connection string not found", + ) + + # Connect to Redis + r = redis.Redis.from_url(redis_conn_string) + + if not r: + raise HTTPException( + status_code=500, + detail="Could not connect to Redis", + ) + + # Generate reset code + def generate_code(length=5): + letters_and_digits = string.ascii_letters + string.digits + return "".join(random.choice(letters_and_digits) for _ in range(length)) + + generated_reset_code = generate_code() + reset_email_invite_uuid = f"reset_email_invite_code_{uuid.uuid4()}" + + ttl = int(datetime.now().timestamp()) + 60 * 60 * 1 # 1 hour + + resetCodeObject = { + "reset_code": generated_reset_code, + "reset_email_invite_uuid": reset_email_invite_uuid, + "reset_code_expires": ttl, + "reset_code_type": "signup", + "created_at": datetime.now().isoformat(), + "created_by": user.user_uuid, + "org_uuid": org.org_uuid, + } + + r.set( + f"{reset_email_invite_uuid}:user:{user.user_uuid}:org:{org.org_uuid}:code:{generated_reset_code}", + json.dumps(resetCodeObject), + ex=ttl, + ) + + user = UserRead.from_orm(user) + + org = OrganizationRead.from_orm(org) + + # Send reset code via email + isEmailSent = send_password_reset_email( + reset_email_invite_uuid=reset_email_invite_uuid, + user=user, + organization=org, + email=user.email, + ) + + if not isEmailSent: + raise HTTPException( + status_code=500, + detail="Issue with sending reset code", + ) + + return "Reset code sent" + + +async def change_password_with_reset_code( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + new_password: str, + org_id: int, + email: EmailStr, + reset_code: str, +): + # Get user + statement = select(User).where(User.email == email) + user = db_session.exec(statement).first() + + if not user: + raise HTTPException( + status_code=400, + detail="User does not exist", + ) + + # Get org + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + + if not org: + raise HTTPException( + status_code=400, + detail="Organization not found", + ) + + + # Redis init + LH_CONFIG = get_learnhouse_config() + redis_conn_string = LH_CONFIG.redis_config.redis_connection_string + + if not redis_conn_string: + raise HTTPException( + status_code=500, + detail="Redis connection string not found", + ) + + # Connect to Redis + r = redis.Redis.from_url(redis_conn_string) + + if not r: + raise HTTPException( + status_code=500, + detail="Could not connect to Redis", + ) + + # Get reset code + reset_code_key = f"*:user:{user.user_uuid}:org:{org.org_uuid}:code:{reset_code}" + keys = r.keys(reset_code_key) + + if not keys: + raise HTTPException( + status_code=400, + detail="Reset code not found", + ) + + # Get reset code object + reset_code_value = r.get(keys[0]) + + if reset_code_value is None: + raise HTTPException( + status_code=400, + detail="Reset code value not found", + ) + reset_code_object = json.loads(reset_code_value) + + # Check if reset code is expired + if reset_code_object["reset_code_expires"] < int(datetime.now().timestamp()): + raise HTTPException( + status_code=400, + detail="Reset code expired", + ) + + # Change password + user.password = await security_hash_password(new_password) + db_session.add(user) + + db_session.commit() + db_session.refresh(user) + + # Delete reset code + r.delete(keys[0]) + + return "Password changed" diff --git a/apps/api/src/services/users/users.py b/apps/api/src/services/users/users.py index 279a9aec..88d5eb09 100644 --- a/apps/api/src/services/users/users.py +++ b/apps/api/src/services/users/users.py @@ -3,6 +3,9 @@ from typing import Literal from uuid import uuid4 from fastapi import HTTPException, Request, UploadFile, status from sqlmodel import Session, select +from src.services.users.emails import ( + send_account_creation_email, +) from src.services.orgs.invites import get_invite_code from src.services.users.avatars import upload_avatar from src.db.roles import Role, RoleRead @@ -102,6 +105,12 @@ async def create_user( user = UserRead.from_orm(user) + # Send Account creation email + send_account_creation_email( + user=user, + email=user.email, + ) + return user @@ -182,6 +191,12 @@ async def create_user_without_org( user = UserRead.from_orm(user) + # Send Account creation email + send_account_creation_email( + user=user, + email=user.email, + ) + return user @@ -331,7 +346,6 @@ async def update_user_password( return user - async def read_user_by_id( request: Request, db_session: Session,