diff --git a/config/configtemplate.json b/config/configtemplate.json
index 9c805bf..d5c325f 100644
--- a/config/configtemplate.json
+++ b/config/configtemplate.json
@@ -41,7 +41,9 @@
"qr_endpoint": true
},
"allow_add_fields_on_signup": [],
- "allow_add_fields_patch_user": []
+ "allow_add_fields_patch_user": [],
+ "allow_deletion": true,
+ "deletion_pending_minutes": 10080
},
"security": {
"allow_origins": [
diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md
index d2e4033..b9dd7f6 100644
--- a/docs/configuration/configuration.md
+++ b/docs/configuration/configuration.md
@@ -67,6 +67,8 @@ Make sure that all parameters are set correctly before starting the service.
| `account_features.2fa.qr_endpoint` | **Datatype:** Boolean
**Default:** `true`
Enable or disable QR Code Generation Endpoint for 2FA Login. This can be useful if you don't want to use any libraries on the client Side. |
| `account_features.allow_add_fields_on_signup` | **Datatype:** List
**Default:** `[]`
Allow those additional fields on signup. Leave empty if not sure. |
| `account_features.allow_add_fields_patch_user` | **Datatype:** List
**Default:** `[]`
Allow those additional fields to be set when modifying user. Leave empty if not sure. The entries here extend already set `account_features.allow_add_fields_on_signup` fields. |
+| `account_features.allow_deletion` | **Datatype:** Boolean
**Default:** `true`
Allow the user to request an account deletion. |
+| `account_features.deletion_pending_minutes` | **Datatype:** Integer
**Default:** `10080`
Minutes before the account gets deleted. Directly after requesting deletion, the User can't log in anymore, but the data will be persisted until this value passes by. Example Value is a Week. |
!!! Note "Additional Fields"
The `allow_add_fields_on_signup` makes it possible to add custom fields to the signup process. If you don't set the fields that are allowed here on signup, you can't update them later, except you also have them in `allow_add_fields_patch_user`.
diff --git a/src/api/model.py b/src/api/model.py
index 9e49d05..fcbd1b7 100644
--- a/src/api/model.py
+++ b/src/api/model.py
@@ -73,6 +73,10 @@ def password_check_hash(cls, password: SecretStr) -> str:
return hashed_pswd
+class DeleteAccountRequest(BaseModel):
+ password: SecretStr
+
+
class ResetPasswordRequest(PasswordHashed):
old_password: SecretStr
diff --git a/src/api/profile.py b/src/api/profile.py
index df05d62..dbcfdab 100644
--- a/src/api/profile.py
+++ b/src/api/profile.py
@@ -1,15 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Response
from tools.conf import AccountFeaturesConfig, SignupConfig
-from api.model import ResetPasswordRequest, ConfirmEmailRequest
+from api.model import ResetPasswordRequest, ConfirmEmailRequest, DeleteAccountRequest
import json
import bcrypt
-from crud.user import change_pswd, update_public_user
+from crud.user import change_pswd, update_public_user, schedule_delete_user
from api.dependencies.authenticated import (
get_pub_user_dep,
get_dangerous_user_dep,
get_user_dep,
)
-from tools import send_email, all_ids, regenerate_ids, r
+from tools import send_email, all_ids, regenerate_ids, r, SessionConfig
router = APIRouter(
prefix="/profile",
@@ -137,3 +137,32 @@ async def update_profile(update_data: dict, user: dict = Depends(get_user_dep)):
This endpoint is used to update the profile information of the user.
"""
return update_public_user(user["_id"], update_data)
+
+
+@router.delete("", status_code=204)
+async def delete_account(
+ password: DeleteAccountRequest,
+ response: Response,
+ user: dict = Depends(get_dangerous_user_dep),
+):
+ """
+ # Delete Account
+
+ ## Description
+ This endpoint is used to request a deletion of the user's account.
+ This process can only be canceled by the administration of the system.
+ """
+ if not AccountFeaturesConfig.allow_deletion:
+ raise HTTPException(status_code=403, detail="Account Deletion is disabled.")
+ # Check Password
+ if not bcrypt.checkpw(
+ password.password.get_secret_value().encode("utf-8"),
+ user["password"].encode("utf-8"),
+ ):
+ raise HTTPException(detail="Invalid Password", status_code=401)
+ schedule_delete_user(user["_id"])
+ response.delete_cookie(
+ SessionConfig.auto_cookie_name,
+ samesite=SessionConfig.cookie_samesite,
+ secure=SessionConfig.cookie_secure,
+ )
diff --git a/src/crud/sessions.py b/src/crud/sessions.py
index 40a3d2d..14bcb12 100644
--- a/src/crud/sessions.py
+++ b/src/crud/sessions.py
@@ -1,5 +1,5 @@
import uuid
-from tools import sessions_collection
+from tools import sessions_collection, users_collection
import datetime
from tools.conf import SessionConfig
from fastapi import Request
@@ -19,6 +19,14 @@ def create_login_session(user_id: ObjectId, request: Request) -> str:
Returns:
str: Session Token
"""
+ # Check if User is already scheduled for deletion before creating a session
+ user = users_collection.find_one({"_id": ObjectId(user_id)})
+ if user.get("expiresAfter", None) is not None:
+ raise HTTPException(
+ status_code=403,
+ detail="User scheduled for deletion. Please Contact an Administrator for more information.",
+ )
+
u_agent = request.headers.get("User-Agent")
ua = parse(u_agent)
device_information = {
@@ -115,3 +123,13 @@ def get_session_by_id(session_id: str) -> dict:
except errors.InvalidId:
raise HTTPException(status_code=404, detail="Session not found.")
return sessions_collection.find_one({"_id": object_id})
+
+
+def clear_sessions_for_user(user_id: ObjectId) -> None:
+ """
+ Clear all sessions for a user.
+
+ Args:
+ user_id (str): User ID
+ """
+ sessions_collection.delete_many({"user_id": user_id})
diff --git a/src/crud/user.py b/src/crud/user.py
index e8b2a51..427042d 100644
--- a/src/crud/user.py
+++ b/src/crud/user.py
@@ -13,6 +13,7 @@
import pymongo
import bson
import datetime
+from crud.sessions import clear_sessions_for_user
def link_google_account(user_id: str, google_uid: str) -> None:
@@ -251,3 +252,23 @@ def create_user(
if SignupConfig.enable_welcome_email:
background_tasks.add_task(send_email, "WelcomeEmail", data["email"], **data)
return session_token
+
+
+def schedule_delete_user(user_id: str) -> None:
+ """Schedule a User for Deletion
+
+ Args:
+ user_id (str): User ID
+ """
+ users_collection.update_one(
+ {"_id": bson.ObjectId(user_id)},
+ {
+ "$set": {
+ "expiresAfter": datetime.datetime.now(datetime.UTC)
+ + datetime.timedelta(
+ minutes=AccountFeaturesConfig.deletion_pending_minutes
+ )
+ }
+ },
+ )
+ clear_sessions_for_user(user_id)
diff --git a/src/tools/conf.py b/src/tools/conf.py
index 9f92c4b..35db699 100644
--- a/src/tools/conf.py
+++ b/src/tools/conf.py
@@ -15,7 +15,7 @@
# 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"]
+not_updateable_cols_internal = ["email", "createdAt", "expireAt"]
# Columns that can leave EZAuth but should only be used internally can be defined in config
@@ -68,10 +68,14 @@ class AccountFeaturesConfig:
qr_code_endpoint_2fa: bool = config["account_features"]["2fa"]["qr_endpoint"]
allow_add_fields_on_signup: set[str] = set(
config["account_features"]["allow_add_fields_on_signup"]
- )
+ ) - set(not_updateable_cols_internal)
allow_add_fields_patch_user: set[str] = set(
config["account_features"]["allow_add_fields_patch_user"]
- )
+ ) - set(not_updateable_cols_internal)
+ allow_deletion: bool = config["account_features"]["allow_deletion"]
+ deletion_pending_minutes: int = config["account_features"][
+ "deletion_pending_minutes"
+ ]
class SecurityConfig:
diff --git a/src/tools/db.py b/src/tools/db.py
index 0beb977..f224d24 100644
--- a/src/tools/db.py
+++ b/src/tools/db.py
@@ -45,8 +45,14 @@
pass
# Set TTL For Sessions
sessions_collection.create_index(
- "createdAt", expireAfterSeconds=SessionConfig.session_expiry_seconds
+ "createdAt", expireAfterSeconds=SessionConfig.session_expiry_seconds, sparse=True
)
+try:
+ users_collection.drop_index("expiresAfter_1")
+except Exception:
+ pass
+# Create TTL For Account Deletions
+users_collection.create_index("expiresAfter", expireAfterSeconds=0, sparse=True)
def bson_to_json(data: bson.BSON) -> dict: