Skip to content

Commit

Permalink
Merge pull request #5 from JohnGrubba/dev
Browse files Browse the repository at this point in the history
Password Resets
  • Loading branch information
JohnGrubba authored Jul 8, 2024
2 parents 95563a1 + d823d67 commit 15f22da
Show file tree
Hide file tree
Showing 16 changed files with 314 additions and 74 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. |
15 changes: 12 additions & 3 deletions docs/getting-started/email_config.md
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 2 additions & 3 deletions src/api/dependencies/authenticated.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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)
Expand Down
16 changes: 11 additions & 5 deletions src/api/internal.py
Original file line number Diff line number Diff line change
@@ -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()


Expand Down Expand Up @@ -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))
21 changes: 17 additions & 4 deletions src/api/login.py
Original file line number Diff line number Diff line change
@@ -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"},
Expand Down Expand Up @@ -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)
41 changes: 25 additions & 16 deletions src/api/model.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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
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
Loading

0 comments on commit 15f22da

Please sign in to comment.