Skip to content

Commit

Permalink
Password Reset Endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
JohnGrubba committed Jul 8, 2024
1 parent a4d2d63 commit 5c6d6ec
Show file tree
Hide file tree
Showing 11 changed files with 203 additions and 51 deletions.
3 changes: 2 additions & 1 deletion config/configtemplate.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
]
},
"account_features": {
"enable_change_password": true
"enable_change_password": true,
"change_password_confirm_email": true
}
}
32 changes: 32 additions & 0 deletions config/email/ChangePassword.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!DOCTYPE HTML
PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">

<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="x-apple-disable-message-reformatting">
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]-->
<title>Reset your Password</title>
</head>

<body class="clean-body u_body"
style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #ecf0f1;color: #000000">
<h1>Did you just request a password reset?</h1>
<p>If it was you, then enter this code to confirm the change:</p>
<h2>{code}</h2>
<p>If it wasn't you, then ignore this email.</p>
<p>This code expires in {time} minutes.</p>
</body>

</html>
3 changes: 2 additions & 1 deletion docs/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <br> **Default:** `true` <br> Enable or disable the password reset feature. |
| `account_features.enable_change_password` | **Datatype:** Boolean <br> **Default:** `true` <br> Enable or disable the password reset feature. |
| `account_features.change_password_confirm_email` | **Datatype:** Boolean <br> **Default:** `true` <br> Enable or disable the password change confirmation E-Mail. |
10 changes: 10 additions & 0 deletions docs/getting-started/email_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ 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`

### 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 email address.
- `{{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.
Expand Down
35 changes: 19 additions & 16 deletions src/api/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,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:
Expand All @@ -64,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
84 changes: 82 additions & 2 deletions src/api/profile.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
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",
tags=["Profile"],
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)):
Expand All @@ -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"]]
34 changes: 3 additions & 31 deletions src/api/signup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions src/crud/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@
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
Expand Down
1 change: 1 addition & 0 deletions src/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
insecure_cols,
)
from .mail import send_email, broadcast_emails
from .confirmation_codes import all_ids, regenerate_ids
3 changes: 3 additions & 0 deletions src/tools/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
36 changes: 36 additions & 0 deletions src/tools/confirmation_codes.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit 5c6d6ec

Please sign in to comment.