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