diff --git a/config/configtemplate.json b/config/configtemplate.json index 6cb58b2..1776567 100644 --- a/config/configtemplate.json +++ b/config/configtemplate.json @@ -25,6 +25,7 @@ ] }, "account_features": { - "enable_change_password": true + "enable_change_password": true, + "change_password_confirm_email": true } } \ No newline at end of file diff --git a/config/email/ChangePassword.html b/config/email/ChangePassword.html index e69de29..238a163 100644 --- a/config/email/ChangePassword.html +++ b/config/email/ChangePassword.html @@ -0,0 +1,32 @@ + + + + + + + + + + + Reset your Password + + + +

Did you just request a password reset?

+

If it was you, then enter this code to confirm the change:

+

{code}

+

If it wasn't you, then ignore this email.

+

This code expires in {time} minutes.

+ + + \ No newline at end of file diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index f4dee4d..5727073 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -54,4 +54,5 @@ Make sure that all parameters are set correctly before starting the service. ### Account Features Configuration | Parameter | Description | |------------|-------------| -| `account_features.enable_change_password` | **Datatype:** Boolean
**Default:** `true`
Enable or disable the password reset feature. | \ No newline at end of file +| `account_features.enable_change_password` | **Datatype:** Boolean
**Default:** `true`
Enable or disable the password reset feature. | +| `account_features.change_password_confirm_email` | **Datatype:** Boolean
**Default:** `true`
Enable or disable the password change confirmation E-Mail. | \ No newline at end of file diff --git a/docs/getting-started/email_config.md b/docs/getting-started/email_config.md index 460644f..bc61903 100644 --- a/docs/getting-started/email_config.md +++ b/docs/getting-started/email_config.md @@ -1,5 +1,4 @@ -# Default E-Mail Templates - +# E-Mail Templates EZAuth uses a set of default E-mail templates to send out e-mails to users. These templates are stored in the `config/email` folder. You can customize these templates as per your requirements. !!! Info "E-Mail Subject" @@ -27,7 +26,17 @@ EZAuth uses a set of default E-mail templates to send out e-mails to users. Thes - Will be sent out after the user has successfully verified their email address. - File Name: `WelcomeMail.html` -# Custom E-Mail Templates +### 3. **Password Reset** +- Can be enabled in the `config.json` file. +- Will be sent out when a user requests a password reset. +- File Name: `ChangePassword.html` + +#### Additional Placeholders +- `{{code}}`: The confirmation code to confirm the password change. +- `{{time}}`: Time remaining before the confirmation code expires in minutes. (e.g. 5). This will be the same as the `signup.conf_code_expiry` value in the `config.json` file. + + +## Custom E-Mail Templates You can add custom e-mail templates to the `config/email` folder. Whenever you are able to specifiy a E-Mail Template, you can specify the template you want to use by providing the file name without the `.html` extension. Example: `WelcomeMail` will use the `WelcomeMail.html` template. diff --git a/src/api/dependencies/authenticated.py b/src/api/dependencies/authenticated.py index d120d97..8c944d2 100644 --- a/src/api/dependencies/authenticated.py +++ b/src/api/dependencies/authenticated.py @@ -1,6 +1,7 @@ from fastapi import HTTPException, Cookie from tools import SessionConfig, InternalConfig from tools import users_collection, sessions_collection +from crud.user import get_public_user import logging @@ -14,9 +15,7 @@ async def get_pub_user( if not session: logging.debug("No session found") raise HTTPException(status_code=401) - user = users_collection.find_one( - {"_id": session["user_id"]}, InternalConfig.internal_columns - ) + user = get_public_user(session["user_id"]) if not user: logging.debug("No user for session found") raise HTTPException(status_code=401) diff --git a/src/api/internal.py b/src/api/internal.py index ad00088..41e05fc 100644 --- a/src/api/internal.py +++ b/src/api/internal.py @@ -1,8 +1,11 @@ from fastapi import APIRouter, Header, HTTPException, Depends, BackgroundTasks -from tools import broadcast_emails, InternalConfig -from api.model import BroadCastEmailRequest +from tools import broadcast_emails, InternalConfig, bson_to_json +from api.model import BroadCastEmailRequest, InternalProfileRequest +from crud.user import get_user +from crud.sessions import get_session from threading import Lock + email_task_running = Lock() @@ -53,12 +56,15 @@ async def broadcast_email( return {"status": "E-Mail Task Started"} -@router.get("/profile") -async def profile(): +@router.post("/profile") +async def profile(internal_req: InternalProfileRequest): """ # Get Profile Information ## Description This endpoint is used to get the whole profile information of the user. (Including Internal Information) """ - return {"status": "ok"} + sess = ( + get_session(internal_req.session_token) if internal_req.session_token else None + ) + return bson_to_json(get_user(sess["user_id"] if sess else internal_req.user_id)) diff --git a/src/api/login.py b/src/api/login.py index c601b67..78a66ee 100644 --- a/src/api/login.py +++ b/src/api/login.py @@ -1,18 +1,18 @@ -from fastapi import APIRouter, HTTPException, Response +from fastapi import APIRouter, HTTPException, Response, Cookie from api.model import LoginRequest, LoginResponse from crud.user import get_user_email_or_username -from crud.sessions import create_login_session +from crud.sessions import create_login_session, delete_session import bcrypt from tools.conf import SessionConfig router = APIRouter( - prefix="/login", + prefix="", tags=["Log In"], ) @router.post( - "/", + "/login", status_code=200, responses={ 401: {"description": "Invalid Credentials"}, @@ -46,3 +46,16 @@ async def login(login_form: LoginRequest, response: Response): ) return LoginResponse(session_token=session_token) raise HTTPException(detail="Invalid Password", status_code=401) + + +@router.get("/logout", status_code=204) +async def logout( + session_token: str = Cookie(default=None, alias=SessionConfig.auto_cookie_name) +): + """ + # Log Out (Delete Session) + + ## Description + This endpoint is used to log out a user and delete the session. + """ + delete_session(session_token) diff --git a/src/api/model.py b/src/api/model.py index e38e1fa..d36afdc 100644 --- a/src/api/model.py +++ b/src/api/model.py @@ -1,8 +1,14 @@ from pydantic import BaseModel, field_validator, EmailStr, SecretStr, ConfigDict +from typing import Optional import re import bcrypt +class InternalProfileRequest(BaseModel): + session_token: Optional[str] = None + user_id: Optional[str] = None + + class LoginRequest(BaseModel): identifier: str password: SecretStr @@ -22,24 +28,9 @@ class BroadCastEmailRequest(BaseModel): mongodb_search_condition: dict -class UserSignupRequest(BaseModel): - email: EmailStr - username: str +class PasswordHashed(BaseModel): password: SecretStr - model_config = ConfigDict( - extra="allow", - ) - - @field_validator("username") - @classmethod - def username_check(cls, username: str) -> str: - if len(username) < 4: - raise ValueError("Username must be at least 4 characters long") - elif re.search("[^a-zA-Z0-9]", username) is not None: - raise ValueError("Username must only contain letters and numbers") - return username - @field_validator("password") @classmethod def password_check_hash(cls, password: SecretStr) -> str: @@ -58,3 +49,21 @@ def password_check_hash(cls, password: SecretStr) -> str: "utf-8" ) return hashed_pswd + + +class UserSignupRequest(PasswordHashed): + email: EmailStr + username: str + + model_config = ConfigDict( + extra="allow", + ) + + @field_validator("username") + @classmethod + def username_check(cls, username: str) -> str: + if len(username) < 4: + raise ValueError("Username must be at least 4 characters long") + elif re.search("[^a-zA-Z0-9]", username) is not None: + raise ValueError("Username must only contain letters and numbers") + return username diff --git a/src/api/profile.py b/src/api/profile.py index 9f3a081..04ce6d7 100644 --- a/src/api/profile.py +++ b/src/api/profile.py @@ -1,6 +1,12 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Cookie, HTTPException, BackgroundTasks +from tools.conf import SessionConfig, AccountFeaturesConfig, SignupConfig +from api.model import PasswordHashed +from crud.user import change_pswd from api.dependencies.authenticated import get_pub_user - +from crud.user import get_user +from crud.sessions import get_session +from tools import send_email, all_ids, regenerate_ids +from expiring_dict import ExpiringDict router = APIRouter( prefix="/profile", @@ -8,6 +14,8 @@ dependencies=[Depends(get_pub_user)], ) +temp_changes = ExpiringDict(ttl=SignupConfig.conf_code_expiry * 60, interval=10) + @router.get("/") async def profile(user: dict = Depends(get_pub_user)): @@ -18,3 +26,75 @@ async def profile(user: dict = Depends(get_pub_user)): This endpoint is used to get the public profile information of the user. """ return user + + +@router.post("/change-password", status_code=204) +async def change_password( + new_password: PasswordHashed, + background_tasks: BackgroundTasks, + session_token: str = Cookie(default=None, alias=SessionConfig.auto_cookie_name), + public_user: dict = Depends(get_pub_user), +): + """ + # Change Password + + ## Description + This endpoint is used to change the password of the user. + """ + if not AccountFeaturesConfig.enable_change_password: + raise HTTPException(status_code=403, detail="Changing Password is disabled.") + sess = get_session(session_token) + if not sess: + raise HTTPException(status_code=401) + user = get_user(sess["user_id"]) + # Send Confirmation E-Mail (If enabled) + if AccountFeaturesConfig.change_password_confirm_email: + if not all_ids: + # Generate new ids + regenerate_ids() + # Get a unique ID for confirmation email + unique_id = all_ids.pop() + temp_changes[user["email"]] = { + "action": "password_reset", + "code": unique_id, + "new_pswd": new_password.password, + } + background_tasks.add_task( + send_email, + "ChangePassword", + user["email"], + code=unique_id, + time=SignupConfig.conf_code_expiry, + **public_user, + ) + else: + change_pswd(user["_id"], new_password.password) + + +@router.post("/confirm-password", status_code=204) +async def confirm_password( + code: str | int, + session_token: str = Cookie(default=None, alias=SessionConfig.auto_cookie_name), +): + """ + # Confirm Password Change + + ## Description + This endpoint is used to confirm a password change. + """ + sess = get_session(session_token) + if not sess: + raise HTTPException(status_code=401) + user = get_user(sess["user_id"]) + if not AccountFeaturesConfig.enable_change_password: + raise HTTPException(status_code=403, detail="Changing Password is disabled.") + try: + change_req = temp_changes[user["email"]] + except KeyError: + raise HTTPException(status_code=404, detail="No Password Change Request found.") + # Check code + if change_req["code"] != code: + raise HTTPException(status_code=401, detail="Invalid Code") + + change_pswd(user["_id"], change_req["new_pswd"]) + del temp_changes[user["email"]] diff --git a/src/api/signup.py b/src/api/signup.py index cc27dd3..2fdfe27 100644 --- a/src/api/signup.py +++ b/src/api/signup.py @@ -3,7 +3,7 @@ from tools import send_email from tools import SignupConfig, SessionConfig from expiring_dict import ExpiringDict -import random +from tools import all_ids, regenerate_ids from crud.user import create_user, check_unique_usr router = APIRouter( @@ -19,32 +19,6 @@ temp_accounts = ExpiringDict(ttl=SignupConfig.conf_code_expiry * 60, interval=10) -# Generate and shuffle 10000 unique IDs for confirmation email (Depending on complexity) -match (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) - case 3: - # Random 4 Character Strings - all_ids = [ - "".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=4)) - for _ in range(10000) - ] - random.shuffle(all_ids) - case 4: - # Random 6 Character Strings - all_ids = [ - "".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=6)) - for _ in range(10000) - ] - random.shuffle(all_ids) - case _: - # Default Case (1) - all_ids = [str(i) for i in range(10000)] - random.shuffle(all_ids) - - @router.post( "/", status_code=200, @@ -81,11 +55,9 @@ async def signup( raise HTTPException( detail="Email or Username already exists", status_code=409 ) - # If all numbers have been used, raise an exception if not all_ids: - raise Exception( - "All unique IDs have been used. More than 10000 signups in a short time." - ) + # Generate new ids + regenerate_ids() # Get a unique ID for confirmation email unique_id = all_ids.pop() # Save the Account into the expiring dict (delete if user refuses to confirm email in time) diff --git a/src/crud/sessions.py b/src/crud/sessions.py index 5349d20..3116550 100644 --- a/src/crud/sessions.py +++ b/src/crud/sessions.py @@ -36,14 +36,30 @@ def create_login_session(user_id: str) -> str: return session_token -def check_session(session_token: str) -> bool: +def get_session(session_token: str) -> dict: """ - Check if the session is valid. + Check if the session is valid and return the session information. Args: session_token (str): Session Token Returns: - bool: True if the session is valid + dict: Session Information """ - return sessions_collection.find_one({"session_token": session_token}) is not None + return sessions_collection.find_one({"session_token": session_token}) + + +def delete_session(session_token: str) -> bool: + """ + Delete a session. + + Args: + session_token (str): Session Token + + Returns: + bool: True if the session was deleted + """ + return ( + sessions_collection.find_one_and_delete({"session_token": session_token}) + is not None + ) diff --git a/src/crud/user.py b/src/crud/user.py index 36e1e3b..57ffece 100644 --- a/src/crud/user.py +++ b/src/crud/user.py @@ -1,11 +1,56 @@ from crud import sessions -from tools import users_collection, SignupConfig, send_email +from tools import ( + users_collection, + SignupConfig, + send_email, + InternalConfig, + insecure_cols, +) from fastapi import HTTPException, BackgroundTasks, Response from api.model import UserSignupRequest, LoginResponse -import pymongo +import pymongo, bson import datetime +def change_pswd(user_id: str, new_password: str) -> None: + """Changes the password of a user + + Args: + user_id (str): User ID + new_password (str): New Password + """ + users_collection.update_one( + {"_id": bson.ObjectId(user_id)}, + {"$set": {"password": new_password}}, + ) + + +def get_user(user_id: str) -> dict: + """Gets a user by ID + + Args: + user_id (str): User ID + + Returns: + dict: User Data + """ + return users_collection.find_one({"_id": bson.ObjectId(user_id)}, insecure_cols) + + +def get_public_user(user_id: str) -> dict: + """Gets public columns of a user + + Args: + user_id (str): User ID + + Returns: + dict: Public User Data + """ + return users_collection.find_one( + {"_id": bson.ObjectId(user_id)}, InternalConfig.internal_columns + ) + + def get_user_email_or_username(credential: str) -> dict: """Get a user by email or username diff --git a/src/tools/__init__.py b/src/tools/__init__.py index 974fb09..73276fe 100644 --- a/src/tools/__init__.py +++ b/src/tools/__init__.py @@ -1,9 +1,11 @@ -from .db import users_collection, sessions_collection +from .db import users_collection, sessions_collection, bson_to_json from .conf import ( SignupConfig, EmailConfig, SessionConfig, InternalConfig, AccountFeaturesConfig, + insecure_cols, ) from .mail import send_email, broadcast_emails +from .confirmation_codes import all_ids, regenerate_ids diff --git a/src/tools/conf.py b/src/tools/conf.py index 93c82bf..7aaea93 100644 --- a/src/tools/conf.py +++ b/src/tools/conf.py @@ -40,3 +40,6 @@ class InternalConfig: class AccountFeaturesConfig: enable_change_password: bool = config["account_features"]["enable_change_password"] + change_password_confirm_email: bool = config["account_features"][ + "change_password_confirm_email" + ] diff --git a/src/tools/confirmation_codes.py b/src/tools/confirmation_codes.py new file mode 100644 index 0000000..4cd0bcd --- /dev/null +++ b/src/tools/confirmation_codes.py @@ -0,0 +1,36 @@ +import random +from tools import SignupConfig + + +all_ids = [] + + +def regenerate_ids(): + global all_ids + # Generate and shuffle 10000 unique IDs for confirmation email (Depending on complexity) + match (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) + case 3: + # Random 4 Character Strings + all_ids = [ + "".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=4)) + for _ in range(10000) + ] + random.shuffle(all_ids) + case 4: + # Random 6 Character Strings + all_ids = [ + "".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=6)) + for _ in range(10000) + ] + random.shuffle(all_ids) + case _: + # Default Case (1) + all_ids = [str(i) for i in range(10000)] + random.shuffle(all_ids) + + +regenerate_ids() diff --git a/src/tools/db.py b/src/tools/db.py index 7a542bc..5784e3e 100644 --- a/src/tools/db.py +++ b/src/tools/db.py @@ -1,5 +1,5 @@ from pymongo import MongoClient -import os +import os, bson.json_util, json from tools.conf import SessionConfig DATABASE_URL = os.getenv("DATABASE_URL") @@ -23,3 +23,19 @@ sessions_collection.create_index( "createdAt", expireAfterSeconds=SessionConfig.session_expiry_seconds ) + + +def bson_to_json(data: bson.BSON) -> dict: + """Convert BSON to JSON. Also converts the _id to a string. + + Args: + data (bson.BSON): BSON Data + + Returns: + dict: JSON Data + """ + if not data: + return None + original_json = json.loads(bson.json_util.dumps(data)) + original_json["_id"] = str(original_json["_id"]["$oid"]) + return original_json