mirror of
https://github.com/joaovitoriasilva/endurain.git
synced 2026-01-09 15:57:59 -05:00
Merge branch 'copilot/fix-274' into pre-release
This commit is contained in:
10
.env.example
10
.env.example
@@ -11,4 +11,12 @@ ENDURAIN_HOST=https://endurain.example.com
|
||||
BEHIND_PROXY=true
|
||||
POSTGRES_DB=endurain
|
||||
POSTGRES_USER=endurain
|
||||
PGDATA=/var/lib/postgresql/data
|
||||
PGDATA=/var/lib/postgresql/data/pgdata
|
||||
|
||||
# Email configuration (for password reset functionality)
|
||||
#SMTP_HOST=smtp.protonmail.ch
|
||||
#SMTP_PORT=587
|
||||
#SMTP_USERNAME=your-email@example.com
|
||||
#SMTP_PASSWORD=your-app-password
|
||||
#SMTP_SECURE=true
|
||||
#SMTP_SECURE_TYPE=starttls
|
||||
|
||||
@@ -20,6 +20,7 @@ import health_data.models
|
||||
import health_targets.models
|
||||
import migrations.models
|
||||
import notifications.models
|
||||
import password_reset_tokens.models
|
||||
import server_settings.models
|
||||
import session.models
|
||||
import users.user.models
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Add password reset tokens table
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 2fb0ae78dea9
|
||||
Create Date: 2025-08-29 20:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a1b2c3d4e5f6"
|
||||
down_revision: Union[str, None] = "2fb0ae78dea9"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"password_reset_tokens",
|
||||
sa.Column(
|
||||
"id",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
comment="Password reset token ID",
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
comment="User ID that the password reset token belongs to",
|
||||
),
|
||||
sa.Column(
|
||||
"token_hash",
|
||||
sa.String(length=128),
|
||||
nullable=False,
|
||||
comment="Hashed password reset token",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
comment="Token creation date (datetime)",
|
||||
),
|
||||
sa.Column(
|
||||
"expires_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
comment="Token expiration date (datetime)",
|
||||
),
|
||||
sa.Column(
|
||||
"used",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
comment="Token usage status (False - unused, True - used)",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_password_reset_tokens_user_id"),
|
||||
"password_reset_tokens",
|
||||
["user_id"],
|
||||
unique=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(
|
||||
op.f("ix_password_reset_tokens_user_id"), table_name="password_reset_tokens"
|
||||
)
|
||||
op.drop_table("password_reset_tokens")
|
||||
# ### end Alembic commands ###
|
||||
218
backend/app/core/apprise.py
Normal file
218
backend/app/core/apprise.py
Normal file
@@ -0,0 +1,218 @@
|
||||
import os
|
||||
import apprise
|
||||
from typing import List
|
||||
|
||||
import core.logger as core_logger
|
||||
|
||||
|
||||
class AppriseService:
|
||||
def __init__(self):
|
||||
self.smtp_host = os.getenv("SMTP_HOST")
|
||||
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
||||
self.smtp_username = os.getenv("SMTP_USERNAME")
|
||||
self.smtp_password = os.getenv("SMTP_PASSWORD")
|
||||
self.smtp_secure = os.getenv("SMTP_SECURE", "true").lower()
|
||||
self.smtp_secure_type = os.getenv("SMTP_SECURE_TYPE", "starttls").lower()
|
||||
self.frontend_host = os.getenv("ENDURAIN_HOST")
|
||||
|
||||
def _build_smtp_url(self) -> str:
|
||||
"""
|
||||
Build and return an SMTP-style URL used by Apprise-like notification backends.
|
||||
|
||||
The method constructs a mailto/mailtos URL with a placeholder recipient ('_')
|
||||
and appends connection and credential parameters as query string values.
|
||||
|
||||
Behavior:
|
||||
- If self.smtp_secure == "true", the URL scheme is "mailtos" and a "mode"
|
||||
parameter with the value of self.smtp_secure_type is added.
|
||||
- Otherwise the URL scheme is "mailto".
|
||||
- The following query parameters are appended in order: mode (optional),
|
||||
user, pass, smtp, port.
|
||||
|
||||
Uses the instance attributes:
|
||||
- smtp_secure (str): "true" to enable secure mode, otherwise omitted.
|
||||
- smtp_secure_type (str): type of secure transport (e.g., "tls", "ssl").
|
||||
- smtp_username (str): username to include as the "user" query parameter.
|
||||
- smtp_password (str): password to include as the "pass" query parameter.
|
||||
- smtp_host (str): SMTP host to include as the "smtp" query parameter.
|
||||
- smtp_port (str|int): SMTP port to include as the "port" query parameter.
|
||||
|
||||
Returns:
|
||||
str: The assembled URL, e.g.
|
||||
"mailto://_?user=USERNAME&pass=PASSWORD&smtp=smtp.example.com&port=587"
|
||||
or when secure:
|
||||
"mailtos://_?mode=tls&user=...&pass=...&smtp=...&port=..."
|
||||
|
||||
Notes and cautions:
|
||||
- Credentials are embedded in the query string; treat the returned URL as
|
||||
sensitive and avoid logging or exposing it.
|
||||
- Values are concatenated directly and are not URL-encoded by this method.
|
||||
If attribute values may contain special characters, URL-encode them before
|
||||
building the URL to ensure a valid query string.
|
||||
- The method does not validate the host, port, or credential contents.
|
||||
"""
|
||||
# Build base URL
|
||||
url = f"mailto://_"
|
||||
params = []
|
||||
# if secure change url to mailtos and append secure type
|
||||
if self.smtp_secure == "true":
|
||||
url = f"mailtos://_"
|
||||
params.append(f"mode={self.smtp_secure_type}")
|
||||
|
||||
# Add query parameters
|
||||
params.append(f"user={self.smtp_username}")
|
||||
params.append(f"pass={self.smtp_password}")
|
||||
params.append(f"smtp={self.smtp_host}")
|
||||
params.append(f"port={self.smtp_port}")
|
||||
|
||||
# Append any additional parameters to the url
|
||||
if params:
|
||||
url += "?" + "&".join(params)
|
||||
|
||||
# return the final URL
|
||||
return url
|
||||
|
||||
async def send_email(
|
||||
self,
|
||||
to_emails: List[str],
|
||||
subject: str,
|
||||
html_content: str | None = None,
|
||||
text_content: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send an email to one or more recipients using Apprise over SMTP.
|
||||
|
||||
This asynchronous method prefers HTML content when provided and falls back to
|
||||
plain text. It builds a temporary Apprise instance, constructs an SMTP URL via
|
||||
self._build_smtp_url(), appends each recipient using the "&to=<email>" query
|
||||
parameter, and invokes Apprise's async_notify to send the message. The method
|
||||
performs configuration checks and logs outcomes; exceptions are caught and
|
||||
logged and result in a False return.
|
||||
|
||||
Parameters:
|
||||
to_emails (List[str]): Recipient email addresses. If empty, no send is
|
||||
attempted and the method returns False.
|
||||
subject (str): The subject line for the email.
|
||||
html_content (str | None): HTML body of the email. Preferred when provided.
|
||||
text_content (str | None): Plain-text body of the email. Used if
|
||||
html_content is None.
|
||||
|
||||
Returns:
|
||||
bool: True if the notification was reported as successfully sent by Apprise;
|
||||
False otherwise (including when SMTP is not configured, there are no
|
||||
recipients, required content is missing, or an exception occurs).
|
||||
|
||||
Raises:
|
||||
None propagated: All exceptions are caught internally, logged via core_logger,
|
||||
and result in a False return value.
|
||||
|
||||
Notes:
|
||||
- The method sets body_format to "html" when html_content is used, otherwise
|
||||
to "text".
|
||||
- The method relies on self.is_smtp_configured() to determine whether to
|
||||
attempt sending and on self._build_smtp_url() to construct the base SMTP URL.
|
||||
- Callers must await this coroutine.
|
||||
"""
|
||||
# Use HTML content primarily, fall back to text if HTML not available
|
||||
content = html_content if html_content else text_content
|
||||
body_format = "html" if html_content else "text"
|
||||
|
||||
try:
|
||||
# set success as false
|
||||
success = False
|
||||
# Check if emails are defined and SMTP is configured
|
||||
if to_emails and self.is_smtp_configured():
|
||||
# Create a temporary Apprise instance
|
||||
temp_apprise = apprise.Apprise()
|
||||
smtp_url = self._build_smtp_url()
|
||||
|
||||
# Add recipients to the SMTP URL
|
||||
for email in to_emails:
|
||||
email_url = f"{smtp_url}&to={email}"
|
||||
temp_apprise.add(email_url)
|
||||
|
||||
success = await temp_apprise.async_notify(
|
||||
title=subject, body=content, body_format=body_format
|
||||
)
|
||||
|
||||
if success:
|
||||
core_logger.print_to_log(f"Emails sent successfully: {subject}", "info")
|
||||
else:
|
||||
core_logger.print_to_log_and_console(
|
||||
f"Failed to send emails: {subject}", "warning"
|
||||
)
|
||||
|
||||
return success
|
||||
except Exception as err:
|
||||
core_logger.print_to_log(
|
||||
f"Failed to send emails '{subject}': {err}", "error", exc=err
|
||||
)
|
||||
return False
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""
|
||||
Return whether the notification system is configured.
|
||||
|
||||
This convenience wrapper delegates to is_smtp_configured() and returns True
|
||||
when SMTP-based notifications are set up, False otherwise. It provides an
|
||||
abstraction so callers do not need to know the underlying mechanism used
|
||||
to determine configuration status.
|
||||
|
||||
Returns:
|
||||
bool: True if the notification system (currently SMTP) is configured,
|
||||
False if it is not.
|
||||
|
||||
Notes:
|
||||
- Currently only checks SMTP configuration via is_smtp_configured().
|
||||
Extend this method if additional notification backends need to be
|
||||
considered in the future.
|
||||
"""
|
||||
return self.is_smtp_configured()
|
||||
|
||||
def is_smtp_configured(self) -> bool:
|
||||
"""
|
||||
Return whether SMTP is configured.
|
||||
|
||||
Checks the instance attribute `smtp_host` and returns True if it is set to a truthy value
|
||||
(e.g., a non-empty string). Returns False if `smtp_host` is `None`, an empty string, or
|
||||
otherwise falsy.
|
||||
|
||||
Returns:
|
||||
bool: True when SMTP host is present (SMTP considered configured), False otherwise.
|
||||
|
||||
Notes:
|
||||
This method only inspects the truthiness of `smtp_host`; it does not validate the host
|
||||
format or attempt any network connection.
|
||||
"""
|
||||
return bool(self.smtp_host)
|
||||
|
||||
|
||||
def get_email_service():
|
||||
"""
|
||||
Return the application's email service instance.
|
||||
|
||||
This function provides access to the module-level email service object used
|
||||
for sending email notifications. It is a convenience accessor that returns
|
||||
whichever email service implementation was initialized during application
|
||||
startup (for example, a configured SMTP client, a wrapper around a third-party
|
||||
notification library, or a mock for testing).
|
||||
|
||||
Returns:
|
||||
object: The initialized email service instance. The concrete type and API
|
||||
depend on how the application configured the service.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the email service has not been initialized before calling
|
||||
this function.
|
||||
|
||||
Usage:
|
||||
Ensure the email service is initialized during application startup. Consumers
|
||||
may call this function to obtain the service and perform send operations:
|
||||
|
||||
svc = get_email_service()
|
||||
svc.send(to="user@example.com", subject="Hi", body="Hello")
|
||||
"""
|
||||
return email_service
|
||||
|
||||
|
||||
email_service = AppriseService()
|
||||
@@ -27,7 +27,9 @@ TZ = os.getenv("TZ", "UTC")
|
||||
REVERSE_GEO_PROVIDER = os.getenv("REVERSE_GEO_PROVIDER", "nominatim").lower()
|
||||
PHOTON_API_HOST = os.getenv("PHOTON_API_HOST", "photon.komoot.io").lower()
|
||||
PHOTON_API_USE_HTTPS = os.getenv("PHOTON_API_USE_HTTPS", "true").lower() == "true"
|
||||
NOMINATIM_API_HOST = os.getenv("NOMINATIM_API_HOST", "nominatim.openstreetmap.org").lower()
|
||||
NOMINATIM_API_HOST = os.getenv(
|
||||
"NOMINATIM_API_HOST", "nominatim.openstreetmap.org"
|
||||
).lower()
|
||||
NOMINATIM_API_USE_HTTPS = os.getenv("NOMINATIM_API_USE_HTTPS", "true").lower() == "true"
|
||||
GEOCODES_MAPS_API = os.getenv("GEOCODES_MAPS_API", "changeme")
|
||||
try:
|
||||
@@ -59,6 +61,15 @@ def check_required_env_vars():
|
||||
"ENDURAIN_HOST",
|
||||
]
|
||||
|
||||
# Email is optional but warn if not configured
|
||||
email_vars = ["SMTP_HOST", "SMTP_USERNAME", "SMTP_PASSWORD"]
|
||||
for var in email_vars:
|
||||
if var not in os.environ:
|
||||
core_logger.print_to_log_and_console(
|
||||
f"Email not configured (missing: {', '.join(var)}). Password reset feature will not work.",
|
||||
"info",
|
||||
)
|
||||
|
||||
for var in required_env_vars:
|
||||
if var not in os.environ:
|
||||
core_logger.print_to_log_and_console(
|
||||
|
||||
@@ -24,6 +24,7 @@ import gears.gear_components.router as gear_components_router
|
||||
import health_data.router as health_data_router
|
||||
import health_targets.router as health_targets_router
|
||||
import notifications.router as notifications_router
|
||||
import password_reset_tokens.router as password_reset_tokens_router
|
||||
import profile.router as profile_router
|
||||
import server_settings.public_router as server_settings_public_router
|
||||
import server_settings.router as server_settings_router
|
||||
@@ -136,6 +137,11 @@ router.include_router(
|
||||
Security(session_security.check_scopes, scopes=["profile"]),
|
||||
],
|
||||
)
|
||||
router.include_router(
|
||||
password_reset_tokens_router.router,
|
||||
prefix=core_config.ROOT_PATH,
|
||||
tags=["password_reset_tokens"],
|
||||
)
|
||||
router.include_router(
|
||||
profile_router.router,
|
||||
prefix=core_config.ROOT_PATH + "/profile",
|
||||
|
||||
@@ -7,6 +7,8 @@ import strava.utils as strava_utils
|
||||
import garmin.activity_utils as garmin_activity_utils
|
||||
import garmin.health_utils as garmin_health_utils
|
||||
|
||||
import password_reset_tokens.utils as password_reset_tokens_utils
|
||||
|
||||
import core.logger as core_logger
|
||||
|
||||
# scheduler = BackgroundScheduler()
|
||||
@@ -50,6 +52,14 @@ def start_scheduler():
|
||||
"retrieve last day Garmin Connect users body composition",
|
||||
)
|
||||
|
||||
add_scheduler_job(
|
||||
password_reset_tokens_utils.delete_invalid_tokens_from_db,
|
||||
"interval",
|
||||
60,
|
||||
[],
|
||||
"delete invalid password reset tokens from the database",
|
||||
)
|
||||
|
||||
|
||||
def add_scheduler_job(func, interval, minutes, args, description):
|
||||
try:
|
||||
|
||||
@@ -21,6 +21,8 @@ import session.schema as session_schema
|
||||
import strava.activity_utils as strava_activity_utils
|
||||
import strava.utils as strava_utils
|
||||
|
||||
import password_reset_tokens.utils as password_reset_tokens_utils
|
||||
|
||||
from core.routes import router as api_router
|
||||
|
||||
|
||||
@@ -60,6 +62,12 @@ async def startup_event():
|
||||
)
|
||||
garmin_health_utils.retrieve_garminconnect_users_bc_for_days(1)
|
||||
|
||||
# Delete invalid password reset tokens
|
||||
core_logger.print_to_log_and_console(
|
||||
"Deleting invalid password reset tokens from the database"
|
||||
)
|
||||
password_reset_tokens_utils.delete_invalid_tokens_from_db()
|
||||
|
||||
|
||||
def shutdown_event():
|
||||
# Log the shutdown event
|
||||
|
||||
0
backend/app/password_reset_tokens/__init__.py
Normal file
0
backend/app/password_reset_tokens/__init__.py
Normal file
233
backend/app/password_reset_tokens/crud.py
Normal file
233
backend/app/password_reset_tokens/crud.py
Normal file
@@ -0,0 +1,233 @@
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import password_reset_tokens.schema as password_reset_tokens_schema
|
||||
import password_reset_tokens.models as password_reset_tokens_models
|
||||
|
||||
import core.logger as core_logger
|
||||
|
||||
|
||||
def create_password_reset_token(
|
||||
token: password_reset_tokens_schema.PasswordResetToken, db: Session
|
||||
) -> password_reset_tokens_models.PasswordResetToken:
|
||||
"""
|
||||
Create and persist a new password reset token record in the database.
|
||||
|
||||
This function constructs a PasswordResetToken ORM model from the provided
|
||||
schema object, adds it to the given SQLAlchemy session, commits the
|
||||
transaction, refreshes the instance from the database, and returns the
|
||||
persisted model instance.
|
||||
|
||||
Parameters:
|
||||
token (password_reset_tokens_schema.PasswordResetToken): A schema object
|
||||
containing the token data to be stored. Expected attributes include
|
||||
id, user_id, token_hash, created_at, expires_at, and used.
|
||||
db (Session): An active SQLAlchemy Session used to persist the model.
|
||||
|
||||
Returns:
|
||||
password_reset_tokens_models.PasswordResetToken: The persisted ORM model
|
||||
instance representing the created password reset token, refreshed from
|
||||
the database to include any defaults or database-side generated values.
|
||||
|
||||
Side effects:
|
||||
- Adds a new PasswordResetToken instance to the provided DB session.
|
||||
- Commits the session, causing the INSERT to be executed.
|
||||
- Refreshes the instance from the database.
|
||||
|
||||
Errors:
|
||||
On any exception during add/commit/refresh the session is rolled back,
|
||||
the error is logged, and an HTTPException with status_code
|
||||
500 (Internal Server Error) is raised (the original exception is chained).
|
||||
"""
|
||||
try:
|
||||
# Create a new password reset token
|
||||
db_token = password_reset_tokens_models.PasswordResetToken(
|
||||
id=token.id,
|
||||
user_id=token.user_id,
|
||||
token_hash=token.token_hash,
|
||||
created_at=token.created_at,
|
||||
expires_at=token.expires_at,
|
||||
used=token.used,
|
||||
)
|
||||
|
||||
# Add the token to the database
|
||||
db.add(db_token)
|
||||
db.commit()
|
||||
db.refresh(db_token)
|
||||
|
||||
# Return the token
|
||||
return db_token
|
||||
except Exception as err:
|
||||
# Rollback the transaction
|
||||
db.rollback()
|
||||
|
||||
# Log the exception
|
||||
core_logger.print_to_log(
|
||||
f"Error in create_password_reset_token: {err}", "error", exc=err
|
||||
)
|
||||
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
def get_password_reset_token_by_hash(
|
||||
token_hash: str, db: Session
|
||||
) -> password_reset_tokens_models.PasswordResetToken | None:
|
||||
try:
|
||||
# Get the token from the database
|
||||
db_token = (
|
||||
db.query(password_reset_tokens_models.PasswordResetToken)
|
||||
.filter(
|
||||
and_(
|
||||
password_reset_tokens_models.PasswordResetToken.token_hash
|
||||
== token_hash,
|
||||
password_reset_tokens_models.PasswordResetToken.used == False,
|
||||
password_reset_tokens_models.PasswordResetToken.expires_at
|
||||
> datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Return the token (can be None if not found)
|
||||
return db_token
|
||||
except Exception as err:
|
||||
# Log the exception
|
||||
core_logger.print_to_log(
|
||||
f"Error in get_password_reset_token_by_hash: {err}", "error", exc=err
|
||||
)
|
||||
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
def mark_password_reset_token_used(
|
||||
token_id: str, db: Session
|
||||
) -> password_reset_tokens_models.PasswordResetToken | None:
|
||||
"""
|
||||
Mark a password reset token as used.
|
||||
|
||||
This function looks up a PasswordResetToken by its identifier, sets its `used`
|
||||
attribute to True, and commits the change to the database.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
token_id : str
|
||||
The unique identifier of the password reset token to mark as used.
|
||||
db : Session
|
||||
An active SQLAlchemy Session used to query and persist changes.
|
||||
|
||||
Returns
|
||||
-------
|
||||
password_reset_tokens_models.PasswordResetToken | None
|
||||
The updated PasswordResetToken instance if found and updated; otherwise
|
||||
None if no token with the given id exists.
|
||||
|
||||
Side effects
|
||||
------------
|
||||
- If the token is found, its `used` field is set to True and the change is
|
||||
committed to the database.
|
||||
- On unexpected errors, the transaction is rolled back, the error is logged
|
||||
(via core_logger), and an HTTPException with status 500 is raised.
|
||||
|
||||
Exceptions
|
||||
----------
|
||||
HTTPException
|
||||
Raised with status code 500 (Internal Server Error) when an unexpected
|
||||
error occurs during the database operation.
|
||||
"""
|
||||
try:
|
||||
# Get the token from the database
|
||||
db_token = (
|
||||
db.query(password_reset_tokens_models.PasswordResetToken)
|
||||
.filter(password_reset_tokens_models.PasswordResetToken.id == token_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if db_token:
|
||||
# Mark the token as used
|
||||
db_token.used = True
|
||||
db.commit()
|
||||
|
||||
return db_token
|
||||
except Exception as err:
|
||||
# Rollback the transaction
|
||||
db.rollback()
|
||||
|
||||
# Log the exception
|
||||
core_logger.print_to_log(
|
||||
f"Error in mark_password_reset_token_used: {err}", "error", exc=err
|
||||
)
|
||||
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
def delete_expired_password_reset_tokens(db: Session):
|
||||
"""
|
||||
Delete expired password reset tokens from the database.
|
||||
|
||||
This function removes all PasswordResetToken records whose `expires_at`
|
||||
timestamp is strictly earlier than the current UTC time (datetime.now(timezone.utc)).
|
||||
On success the transaction is committed and the number of deleted rows is returned.
|
||||
If any exception occurs during the operation, the session is rolled back,
|
||||
the error is logged via core_logger.print_to_log, and an HTTPException with
|
||||
status 500 (Internal Server Error) is raised.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : Session
|
||||
An active SQLAlchemy session used to query, delete, and commit changes.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of password reset token rows deleted.
|
||||
|
||||
Raises
|
||||
------
|
||||
fastapi.HTTPException
|
||||
Raised with status_code=500 if an unexpected error occurs while deleting
|
||||
tokens or committing the transaction. The original exception is logged
|
||||
and the transaction is rolled back before raising.
|
||||
"""
|
||||
try:
|
||||
# Delete expired tokens
|
||||
num_deleted = (
|
||||
db.query(password_reset_tokens_models.PasswordResetToken)
|
||||
.filter(
|
||||
password_reset_tokens_models.PasswordResetToken.expires_at
|
||||
< datetime.now(timezone.utc)
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return num_deleted
|
||||
except Exception as err:
|
||||
# Rollback the transaction
|
||||
db.rollback()
|
||||
|
||||
# Log the exception
|
||||
core_logger.print_to_log(
|
||||
f"Error in delete_expired_password_reset_tokens: {err}", "error", exc=err
|
||||
)
|
||||
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
103
backend/app/password_reset_tokens/email_messages.py
Normal file
103
backend/app/password_reset_tokens/email_messages.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import core.apprise as core_apprise
|
||||
|
||||
|
||||
def get_password_reset_email_en(
|
||||
user_name: str, reset_link: str, email_service: core_apprise.AppriseService
|
||||
) -> tuple:
|
||||
"""
|
||||
Return the subject, HTML body, and plain-text body for an English password-reset email.
|
||||
|
||||
Args:
|
||||
user_name (str): The recipient's display name inserted into the greeting.
|
||||
reset_link (str): The URL the user will follow to reset their password; inserted into the CTA button
|
||||
and included as a plain link for clients that do not render the button.
|
||||
email_service (core_apprise.AppriseService): Notification service instance used to obtain service
|
||||
metadata (e.g., `frontend_host`) for the email footer.
|
||||
|
||||
Returns:
|
||||
tuple[str, str, str]: A 3-tuple containing:
|
||||
- subject: The email subject line.
|
||||
- html_content: The full HTML email content (string) including inline styles, logo, a prominent
|
||||
"Reset Password" button linking to `reset_link`, a security notice about a 1-hour expiry, and
|
||||
a footer referencing `email_service.frontend_host`.
|
||||
- text_content: A plain-text alternative suitable for clients that do not render HTML, containing
|
||||
the greeting, reset instructions, the raw `reset_link`, expiry notice, and sign-off.
|
||||
|
||||
Notes:
|
||||
- The function only constructs and returns strings; it does not send emails or perform network I/O.
|
||||
- Calling code should ensure `reset_link` and `user_name` are properly validated/sanitized as needed.
|
||||
- The HTML is crafted with inline styles for broad email-client compatibility.
|
||||
"""
|
||||
subject = "Endurain - Password reset"
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{subject}</title>
|
||||
</head>
|
||||
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f4f4f4;">
|
||||
<div style="background-color: #ffffff; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<div style="font-size: 34px; font-weight: bold; margin-bottom: 10px; display: flex; align-items: center; justify-content: center; gap: 10px;">
|
||||
<img src="https://github.com/joaovitoriasilva/endurain/blob/0e17fafe450b66eda7982311e6f94cee44316684/frontend/app/public/logo/logo.svg?raw=true"
|
||||
alt="Endurain logo" style="height: 32px; width: auto;">
|
||||
<span>Endurain</span>
|
||||
</div>
|
||||
<h3 style="margin: 0;">Password reset request</h3>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 30px;">
|
||||
<p>Hi {user_name},</p>
|
||||
|
||||
<p>You requested to reset your password for your Endurain account. Click the button below to reset your
|
||||
password:</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{reset_link}" style="background-color: #0d6efd; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: bold;">Reset Password</a>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<strong>Security notice:</strong> This link will expire in 1 hour for security reasons.
|
||||
</div>
|
||||
|
||||
<p>If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
|
||||
</p>
|
||||
|
||||
<p>If the button above doesn't work, you can copy and paste the following link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #0d6efd;">{reset_link}</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; font-size: 12px; color: #666; margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
|
||||
<p>Best regards,<br>The Endurain team</p>
|
||||
<p>Visit Endurain at: <a style="color: #0d6efd;" href="{email_service.frontend_host}">{email_service.frontend_host}</a> -
|
||||
Source code at: <a style="color: #0d6efd;"
|
||||
href="https://github.com/joaovitoriasilva/endurain">GitHub</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
""".strip()
|
||||
|
||||
# Create text version
|
||||
text_content = f"""
|
||||
Hi {user_name},
|
||||
|
||||
You requested to reset your password for your Endurain account.
|
||||
|
||||
Please click the following link to reset your password:
|
||||
{reset_link}
|
||||
|
||||
This link will expire in 1 hour for security reasons.
|
||||
|
||||
If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
|
||||
|
||||
Best regards,
|
||||
The Endurain team
|
||||
""".strip()
|
||||
|
||||
return subject, html_content, text_content
|
||||
41
backend/app/password_reset_tokens/models.py
Normal file
41
backend/app/password_reset_tokens/models.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Boolean,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class PasswordResetToken(Base):
|
||||
__tablename__ = "password_reset_tokens"
|
||||
|
||||
id = Column(String(length=64), nullable=False, primary_key=True)
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="User ID that the password reset token belongs to",
|
||||
)
|
||||
token_hash = Column(
|
||||
String(length=128), nullable=False, comment="Hashed password reset token"
|
||||
)
|
||||
created_at = Column(
|
||||
DateTime, nullable=False, comment="Token creation date (datetime)"
|
||||
)
|
||||
expires_at = Column(
|
||||
DateTime, nullable=False, comment="Token expiration date (datetime)"
|
||||
)
|
||||
used = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="Token usage status (False - unused, True - used)",
|
||||
)
|
||||
|
||||
# Define a relationship to the User model
|
||||
user = relationship("User", back_populates="password_reset_tokens")
|
||||
150
backend/app/password_reset_tokens/router.py
Normal file
150
backend/app/password_reset_tokens/router.py
Normal file
@@ -0,0 +1,150 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
status,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import password_reset_tokens.schema as password_reset_tokens_schema
|
||||
import password_reset_tokens.utils as password_reset_tokens_utils
|
||||
|
||||
import session.security as session_security
|
||||
|
||||
import core.database as core_database
|
||||
import core.apprise as core_apprise
|
||||
|
||||
# Define the API router
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/password-reset/request")
|
||||
async def request_password_reset(
|
||||
request_data: password_reset_tokens_schema.PasswordResetRequest,
|
||||
email_service: Annotated[
|
||||
core_apprise.AppriseService,
|
||||
Depends(core_apprise.get_email_service),
|
||||
],
|
||||
db: Annotated[
|
||||
Session,
|
||||
Depends(core_database.get_db),
|
||||
],
|
||||
):
|
||||
"""
|
||||
Asynchronously handle a password reset request.
|
||||
|
||||
Attempts to send a password reset email for the provided email address using an
|
||||
injected email service and a database session. The endpoint intentionally returns
|
||||
a generic success message to avoid revealing whether the provided email exists
|
||||
in the system.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
request_data : password_reset_tokens_schema.PasswordResetRequest
|
||||
Pydantic model containing the email address to send the reset link to.
|
||||
email_service : core_apprise.AppriseService
|
||||
Dependency-injected service responsible for sending emails.
|
||||
db : Session
|
||||
Dependency-injected database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
A generic success message:
|
||||
{"message": "If the email exists in the system, a password reset link has been sent."}
|
||||
|
||||
Raises
|
||||
------
|
||||
HTTPException
|
||||
Raised with status_code=status.HTTP_500_INTERNAL_SERVER_ERROR if sending
|
||||
the password reset email fails.
|
||||
Other Errors
|
||||
Validation errors from FastAPI/Pydantic or dependency resolution errors may
|
||||
be propagated by the framework.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- This function is asynchronous.
|
||||
- Side effects include attempting to send an email and potentially interacting
|
||||
with the database (e.g., creating or updating a password reset token).
|
||||
- The generic response is used to mitigate user enumeration attacks.
|
||||
"""
|
||||
success = await password_reset_tokens_utils.send_password_reset_email(
|
||||
request_data.email, email_service, db
|
||||
)
|
||||
|
||||
# if the email was sent successfully send a generic success message
|
||||
if success:
|
||||
return {
|
||||
"message": "If the email exists in the system, a password reset link has been sent."
|
||||
}
|
||||
|
||||
# If the email sending failed, raise an error
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Unable to send password reset email",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm")
|
||||
async def confirm_password_reset(
|
||||
confirm_data: password_reset_tokens_schema.PasswordResetConfirm,
|
||||
db: Annotated[
|
||||
Session,
|
||||
Depends(core_database.get_db),
|
||||
],
|
||||
):
|
||||
"""
|
||||
Confirm a password reset using a one-time token.
|
||||
|
||||
Validates the provided new password against configured complexity rules and, if
|
||||
valid, delegates to the password reset token utility to apply the new password.
|
||||
|
||||
Parameters:
|
||||
confirm_data (password_reset_tokens_schema.PasswordResetConfirm):
|
||||
Object containing the reset token and the requested new password
|
||||
(expected attributes: 'token', 'new_password').
|
||||
db (Session):
|
||||
Database session provided by dependency injection (core_database.get_db).
|
||||
|
||||
Returns:
|
||||
dict: A JSON-serializable mapping with a success message, e.g.:
|
||||
{"message": "Password reset successful"}
|
||||
|
||||
Raises:
|
||||
HTTPException:
|
||||
- Raised with status 400 if the new password does not meet complexity
|
||||
requirements. The response detail contains the validation message.
|
||||
- May be raised by password_reset_tokens_utils.use_password_reset_token
|
||||
for problems such as an invalid, expired, or already-consumed token,
|
||||
or for database-related errors.
|
||||
|
||||
Side effects:
|
||||
- Updates the user's password in persistent storage.
|
||||
- Invalidates/consumes the provided password reset token.
|
||||
- Persists changes using the provided database session.
|
||||
|
||||
Notes:
|
||||
- Password complexity rules are enforced by
|
||||
session_security.is_password_complexity_valid.
|
||||
- Token application, user lookup, password hashing, and database commits
|
||||
are handled by password_reset_tokens_utils.use_password_reset_token.
|
||||
"""
|
||||
# Check if the password meets the complexity requirements
|
||||
is_valid, message = session_security.is_password_complexity_valid(
|
||||
confirm_data.new_password
|
||||
)
|
||||
if not is_valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=message,
|
||||
)
|
||||
|
||||
# Use the token to reset password
|
||||
password_reset_tokens_utils.use_password_reset_token(
|
||||
confirm_data.token, confirm_data.new_password, db
|
||||
)
|
||||
|
||||
return {"message": "Password reset successful"}
|
||||
22
backend/app/password_reset_tokens/schema.py
Normal file
22
backend/app/password_reset_tokens/schema.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class PasswordResetToken(BaseModel):
|
||||
id: str
|
||||
user_id: int
|
||||
token_hash: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
used: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
321
backend/app/password_reset_tokens/utils.py
Normal file
321
backend/app/password_reset_tokens/utils.py
Normal file
@@ -0,0 +1,321 @@
|
||||
import secrets
|
||||
import hashlib
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import (
|
||||
HTTPException,
|
||||
status,
|
||||
)
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import password_reset_tokens.email_messages as password_reset_tokens_email_messages
|
||||
import password_reset_tokens.schema as password_reset_tokens_schema
|
||||
import password_reset_tokens.crud as password_reset_tokens_crud
|
||||
|
||||
import users.user.crud as users_crud
|
||||
|
||||
import core.apprise as core_apprise
|
||||
import core.logger as core_logger
|
||||
|
||||
from core.database import SessionLocal
|
||||
|
||||
|
||||
def generate_password_reset_token() -> tuple[str, str]:
|
||||
"""
|
||||
Generate a URL-safe password reset token and its SHA-256 hash for storage.
|
||||
Returns:
|
||||
tuple[str, str]: A tuple (token, token_hash) where:
|
||||
- token: a URL-safe, cryptographically secure random token suitable for
|
||||
inclusion in password reset links (this raw token is intended to be
|
||||
sent to the user).
|
||||
- token_hash: the hexadecimal SHA-256 hash of the token, suitable for
|
||||
storing in a database instead of the raw token.
|
||||
Notes:
|
||||
- Do not store or log the raw token; store only the hash (token_hash).
|
||||
- When validating a presented token, compute its SHA-256 hex digest and
|
||||
compare it to the stored token_hash using a constant-time comparison to
|
||||
mitigate timing attacks (e.g., secrets.compare_digest).
|
||||
- Consider associating an expiration timestamp and single-use semantics with
|
||||
the token to limit its validity window.
|
||||
- Token generation relies on the `secrets` module for cryptographic randomness.
|
||||
"""
|
||||
# Generate a random 32-byte token
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
# Create a hash of the token for database storage
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
return token, token_hash
|
||||
|
||||
|
||||
def create_password_reset_token(user_id: int, db: Session) -> str:
|
||||
"""
|
||||
Create and persist a password reset token for a user and return the plain token.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user_id : int
|
||||
The ID of the user for whom the password reset token will be created.
|
||||
db : Session
|
||||
Database session used to persist the token (e.g., an SQLAlchemy Session).
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The plaintext password reset token that should be delivered to the user (for example via email).
|
||||
The function stores only a hash of this token in the database.
|
||||
|
||||
Behavior / Side effects
|
||||
-----------------------
|
||||
- Generates a secure token and a corresponding hash.
|
||||
- Creates a PasswordResetToken record with a unique id, user_id, token_hash, created_at,
|
||||
expires_at (1 hour after creation), and used flag set to 0.
|
||||
- Persists the PasswordResetToken record to the provided database session.
|
||||
|
||||
Security notes
|
||||
--------------
|
||||
- Treat the returned plaintext token as sensitive; transmit it over secure channels only.
|
||||
- Only the token hash is stored in the database to avoid storing secrets in plaintext.
|
||||
- When validating a token later, compare the provided token against the stored hash,
|
||||
ensure it has not expired, and verify it has not already been used.
|
||||
- Consider adding rate limiting, logging, and additional checks to reduce abuse.
|
||||
|
||||
Exceptions
|
||||
----------
|
||||
May raise exceptions originating from token generation/hashing utilities or from the database layer
|
||||
(e.g., integrity or operational errors). Callers should handle or propagate these exceptions as appropriate.
|
||||
|
||||
Example
|
||||
-------
|
||||
token = create_password_reset_token(user_id=42, db=session)
|
||||
# Send `token` to the user's email. Do not store the plaintext token in persistent storage.
|
||||
"""
|
||||
# Generate token and hash
|
||||
token, token_hash = generate_password_reset_token()
|
||||
|
||||
# Create token object
|
||||
reset_token = password_reset_tokens_schema.PasswordResetToken(
|
||||
id=str(uuid4()),
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=1), # 1 hour expiration
|
||||
used=0,
|
||||
)
|
||||
|
||||
# Save to database
|
||||
password_reset_tokens_crud.create_password_reset_token(reset_token, db)
|
||||
|
||||
# Return the plain token (not the hash)
|
||||
return token
|
||||
|
||||
|
||||
async def send_password_reset_email(
|
||||
email: str, email_service: core_apprise.AppriseService, db: Session
|
||||
) -> bool:
|
||||
"""
|
||||
Asynchronously send a password reset email for the given address.
|
||||
|
||||
This function performs the following steps:
|
||||
1. Verifies that the provided email service is configured; if not, raises an HTTP 503.
|
||||
2. Attempts to locate the user record for the given email in the provided DB session.
|
||||
- For security (to avoid user enumeration), if the user does not exist the function
|
||||
returns True and does not indicate existence to the caller.
|
||||
3. Verifies the located user is active (expects user.is_active == 1).
|
||||
- If the user is inactive the function returns True for the same security reason.
|
||||
4. Creates a password reset token and persists it via create_password_reset_token.
|
||||
5. Constructs a frontend reset URL using the email_service.frontend_host and the token.
|
||||
6. Builds a default English email subject/body via password_reset_tokens_email_messages
|
||||
and delegates actual sending to email_service.send_email.
|
||||
7. Returns the boolean result from the email service send operation.
|
||||
|
||||
Parameters
|
||||
- email (str): Recipient email address for the password reset message.
|
||||
- email_service (core_apprise.AppriseService): An email service instance used to
|
||||
construct the frontend host and send the message. Must implement is_configured()
|
||||
and an async send_email(...) method that returns a bool.
|
||||
- db (Session): SQLAlchemy Session (or equivalent) used to look up the user and
|
||||
persist the reset token.
|
||||
|
||||
Returns
|
||||
- bool: True when the operation is considered successful. This includes the cases
|
||||
where the user does not exist or is inactive (to avoid revealing account existence).
|
||||
Otherwise returns the boolean result produced by the email_service.send_email call
|
||||
(False typically indicates the email failed to send).
|
||||
|
||||
Raises
|
||||
- HTTPException (status 503): If the email service is not configured.
|
||||
- Any other exceptions raised by the DB access, token creation, or email service
|
||||
may propagate to the caller.
|
||||
|
||||
Side effects and security notes
|
||||
- A password reset token is generated and stored when a matching active user is found.
|
||||
- The function deliberately avoids disclosing whether an email address maps to a user
|
||||
or whether that user is active, to mitigate user enumeration attacks.
|
||||
- The reset link contains the raw token; callers should ensure the frontend and token
|
||||
handling enforce appropriate expiry and single-use semantics.
|
||||
- As an async function, it must be awaited.
|
||||
|
||||
Example
|
||||
- await send_password_reset_email("user@example.com", email_service, db)
|
||||
"""
|
||||
# Check if email service is configured
|
||||
if not email_service.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Email service is not configured",
|
||||
)
|
||||
|
||||
# Find user by email
|
||||
user = users_crud.get_user_by_email(email, db)
|
||||
if not user:
|
||||
# Don't reveal if email exists or not for security
|
||||
return True
|
||||
|
||||
# Check if user is active
|
||||
if user.is_active != 1:
|
||||
# Don't reveal if user is inactive for security
|
||||
return True
|
||||
|
||||
# Generate password reset token
|
||||
token = create_password_reset_token(user.id, db)
|
||||
|
||||
# Generate reset link
|
||||
reset_link = f"{email_service.frontend_host}/reset-password?token={token}"
|
||||
|
||||
# use default email message in English
|
||||
subject, html_content, text_content = (
|
||||
password_reset_tokens_email_messages.get_password_reset_email_en(
|
||||
user.name, reset_link, email_service
|
||||
)
|
||||
)
|
||||
|
||||
# Send email
|
||||
return await email_service.send_email(
|
||||
to_emails=[email],
|
||||
subject=subject,
|
||||
html_content=html_content,
|
||||
text_content=text_content,
|
||||
)
|
||||
|
||||
|
||||
def use_password_reset_token(token: str, new_password: str, db: Session):
|
||||
"""
|
||||
Use a password reset token to update a user's password and mark the token as used.
|
||||
|
||||
The function:
|
||||
- Hashes the provided plain-text token (SHA-256) and looks up the corresponding
|
||||
password reset record in the database.
|
||||
- If no matching record is found, raises an HTTPException with status 400.
|
||||
- Delegates password update to users_crud.edit_user_password.
|
||||
- Marks the token as used via password_reset_tokens_crud.mark_password_reset_token_used.
|
||||
- Logs unexpected errors and raises an HTTPException with status 500 on failure.
|
||||
|
||||
Parameters:
|
||||
- token (str): The plain-text password reset token supplied by the user. This
|
||||
function will hash it before database lookup.
|
||||
- new_password (str): The new plain-text password to set for the user. Password
|
||||
validation/hashing is expected to be handled by the underlying users_crud.
|
||||
- db (Session): An active SQLAlchemy Session (or equivalent) used for DB operations.
|
||||
Transaction management (commit/rollback) is expected to be handled by the caller
|
||||
or the CRUD functions.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
|
||||
Side effects:
|
||||
- Updates the user's password in the database.
|
||||
- Marks the password reset token record as used/consumed.
|
||||
- Writes error information to the application log on unexpected failures.
|
||||
|
||||
Exceptions:
|
||||
- Raises HTTPException(status_code=400) when the token is invalid or expired.
|
||||
- Re-raises any HTTPException raised by underlying CRUD functions.
|
||||
- Raises HTTPException(status_code=500) for unexpected internal errors.
|
||||
|
||||
Security notes:
|
||||
- The token is hashed (SHA-256) before lookup to avoid storing/using the plain token.
|
||||
- Ensure new_password meets application password policy and that users_crud
|
||||
securely hashes and salts passwords before persisting.
|
||||
"""
|
||||
# Hash the provided token to find the database record
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
# Look up the token in the database
|
||||
db_token = password_reset_tokens_crud.get_password_reset_token_by_hash(
|
||||
token_hash, db
|
||||
)
|
||||
|
||||
if not db_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or expired password reset token",
|
||||
)
|
||||
|
||||
# Update user password
|
||||
try:
|
||||
users_crud.edit_user_password(db_token.user_id, new_password, db)
|
||||
|
||||
# Mark token as used
|
||||
password_reset_tokens_crud.mark_password_reset_token_used(db_token.id, db)
|
||||
except HTTPException as http_err:
|
||||
raise http_err
|
||||
except Exception as err:
|
||||
core_logger.print_to_log(
|
||||
f"Error in use_password_reset_token: {err}", "error", exc=err
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
def delete_invalid_tokens_from_db():
|
||||
"""
|
||||
Remove expired password reset tokens from the database.
|
||||
|
||||
Opens a new database session, calls the password_reset_tokens_crud layer to
|
||||
delete any expired password reset tokens, and logs the number of deleted
|
||||
tokens if one or more were removed. The database session is guaranteed to be
|
||||
closed whether the operation succeeds or an exception is raised.
|
||||
|
||||
Behavior:
|
||||
- Creates a SessionLocal() session.
|
||||
- Invokes password_reset_tokens_crud.delete_expired_password_reset_tokens(db),
|
||||
which should return the number of deleted tokens (int).
|
||||
- If the returned count is greater than zero, logs an informational message
|
||||
via core_logger.print_to_log_and_console.
|
||||
- Always closes the database session in a finally block.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
|
||||
Exceptions:
|
||||
- Exceptions raised by the CRUD layer or the logger will propagate to the
|
||||
caller, but the database session will still be closed before propagation.
|
||||
|
||||
Notes:
|
||||
- This function performs destructive, persistent changes (deletions) and is
|
||||
intended to be run as part of maintenance (for example, a scheduled task).
|
||||
- The operation is effectively idempotent: running it repeatedly when there
|
||||
are no expired tokens will have no further effect.
|
||||
"""
|
||||
# Create a new database session
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Get num tokens deleted
|
||||
num_deleted = password_reset_tokens_crud.delete_expired_password_reset_tokens(
|
||||
db
|
||||
)
|
||||
|
||||
# Log the number of deleted tokens
|
||||
if num_deleted > 0:
|
||||
core_logger.print_to_log_and_console(
|
||||
f"Deleted {num_deleted} expired password reset tokens", "info"
|
||||
)
|
||||
finally:
|
||||
# Ensure the session is closed after use
|
||||
db.close()
|
||||
@@ -43,4 +43,4 @@ class UsersSessions(Base):
|
||||
)
|
||||
|
||||
# Define a relationship to the User model
|
||||
user = relationship("User", back_populates="users_sessions")
|
||||
user = relationship("User", back_populates="users_sessions")
|
||||
|
||||
@@ -14,7 +14,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
import session.utils as session_utils
|
||||
import session.security as session_security
|
||||
import session.constants as session_constants
|
||||
import session.crud as session_crud
|
||||
import session.schema as session_schema
|
||||
|
||||
|
||||
@@ -70,7 +70,13 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app):
|
||||
super().__init__(app)
|
||||
# Define paths that don't need CSRF protection
|
||||
self.exempt_paths = ["/api/v1/token", "/api/v1/refresh", "/api/v1/mfa/verify"]
|
||||
self.exempt_paths = [
|
||||
"/api/v1/token",
|
||||
"/api/v1/refresh",
|
||||
"/api/v1/mfa/verify",
|
||||
"/api/v1/password-reset/request",
|
||||
"/api/v1/password-reset/confirm",
|
||||
]
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Get client type from header
|
||||
|
||||
@@ -14,7 +14,6 @@ import users.user_integrations.schema as user_integrations_schema
|
||||
import users.user_integrations.crud as user_integrations_crud
|
||||
|
||||
import users.user.crud as users_crud
|
||||
import users.user.schema as user_schema
|
||||
|
||||
from core.database import SessionLocal
|
||||
|
||||
|
||||
@@ -95,6 +95,12 @@ class User(Base):
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
# Define a relationship to PasswordResetToken model
|
||||
password_reset_tokens = relationship(
|
||||
"PasswordResetToken",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
# Define a relationship to UsersIntegrations model
|
||||
users_integrations = relationship(
|
||||
"UsersIntegrations",
|
||||
|
||||
120
backend/poetry.lock
generated
120
backend/poetry.lock
generated
@@ -52,6 +52,31 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
|
||||
[package.extras]
|
||||
trio = ["trio (>=0.26.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "apprise"
|
||||
version = "1.9.4"
|
||||
description = "Push Notifications that work with just about every platform!"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "apprise-1.9.4-py3-none-any.whl", hash = "sha256:17dca8ad0a5b2063f6bae707979a51ca2cb374fcc66b0dd5c05a9d286dd40069"},
|
||||
{file = "apprise-1.9.4.tar.gz", hash = "sha256:483122aee19a89a7b075ecd48ef11ae37d79744f7aeb450bcf985a9a6c28c988"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = "*"
|
||||
click = ">=5.0"
|
||||
markdown = "*"
|
||||
PyYAML = "*"
|
||||
requests = "*"
|
||||
requests-oauthlib = "*"
|
||||
|
||||
[package.extras]
|
||||
all-plugins = ["PGPy", "cryptography", "gntp", "paho-mqtt (!=2.0.*)", "smpplib"]
|
||||
dev = ["babel", "coverage", "mock", "pytest", "pytest-cov", "pytest-mock", "ruff", "tox", "validate-pyproject"]
|
||||
windows = ["pywin32"]
|
||||
|
||||
[[package]]
|
||||
name = "apscheduler"
|
||||
version = "3.11.0"
|
||||
@@ -1332,6 +1357,22 @@ babel = ["Babel"]
|
||||
lingua = ["lingua"]
|
||||
testing = ["pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "3.8.2"
|
||||
description = "Python implementation of John Gruber's Markdown."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24"},
|
||||
{file = "markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
|
||||
testing = ["coverage", "pyyaml"]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.2"
|
||||
@@ -1405,14 +1446,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "more-itertools"
|
||||
version = "10.7.0"
|
||||
version = "10.8.0"
|
||||
description = "More routines for operating on iterables, beyond itertools"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e"},
|
||||
{file = "more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3"},
|
||||
{file = "more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b"},
|
||||
{file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1828,14 +1869,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "pbs-installer"
|
||||
version = "2025.8.28"
|
||||
version = "2025.9.2"
|
||||
description = "Installer for Python Build Standalone"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pbs_installer-2025.8.28-py3-none-any.whl", hash = "sha256:a42cd2532dbac5dc836db41cf1bc5ed26ef275214e8e30e35aff961883997048"},
|
||||
{file = "pbs_installer-2025.8.28.tar.gz", hash = "sha256:3accb1a184a048e657323c17d1c48b2969e49501b165e7200a520af7022d9bb0"},
|
||||
{file = "pbs_installer-2025.9.2-py3-none-any.whl", hash = "sha256:659a5399278c810761c1e7bc54095f38af11a5b593ce8d45c41a3a9d6759d8f1"},
|
||||
{file = "pbs_installer-2025.9.2.tar.gz", hash = "sha256:0da1d59bb5c4d8cfb5aee29ac2a37b37d651a45ab5ede19d1331df9a92464b5d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2464,6 +2505,69 @@ files = [
|
||||
{file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.2"
|
||||
description = "YAML parser and emitter for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
|
||||
{file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
|
||||
{file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"},
|
||||
{file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"},
|
||||
{file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"},
|
||||
{file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"},
|
||||
{file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"},
|
||||
{file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"},
|
||||
{file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"},
|
||||
{file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"},
|
||||
{file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"},
|
||||
{file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"},
|
||||
{file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"},
|
||||
{file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"},
|
||||
{file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"},
|
||||
{file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"},
|
||||
{file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"},
|
||||
{file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"},
|
||||
{file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"},
|
||||
{file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"},
|
||||
{file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"},
|
||||
{file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"},
|
||||
{file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"},
|
||||
{file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"},
|
||||
{file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"},
|
||||
{file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"},
|
||||
{file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"},
|
||||
{file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"},
|
||||
{file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"},
|
||||
{file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"},
|
||||
{file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"},
|
||||
{file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"},
|
||||
{file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"},
|
||||
{file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"},
|
||||
{file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"},
|
||||
{file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"},
|
||||
{file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"},
|
||||
{file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"},
|
||||
{file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"},
|
||||
{file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"},
|
||||
{file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"},
|
||||
{file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"},
|
||||
{file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"},
|
||||
{file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"},
|
||||
{file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"},
|
||||
{file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"},
|
||||
{file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"},
|
||||
{file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"},
|
||||
{file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"},
|
||||
{file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"},
|
||||
{file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"},
|
||||
{file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"},
|
||||
{file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "8.2"
|
||||
@@ -3466,5 +3570,5 @@ cffi = ["cffi (>=1.17)"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "4dc675352bb11a60f0f410a71e3c393d0b7c97714bbdb2ffdaee2cf25764606b"
|
||||
python-versions = "^3.13"
|
||||
content-hash = "43875600d84afa2ad5c6b5fbd55fa84c74b90859c1c3875b7df8599aca2e6f9c"
|
||||
|
||||
@@ -39,6 +39,7 @@ poetry = "^2.0.0"
|
||||
user-agents = "^2.2.0"
|
||||
pydantic = {extras = ["email"], version = "^2.11.3"}
|
||||
garth = "^0.5.16"
|
||||
apprise = "^1.8.0"
|
||||
pyotp = "^2.9.0"
|
||||
qrcode = {extras = ["pil"], version = "^8.2"}
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ Table below shows supported environment variables. Variables marked with optiona
|
||||
| JAEGER_PORT | 4317 | Yes | N/A |
|
||||
| BEHIND_PROXY | false | Yes | Change to true if behind reverse proxy |
|
||||
| ENVIRONMENT | production | Yes | "production" and "development" allowed. "development" allows connections from localhost:8080 and localhost:5173 at the CORS level |
|
||||
| SMTP_HOST | No default set | Yes | The SMTP host of your email provider. Example `smtp.protonmail.ch` |
|
||||
| SMTP_PORT | 587 | Yes | The SMTP port of your email provider. Default is 587 |
|
||||
| SMTP_USERNAME | No default set | Yes | The username of your SMTP email provider, probably your email address |
|
||||
| SMTP_PASSWORD | No default set | Yes | The password of your SMTP email provider. Some providers allow the use of your account password, others require the creation of an app password. Please refer to your provider documentation |
|
||||
| SMTP_SECURE | true | Yes | By default it uses secure communications. Accepted values are `true` and `false` |
|
||||
| SMTP_SECURE_TYPE | starttls | Yes | If SMTP_SECURE is set you can set the communication type. Accepted values are `starttls` and `ssl` |
|
||||
|
||||
Table below shows the obligatory environment variables for mariadb container. You should set them based on what was also set for the Endurain container.
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
Endurain is a self-hosted fitness tracking service designed to give users full control over their data and hosting environment. Built with:
|
||||
|
||||
- **Frontend:** Vue.js, Notivue and Bootstrap CSS
|
||||
- **Backend:** Python FastAPI, Alembic, SQLAlchemy, stravalib and python-garminconnect for Strava and Garmin Connect integration, gpxpy, tcxreader and fitdecode for .gpx, .tcx and .fit file import respectively
|
||||
- **Backend:** Python FastAPI, Alembic, SQLAlchemy, Apprise, stravalib and python-garminconnect for Strava and Garmin Connect integration, gpxpy, tcxreader and fitdecode for .gpx, .tcx and .fit file import respectively
|
||||
- **Database:** MariaDB or PostgreSQL for efficient data management
|
||||
- **Observability:** Jaeger for basic tracing and monitoring
|
||||
|
||||
@@ -64,6 +64,7 @@ Endurain currently supports:
|
||||
- Notification system
|
||||
- Define and track goals
|
||||
- MFA TOTP support
|
||||
- Password reset through email link. Uses Apprise for email notifications
|
||||
|
||||
## Planned Features
|
||||
|
||||
|
||||
117
frontend/app/src/components/Modals/ModalComponentEmailInput.vue
Normal file
117
frontend/app/src/components/Modals/ModalComponentEmailInput.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div
|
||||
class="modal fade"
|
||||
:id="`${modalId}`"
|
||||
tabindex="-1"
|
||||
:aria-labelledby="`${modalId}`"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" :id="`${modalId}`">{{ title }}</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="modal"
|
||||
aria-label="Close"
|
||||
></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label :for="`${modalId}Email`" class="form-label">{{ emailFieldLabel }}</label>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control"
|
||||
:class="{ 'is-invalid': !isEmailValid }"
|
||||
:name="`${modalId}Email`"
|
||||
:id="`${modalId}Email`"
|
||||
v-model="emailToEmit"
|
||||
:placeholder="emailFieldLabel"
|
||||
required
|
||||
/>
|
||||
<div id="validationEmailFeedback" class="invalid-feedback" v-if="!isEmailValid">
|
||||
{{ $t('usersAddEditUserModalComponent.addEditUserModalErrorEmailInvalid') }}
|
||||
</div>
|
||||
<div class="form-text" v-if="emailHelpText">{{ emailHelpText }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
{{ $t('generalItems.buttonClose') }}
|
||||
</button>
|
||||
<a
|
||||
type="button"
|
||||
@click="submitAction()"
|
||||
class="btn"
|
||||
:class="{
|
||||
'btn-success': actionButtonType === 'success',
|
||||
'btn-danger': actionButtonType === 'danger',
|
||||
'btn-warning': actionButtonType === 'warning',
|
||||
'btn-primary': actionButtonType === 'loading'
|
||||
}"
|
||||
:disabled="isLoading"
|
||||
><span
|
||||
v-if="isLoading"
|
||||
class="spinner-border spinner-border-sm me-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span
|
||||
>{{ actionButtonText }}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modalId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
emailFieldLabel: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
emailHelpText: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
emailDefaultValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
actionButtonType: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
actionButtonText: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['emailToEmitAction'])
|
||||
const isEmailValid = computed(() => {
|
||||
const emailRegex = /^[^\s@]{1,}@[^\s@]{2,}\.[^\s@]{2,}$/
|
||||
return emailRegex.test(emailToEmit.value)
|
||||
})
|
||||
|
||||
const emailToEmit = ref(props.emailDefaultValue)
|
||||
|
||||
function submitAction() {
|
||||
if (emailToEmit.value) {
|
||||
emit('emailToEmitAction', emailToEmit.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,52 @@
|
||||
<template>
|
||||
<div class="col">
|
||||
<div class="bg-body-tertiary rounded p-3 shadow-sm">
|
||||
<h4>{{ $t('settingsSecurityZone.subtitleChangePassword') }}</h4>
|
||||
<UsersPasswordRequirementsComponent />
|
||||
<div class="col">
|
||||
<div class="bg-body-tertiary rounded p-3 shadow-sm">
|
||||
<h4>{{ $t('settingsSecurityZone.subtitleChangePassword') }}</h4>
|
||||
<UsersPasswordRequirementsComponent />
|
||||
|
||||
<form @submit.prevent="submitChangeUserPasswordForm">
|
||||
<!-- password fields -->
|
||||
<label for="validationNewPassword"
|
||||
><b>* {{ $t('settingsSecurityZone.changeUserPasswordPasswordLabel') }}</b></label
|
||||
>
|
||||
<div class="position-relative">
|
||||
<input
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
class="form-control"
|
||||
:class="{ 'is-invalid': !isNewPasswordValid || !isPasswordMatch }"
|
||||
id="validationNewPassword"
|
||||
aria-describedby="validationNewPasswordFeedback"
|
||||
:placeholder="$t('settingsSecurityZone.changeUserPasswordPasswordLabel')"
|
||||
v-model="newPassword"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn position-absolute top-50 end-0 translate-middle-y"
|
||||
:class="{ 'me-4': !isNewPasswordValid || !isPasswordMatch }"
|
||||
@click="toggleNewPasswordVisibility"
|
||||
>
|
||||
<font-awesome-icon :icon="showNewPassword ? ['fas', 'eye-slash'] : ['fas', 'eye']" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="validationNewPasswordFeedback"
|
||||
class="invalid-feedback d-block"
|
||||
v-if="!isNewPasswordValid"
|
||||
>
|
||||
{{ $t('settingsSecurityZone.changeUserPasswordFeedbackLabel') }}
|
||||
</div>
|
||||
<div
|
||||
id="validationNewPasswordFeedback"
|
||||
class="invalid-feedback d-block"
|
||||
v-if="!isPasswordMatch"
|
||||
>
|
||||
{{ $t('settingsSecurityZone.changeUserPasswordPasswordsDoNotMatchFeedbackLabel') }}
|
||||
</div>
|
||||
<form @submit.prevent="submitChangeUserPasswordForm">
|
||||
<!-- password fields -->
|
||||
<label for="validationNewPassword"
|
||||
@@ -294,8 +337,10 @@ import { useI18n } from 'vue-i18n'
|
||||
import Modal from 'bootstrap/js/src/modal'
|
||||
// Importing the services
|
||||
import { profile } from '@/services/profileService'
|
||||
import { profile } from '@/services/profileService'
|
||||
// Import Notivue push
|
||||
import { push } from 'notivue'
|
||||
import { push } from 'notivue'
|
||||
// Importing the components
|
||||
import UsersPasswordRequirementsComponent from '@/components/Settings/SettingsUsersZone/UsersPasswordRequirementsComponent.vue'
|
||||
import LoadingComponent from '@/components/GeneralComponents/LoadingComponent.vue'
|
||||
|
||||
@@ -56,6 +56,20 @@
|
||||
<!-- Displaying loading if users are updating -->
|
||||
<LoadingComponent v-if="isUsersUpdatingLoading" />
|
||||
|
||||
<!-- list zone -->
|
||||
<ul
|
||||
class="list-group list-group-flush"
|
||||
v-for="user in usersArray"
|
||||
:key="user.id"
|
||||
:user="user"
|
||||
v-else
|
||||
>
|
||||
<UsersListComponent
|
||||
:user="user"
|
||||
@userDeleted="updateUserList"
|
||||
@editedUser="editUserList"
|
||||
/>
|
||||
</ul>
|
||||
<!-- list zone -->
|
||||
<ul
|
||||
class="list-group list-group-flush"
|
||||
@@ -102,6 +116,17 @@ import PaginationComponent from '@/components/GeneralComponents/PaginationCompon
|
||||
import UsersAddEditUserModalComponent from '@/components/Settings/SettingsUsersZone/UsersAddEditUserModalComponent.vue'
|
||||
import { users } from '@/services/usersService'
|
||||
import { useServerSettingsStore } from '@/stores/serverSettingsStore'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { push } from 'notivue'
|
||||
import { debounce } from 'lodash'
|
||||
import LoadingComponent from '@/components/GeneralComponents/LoadingComponent.vue'
|
||||
import NoItemsFoundComponent from '@/components/GeneralComponents/NoItemsFoundComponents.vue'
|
||||
import UsersListComponent from '@/components/Settings/SettingsUsersZone/UsersListComponent.vue'
|
||||
import PaginationComponent from '@/components/GeneralComponents/PaginationComponent.vue'
|
||||
import UsersAddEditUserModalComponent from '@/components/Settings/SettingsUsersZone/UsersAddEditUserModalComponent.vue'
|
||||
import { users } from '@/services/usersService'
|
||||
import { useServerSettingsStore } from '@/stores/serverSettingsStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const serverSettingsStore = useServerSettingsStore()
|
||||
|
||||
11
frontend/app/src/i18n/ca/resetPassword.json
Normal file
11
frontend/app/src/i18n/ca/resetPassword.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"title": "Reset Password",
|
||||
"newPasswordLabel": "New Password",
|
||||
"confirmPasswordLabel": "Confirm New Password",
|
||||
"submitButton": "Reset Password",
|
||||
"backToLogin": "Back to Login",
|
||||
"passwordComplexityError": "Password must be at least 8 characters long, include an uppercase letter, a number, and a special character",
|
||||
"passwordMismatchError": "Passwords do not match",
|
||||
"resetError": "Failed to reset password",
|
||||
"invalidOrExpiredToken": "Invalid or expired password reset token"
|
||||
}
|
||||
11
frontend/app/src/i18n/de/resetPassword.json
Normal file
11
frontend/app/src/i18n/de/resetPassword.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"title": "Reset Password",
|
||||
"newPasswordLabel": "New Password",
|
||||
"confirmPasswordLabel": "Confirm New Password",
|
||||
"submitButton": "Reset Password",
|
||||
"backToLogin": "Back to Login",
|
||||
"passwordComplexityError": "Password must be at least 8 characters long, include an uppercase letter, a number, and a special character",
|
||||
"passwordMismatchError": "Passwords do not match",
|
||||
"resetError": "Failed to reset password",
|
||||
"invalidOrExpiredToken": "Invalid or expired password reset token"
|
||||
}
|
||||
11
frontend/app/src/i18n/es/resetPassword.json
Normal file
11
frontend/app/src/i18n/es/resetPassword.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"title": "Reset Password",
|
||||
"newPasswordLabel": "New Password",
|
||||
"confirmPasswordLabel": "Confirm New Password",
|
||||
"submitButton": "Reset Password",
|
||||
"backToLogin": "Back to Login",
|
||||
"passwordComplexityError": "Password must be at least 8 characters long, include an uppercase letter, a number, and a special character",
|
||||
"passwordMismatchError": "Passwords do not match",
|
||||
"resetError": "Failed to reset password",
|
||||
"invalidOrExpiredToken": "Invalid or expired password reset token"
|
||||
}
|
||||
11
frontend/app/src/i18n/fr/resetPassword.json
Normal file
11
frontend/app/src/i18n/fr/resetPassword.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"title": "Reset Password",
|
||||
"newPasswordLabel": "New Password",
|
||||
"confirmPasswordLabel": "Confirm New Password",
|
||||
"submitButton": "Reset Password",
|
||||
"backToLogin": "Back to Login",
|
||||
"passwordComplexityError": "Password must be at least 8 characters long, include an uppercase letter, a number, and a special character",
|
||||
"passwordMismatchError": "Passwords do not match",
|
||||
"resetError": "Failed to reset password",
|
||||
"invalidOrExpiredToken": "Invalid or expired password reset token"
|
||||
}
|
||||
@@ -97,7 +97,8 @@ const componentPaths = {
|
||||
searchView: 'searchView.json',
|
||||
settingsView: 'settingsView.json',
|
||||
userView: 'userView.json',
|
||||
summaryView: 'summaryView.json'
|
||||
summaryView: 'summaryView.json',
|
||||
resetPassword: 'resetPassword.json'
|
||||
}
|
||||
|
||||
// Reverse map: relative path -> semantic key
|
||||
|
||||
11
frontend/app/src/i18n/nl/resetPassword.json
Normal file
11
frontend/app/src/i18n/nl/resetPassword.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"title": "Reset Password",
|
||||
"newPasswordLabel": "New Password",
|
||||
"confirmPasswordLabel": "Confirm New Password",
|
||||
"submitButton": "Reset Password",
|
||||
"backToLogin": "Back to Login",
|
||||
"passwordComplexityError": "Password must be at least 8 characters long, include an uppercase letter, a number, and a special character",
|
||||
"passwordMismatchError": "Passwords do not match",
|
||||
"resetError": "Failed to reset password",
|
||||
"invalidOrExpiredToken": "Invalid or expired password reset token"
|
||||
}
|
||||
11
frontend/app/src/i18n/pt/resetPassword.json
Normal file
11
frontend/app/src/i18n/pt/resetPassword.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"title": "Reset Password",
|
||||
"newPasswordLabel": "New Password",
|
||||
"confirmPasswordLabel": "Confirm New Password",
|
||||
"submitButton": "Reset Password",
|
||||
"backToLogin": "Back to Login",
|
||||
"passwordComplexityError": "Password must be at least 8 characters long, include an uppercase letter, a number, and a special character",
|
||||
"passwordMismatchError": "Passwords do not match",
|
||||
"resetError": "Failed to reset password",
|
||||
"invalidOrExpiredToken": "Invalid or expired password reset token"
|
||||
}
|
||||
@@ -17,5 +17,17 @@
|
||||
"signUpText": "Looking for signing up?",
|
||||
"signUpButton": "Sign up",
|
||||
"errorPublicActivityNotFound": "Public activity not found",
|
||||
"errorpublic_shareable_links": "Public shareable links are not allowed. To view this activity, you must be signed in"
|
||||
"errorPublic_shareable_links": "Public shareable links are not allowed. To view this activity, you must be signed in",
|
||||
"forgotPassword": "Forgot your password?",
|
||||
"passwordResetInvalidLink": "No password reset token provided",
|
||||
"passwordResetSuccess": "Your password has been reset successfully",
|
||||
"forgotPasswordModalTitle": "Forgot Password",
|
||||
"forgotPasswordModalEmailLabel": "Email address",
|
||||
"forgotPasswordModalEmailHelp": "Enter the email address associated with your account. An email with a link to reset your password will be sent.",
|
||||
"forgotPasswordModalSubmitButton": "Send Reset Link",
|
||||
"forgotPasswordModalEmailRequired": "Email address is required",
|
||||
"forgotPasswordModalRequestSuccess": "If the email exists in the system, a password reset link will be sent to the provided email address",
|
||||
"forgotPasswordModalRequestError": "Failed to process password reset request",
|
||||
"forgotPasswordModalEmailNotConfigured": "Email service is not configured. Please contact the administrator",
|
||||
"forgotPasswordModalUnableToSendEmail": "Unable to send email. Please try again later or contact the administrator"
|
||||
}
|
||||
|
||||
11
frontend/app/src/i18n/us/resetPassword.json
Normal file
11
frontend/app/src/i18n/us/resetPassword.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"title": "Reset Password",
|
||||
"newPasswordLabel": "New Password",
|
||||
"confirmPasswordLabel": "Confirm New Password",
|
||||
"submitButton": "Reset Password",
|
||||
"backToLogin": "Back to Login",
|
||||
"passwordComplexityError": "Password must be at least 8 characters long, include an uppercase letter, a number, and a special character",
|
||||
"passwordMismatchError": "Passwords do not match",
|
||||
"resetError": "Failed to reset password",
|
||||
"invalidOrExpiredToken": "Invalid or expired password reset token"
|
||||
}
|
||||
@@ -15,6 +15,12 @@ const routes = [
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'reset-password',
|
||||
component: () => import('../views/ResetPasswordView.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/gears',
|
||||
name: 'gears',
|
||||
|
||||
10
frontend/app/src/services/passwordResetService.js
Normal file
10
frontend/app/src/services/passwordResetService.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { fetchPostRequest } from '@/utils/serviceUtils'
|
||||
|
||||
export const passwordReset = {
|
||||
requestPasswordReset(data) {
|
||||
return fetchPostRequest('password-reset/request', data)
|
||||
},
|
||||
confirmPasswordReset(data) {
|
||||
return fetchPostRequest('password-reset/confirm', data)
|
||||
}
|
||||
}
|
||||
@@ -20,5 +20,11 @@ export const session = {
|
||||
},
|
||||
logoutUser() {
|
||||
return fetchPostRequest('logout', null)
|
||||
},
|
||||
requestPasswordReset(data) {
|
||||
return fetchPostRequest('password-reset/request', data)
|
||||
},
|
||||
confirmPasswordReset(data) {
|
||||
return fetchPostRequest('password-reset/confirm', data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,17 +7,19 @@ export const API_URL = `${window.env.ENDURAIN_HOST}/api/v1/`
|
||||
export const FRONTEND_URL = `${window.env.ENDURAIN_HOST}/`
|
||||
|
||||
async function fetchWithRetry(url, options, responseType = 'json') {
|
||||
// Add CSRF token to headers for state-changing requests
|
||||
if (
|
||||
['POST', 'PUT', 'DELETE', 'PATCH'].includes(options.method) &&
|
||||
url !== 'token' &&
|
||||
url !== 'refresh' &&
|
||||
url !== 'mfa/verify'
|
||||
) {
|
||||
const csrfToken = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('endurain_csrf_token='))
|
||||
?.split('=')[1]
|
||||
// Add CSRF token to headers for state-changing requests
|
||||
if (
|
||||
['POST', 'PUT', 'DELETE', 'PATCH'].includes(options.method) &&
|
||||
url !== 'token' &&
|
||||
url !== 'refresh' &&
|
||||
url !== 'mfa/verify' &&
|
||||
url !== 'password-reset/request' &&
|
||||
url !== 'password-reset/confirm'
|
||||
) {
|
||||
const csrfToken = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('endurain_csrf_token='))
|
||||
?.split('=')[1]
|
||||
|
||||
if (csrfToken) {
|
||||
options.headers = {
|
||||
|
||||
@@ -90,11 +90,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forgot Password Modal -->
|
||||
<ModalComponentEmailInput
|
||||
ref="forgotPasswordModalRef"
|
||||
modal-id="forgotPasswordModal"
|
||||
:title="$t('loginView.forgotPasswordModalTitle')"
|
||||
:email-field-label="$t('loginView.forgotPasswordModalEmailLabel')"
|
||||
:email-help-text="$t('loginView.forgotPasswordModalEmailHelp')"
|
||||
action-button-type="success"
|
||||
:action-button-text="$t('loginView.forgotPasswordModalSubmitButton')"
|
||||
:is-loading="forgotPasswordLoading"
|
||||
@emailToEmitAction="handleForgotPasswordSubmit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Importing the vue composition API
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, nextTick, watch } from 'vue'
|
||||
// Importing the router
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
// Importing the i18n
|
||||
@@ -106,7 +119,11 @@ import { useAuthStore } from '@/stores/authStore'
|
||||
import { useServerSettingsStore } from '@/stores/serverSettingsStore'
|
||||
// Importing the services for the login
|
||||
import { session } from '@/services/sessionService'
|
||||
import { passwordReset } from '@/services/passwordResetService'
|
||||
import { profile } from '@/services/profileService'
|
||||
import { Modal } from 'bootstrap'
|
||||
// Importing modal component
|
||||
import ModalComponentEmailInput from '@/components/Modals/ModalComponentEmailInput.vue'
|
||||
|
||||
// Variables
|
||||
const route = useRoute()
|
||||
@@ -125,6 +142,18 @@ const loginPhotoUrl = serverSettingsStore.serverSettings.login_photo_set
|
||||
? `${window.env.ENDURAIN_HOST}/server_images/login.png`
|
||||
: null
|
||||
|
||||
// Forgot password variables
|
||||
const forgotPasswordLoading = ref(false)
|
||||
const forgotPasswordModalRef = ref(null)
|
||||
let forgotPasswordModalInstance = null
|
||||
|
||||
// Function to show forgot password modal
|
||||
const showForgotPasswordModal = () => {
|
||||
if (forgotPasswordModalInstance) {
|
||||
forgotPasswordModalInstance.show()
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle password visibility
|
||||
const togglePasswordVisibility = () => {
|
||||
showPassword.value = !showPassword.value
|
||||
@@ -200,24 +229,64 @@ const completeLogin = async (session_id) => {
|
||||
// Store the user in the auth store
|
||||
authStore.setUser(userProfile, session_id, locale)
|
||||
|
||||
// Redirect to the home page
|
||||
return router.push('/')
|
||||
}
|
||||
|
||||
// Handle login errors
|
||||
const handleLoginError = (error) => {
|
||||
if (error.toString().includes('401')) {
|
||||
push.error(`${t('loginView.error401')} - ${error}`)
|
||||
} else if (error.toString().includes('403')) {
|
||||
push.error(`${t('loginView.error403')} - ${error}`)
|
||||
} else if (error.toString().includes('500')) {
|
||||
push.error(`${t('loginView.error500')} - ${error}`)
|
||||
} else {
|
||||
push.error(`${t('loginView.errorUndefined')} - ${error}`)
|
||||
// Redirect to the home page
|
||||
return router.push('/')
|
||||
} catch (error) {
|
||||
// Handle the error
|
||||
if (error.toString().includes('401')) {
|
||||
push.error(`${t('loginView.error401')} - ${error}`)
|
||||
} else if (error.toString().includes('403')) {
|
||||
push.error(`${t('loginView.error403')} - ${error}`)
|
||||
} else if (error.toString().includes('500')) {
|
||||
push.error(`${t('loginView.error500')} - ${error}`)
|
||||
} else {
|
||||
push.error(`${t('loginView.errorUndefined')} - ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Forgot password form submission
|
||||
const handleForgotPasswordSubmit = async (email) => {
|
||||
if (!email) {
|
||||
push.error(t('loginView.forgotPasswordModalEmailRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
forgotPasswordLoading.value = true
|
||||
|
||||
try {
|
||||
await passwordReset.requestPasswordReset({
|
||||
email: email
|
||||
})
|
||||
|
||||
push.success(t('loginView.forgotPasswordModalRequestSuccess'))
|
||||
} catch (error) {
|
||||
if (error.toString().includes('500')) {
|
||||
push.error(t('loginView.forgotPasswordModalUnableToSendEmail'))
|
||||
} else if (error.toString().includes('503')) {
|
||||
push.error(t('loginView.forgotPasswordModalEmailNotConfigured'))
|
||||
} else {
|
||||
push.error(`${t('loginView.forgotPasswordModalRequestError')} - ${error}`)
|
||||
}
|
||||
} finally {
|
||||
forgotPasswordLoading.value = false
|
||||
|
||||
// Close modal
|
||||
if (forgotPasswordModalInstance) {
|
||||
forgotPasswordModalInstance.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Initialize the modal
|
||||
await nextTick()
|
||||
if (forgotPasswordModalRef.value) {
|
||||
// Access the modal element from the component
|
||||
const modalElement = forgotPasswordModalRef.value.$el
|
||||
forgotPasswordModalInstance = new Modal(modalElement)
|
||||
}
|
||||
|
||||
// Check if the session expired
|
||||
if (route.query.sessionExpired === 'true') {
|
||||
push.warning(t('loginView.sessionExpired'))
|
||||
@@ -232,7 +301,15 @@ onMounted(() => {
|
||||
}
|
||||
// Check if the public shareable links are disabled
|
||||
if (route.query.errorpublic_shareable_links === 'true') {
|
||||
push.error(t('loginView.errorpublic_shareable_links'))
|
||||
push.error(t('loginView.errorPublic_shareable_links'))
|
||||
}
|
||||
// Check for password reset success
|
||||
if (route.query.passwordResetSuccess === 'true') {
|
||||
push.success(t('loginView.passwordResetSuccess'))
|
||||
}
|
||||
// Check for password reset invalid link
|
||||
if (route.query.passwordResetInvalidLink === 'true') {
|
||||
push.error(t('loginView.passwordResetInvalidLink'))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
162
frontend/app/src/views/ResetPasswordView.vue
Normal file
162
frontend/app/src/views/ResetPasswordView.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 p-3 rounded bg-body-tertiary shadow-sm">
|
||||
<h1>{{ $t('resetPassword.title') }}</h1>
|
||||
<form @submit.prevent="submitResetForm">
|
||||
<div class="mb-3">
|
||||
<label for="newPassword" class="form-label">{{
|
||||
$t('resetPassword.newPasswordLabel')
|
||||
}}</label>
|
||||
<div class="position-relative">
|
||||
<input
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
class="form-control"
|
||||
:class="{ 'is-invalid': !isNewPasswordValid && newPassword }"
|
||||
id="newPassword"
|
||||
v-model="newPassword"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn position-absolute top-50 end-0 translate-middle-y me-2"
|
||||
@click="toggleNewPasswordVisibility"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="showNewPassword ? ['fas', 'eye-slash'] : ['fas', 'eye']"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!isNewPasswordValid && newPassword" class="invalid-feedback d-block">
|
||||
{{ $t('resetPassword.passwordComplexityError') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirmPassword" class="form-label">{{
|
||||
$t('resetPassword.confirmPasswordLabel')
|
||||
}}</label>
|
||||
<div class="position-relative">
|
||||
<input
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
class="form-control"
|
||||
:class="{ 'is-invalid': !isPasswordMatch && confirmPassword }"
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn position-absolute top-50 end-0 translate-middle-y me-2"
|
||||
@click="toggleConfirmPasswordVisibility"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="showConfirmPassword ? ['fas', 'eye-slash'] : ['fas', 'eye']"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!isPasswordMatch && confirmPassword" class="invalid-feedback d-block">
|
||||
{{ $t('resetPassword.passwordMismatchError') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="!isNewPasswordValid || !isPasswordMatch || resetLoading"
|
||||
>
|
||||
<span
|
||||
v-if="resetLoading"
|
||||
class="spinner-border spinner-border-sm me-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{{ $t('resetPassword.submitButton') }}
|
||||
</button>
|
||||
<router-link to="/login" class="btn btn-secondary">
|
||||
{{ $t('resetPassword.backToLogin') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { push } from 'notivue'
|
||||
import { passwordReset } from '@/services/passwordResetService'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Form data
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const showNewPassword = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
const resetLoading = ref(false)
|
||||
|
||||
// Get token from query params
|
||||
const token = route.query.token
|
||||
|
||||
// Password validation regex (same as backend)
|
||||
const passwordRegex =
|
||||
/^(?=.*[A-Z])(?=.*\d)(?=.*[ !"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])[A-Za-z\d !"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]{8,}$/
|
||||
|
||||
// Computed properties
|
||||
const isNewPasswordValid = computed(() => {
|
||||
if (!newPassword.value) return true
|
||||
return passwordRegex.test(newPassword.value)
|
||||
})
|
||||
|
||||
const isPasswordMatch = computed(() => {
|
||||
if (!confirmPassword.value) return true
|
||||
return newPassword.value === confirmPassword.value
|
||||
})
|
||||
|
||||
// Methods
|
||||
const toggleNewPasswordVisibility = () => {
|
||||
showNewPassword.value = !showNewPassword.value
|
||||
}
|
||||
|
||||
const toggleConfirmPasswordVisibility = () => {
|
||||
showConfirmPassword.value = !showConfirmPassword.value
|
||||
}
|
||||
|
||||
const submitResetForm = async () => {
|
||||
if (!isNewPasswordValid.value || !isPasswordMatch.value) {
|
||||
return
|
||||
}
|
||||
|
||||
resetLoading.value = true
|
||||
|
||||
try {
|
||||
await passwordReset.confirmPasswordReset({
|
||||
token: token,
|
||||
new_password: newPassword.value
|
||||
})
|
||||
|
||||
// Redirect to login with success message
|
||||
router.push('/login?passwordResetSuccess=true')
|
||||
} catch (error) {
|
||||
if (error.toString().includes('400')) {
|
||||
push.error(t('resetPassword.invalidOrExpiredToken'))
|
||||
} else {
|
||||
push.error(`${t('resetPassword.resetError')} - ${error}`)
|
||||
}
|
||||
} finally {
|
||||
resetLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Check if token is provided
|
||||
if (!token) {
|
||||
router.push('/login?passwordResetInvalidLink=true')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user