Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@ When sharing sensitive records with doctors, employers, financial institutions,
- **Global File Inventory**: Platform-wide vault explorer with SHA-256 hash inspection, encryption status verification, and physical disk file cleanup.
- **Emergency Pass Governance**: System-wide pass monitor with instant emergency revocation capabilities.

### ⚙️ 7. Account Settings & Profile Management
- **Unified Settings Dashboard**: Accessible at `/settings/` for standard users and administrators with `Cache-Control: no-store` and strict CSRF protection.
- **Profile Customization**: Optional display name (up to 50 characters, control character sanitized) and profile avatar.
- **Validated Avatar Pipeline**: Validated with Pillow MIME headers (JPEG, PNG, WebP only; max 2 MB; 10M pixel decompression bomb guard; animated/multi-frame files rejected), square-cropped, resized to 256x256, and re-encoded to WebP with EXIF/metadata stripped. Served strictly through an authenticated Django route with `X-Content-Type-Options: nosniff` and `Cache-Control: private` (no public `/media/` routing).
- **Username Change Policy**: Usernames can be changed at most once every 30 days, enforced under atomic database row locks (`select_for_update()`). Validated against reserved system identifiers and '@' symbol exclusion to prevent login ambiguity.
- **Two-Step Email Change**: Email addresses are never modified without verification. Changes require current password authorization, store pending state isolated from the user record, dispatch a 6-digit OTP to the new address, and send security notifications to the old address if verified. Addresses are atomically re-checked upon confirmation.
- **Dual-Path Password Change**:
- *Standard*: Requires current password re-authentication, enforces `AUTH_PASSWORD_VALIDATORS`, rejects previous passwords, updates session auth hashes to maintain current session while invalidating all other sessions, and sends notification to verified emails.
- *Forgot Password*: Authorized users with verified emails can receive a 6-digit email OTP (purpose `change_password`), unlocking a 10-minute session window to reset their password without the old credential.
- **Database-Backed Action Throttling**: 5 consecutive failed current-password attempts lock the action for 15 minutes, doubling exponentially per subsequent failure up to 24 hours.
- **Account Event Audit**: Records security milestones (`username_changed`, `email_change_requested`, `email_changed`, `password_changed`, `password_otp_sent`, `avatar_updated`, `avatar_removed`, `reauth_failed`) with sanitized client IP and user agent metadata.
- **Transparent Security Disclosures**:
- *No Two-Factor Authentication (2FA/TOTP)*: Account login and settings do not currently implement time-based one-time password (TOTP) or hardware security key 2FA.
- *Avatars Stored Unencrypted*: Unlike vault documents which use AES-256-GCM AEAD encryption at rest, avatar images are stored as standard WebP image files on disk under `MEDIA_ROOT/avatars/`.
- *Email OTPs via SMTP*: Verification and reset codes are delivered as 6-digit numerical OTPs via standard transactional SMTP email.

---

## 🔐 Cryptographic Architecture Flow
Expand Down
12 changes: 12 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ We actively support and release security updates for the following versions of P
- Access events are recorded in an append-only relational audit table (`ShareAccessLog`), capturing client IP, user agent, event type, and outcome timestamp for owner visibility.
- Sensitive values (passcode characters, authorization tokens, full email addresses) are sanitized and masked before log persistence.

### 9. Account Settings Security Model
- **Username Modification Policy**: Usernames may be changed at most once every 30 days. Changes are checked and committed under atomic database row locks (`select_for_update()`) on `UserProfile` to prevent race conditions. Usernames cannot contain '@' symbols, eliminating authentication ambiguity with email addresses.
- **Two-Step Email Verification**: Modifying an account email requires re-authenticating with the current password. The new email is stored in a pending state and verified via a 6-digit OTP code dispatched to the new address. Unverified old addresses receive no codes, while verified old addresses receive security notifications on request and completion. Uniqueness is re-verified atomically upon code confirmation.
- **Two-Path Password Change & DB Throttling**:
- *Standard*: Requires current password, enforces Django password validators, rejects current password reuse, rotates session auth hashes (invalidating all other device sessions), and notifies the verified email.
- *Forgot Password*: Accessible only when a verified email is on file; dispatches a purpose-scoped 6-digit OTP granting a 10-minute session authorization window to set a new password without current credential entry.
- *Brute-Force Throttling*: Failed current-password attempts are tracked in the database (`ActionThrottle`): 5 failures trigger a 15-minute action lockout, doubling exponentially per subsequent failure up to 24 hours.
- **Transparent Security Disclosures**:
- **No 2FA**: The application does not currently support multi-factor authentication (MFA) or time-based one-time password (TOTP) app authenticators.
- **Avatars Stored Unencrypted**: User avatars are stored as standard unencrypted WebP files under `MEDIA_ROOT/avatars/`. They are never exposed via public `/media/` routes and are served strictly through an authenticated view enforcing `X-Content-Type-Options: nosniff` and `Cache-Control: private`.
- **Email OTP via SMTP**: Verification codes are delivered as 6-digit numerical codes through transactional SMTP email.

---

## 🚨 Reporting a Vulnerability
Expand Down
109 changes: 109 additions & 0 deletions accounts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,3 +365,112 @@ def clean_code(self):
if not raw.isdigit() or len(raw) != 6:
raise ValidationError("Please enter a valid 6-digit numerical code.")
return raw


class PasswordChangeNormalForm(forms.Form):
"""Form to change password with current password authentication."""

current_password = forms.CharField(
required=True,
widget=forms.PasswordInput(
attrs={
"class": "form-input",
"placeholder": "Enter your current password",
"autocomplete": "current-password",
"id": "settings-current-password",
}
),
)
new_password = forms.CharField(
required=True,
widget=forms.PasswordInput(
attrs={
"class": "form-input",
"placeholder": "Enter new secure password",
"autocomplete": "new-password",
"id": "settings-new-password",
}
),
)
confirm_password = forms.CharField(
required=True,
widget=forms.PasswordInput(
attrs={
"class": "form-input",
"placeholder": "Confirm new password",
"autocomplete": "new-password",
"id": "settings-confirm-password",
}
),
)

def clean(self):
cleaned_data = super().clean()
new_pwd = cleaned_data.get("new_password")
confirm_pwd = cleaned_data.get("confirm_password")
if new_pwd and confirm_pwd and new_pwd != confirm_pwd:
self.add_error("confirm_password", "The new passwords do not match.")
return cleaned_data


class PasswordResetOTPConfirmForm(forms.Form):
"""Form to submit 6-digit OTP code for forgotten password reset authorization."""

code = forms.CharField(
max_length=6,
min_length=6,
required=True,
widget=forms.TextInput(
attrs={
"class": "form-input font-mono-data",
"placeholder": "123456",
"id": "password-otp-combined-input",
"autocomplete": "one-time-code",
"inputmode": "numeric",
"pattern": "[0-9]{6}",
"maxlength": "6",
"style": "letter-spacing: 0.25em; text-align: center; font-size: 1.15rem; max-width: 200px;",
}
),
)

def clean_code(self):
raw = self.cleaned_data.get("code", "").strip()
if not raw.isdigit() or len(raw) != 6:
raise ValidationError("Please enter a valid 6-digit numerical code.")
return raw


class SetNewPasswordAfterOTPForm(forms.Form):
"""Form to set new password after email OTP session authorization."""

new_password = forms.CharField(
required=True,
widget=forms.PasswordInput(
attrs={
"class": "form-input",
"placeholder": "Enter new secure password",
"autocomplete": "new-password",
"id": "settings-otp-new-password",
}
),
)
confirm_password = forms.CharField(
required=True,
widget=forms.PasswordInput(
attrs={
"class": "form-input",
"placeholder": "Confirm new password",
"autocomplete": "new-password",
"id": "settings-otp-confirm-password",
}
),
)

def clean(self):
cleaned_data = super().clean()
new_pwd = cleaned_data.get("new_password")
confirm_pwd = cleaned_data.get("confirm_password")
if new_pwd and confirm_pwd and new_pwd != confirm_pwd:
self.add_error("confirm_password", "The new passwords do not match.")
return cleaned_data
164 changes: 163 additions & 1 deletion accounts/views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import secrets
from datetime import timedelta
from datetime import datetime, timedelta

from pathlib import Path
from django.conf import settings
Expand All @@ -26,6 +26,9 @@
UsernameChangeForm,
EmailChangeRequestForm,
EmailChangeConfirmForm,
PasswordChangeNormalForm,
PasswordResetOTPConfirmForm,
SetNewPasswordAfterOTPForm,
)
from .models import (
UserProfile,
Expand All @@ -50,6 +53,15 @@
confirm_email_change,
cancel_email_change,
)
from .services.password_service import (
change_password_normal,
request_password_reset_otp,
verify_password_reset_otp,
change_password_via_otp_session,
get_password_change_throttle,
PASSWORD_RESET_SESSION_KEY,
PASSWORD_RESET_WINDOW_MINUTES,
)
from .services.otp_service import issue_otp
from .services.email_service import send_templated_email
from documents.models import Document
Expand Down Expand Up @@ -616,6 +628,96 @@ def account_settings_view(request):
messages.info(request, "Pending email change request cancelled.")
return redirect(reverse("account_settings") + "#email")

elif action == "change_password_normal":
pwd_normal_form = PasswordChangeNormalForm(request.POST)
if pwd_normal_form.is_valid():
try:
change_password_normal(
user=user,
current_password=pwd_normal_form.cleaned_data["current_password"],
new_password=pwd_normal_form.cleaned_data["new_password"],
confirm_password=pwd_normal_form.cleaned_data["confirm_password"],
request=request,
)
messages.success(request, "Your master password has been successfully updated.")
return redirect(reverse("account_settings") + "#password")
except ValidationError as exc:
messages.error(request, exc.message if hasattr(exc, "message") else str(exc))
return redirect(reverse("account_settings") + "#password")
else:
err_msg = (
pwd_normal_form.errors.get("current_password")
or pwd_normal_form.errors.get("new_password")
or pwd_normal_form.errors.get("confirm_password")
)
if err_msg:
messages.error(request, err_msg[0])
else:
messages.error(request, "Please check the password form for errors.")
return redirect(reverse("account_settings") + "#password")

elif action == "request_password_otp":
try:
success, msg = request_password_reset_otp(user, request=request)
if success:
request.session["pwd_reset_otp_requested"] = True
messages.success(request, msg)
else:
messages.error(request, msg)
except ValidationError as exc:
messages.error(request, exc.message if hasattr(exc, "message") else str(exc))
return redirect(reverse("account_settings") + "#password")

elif action == "verify_password_otp":
pwd_otp_form = PasswordResetOTPConfirmForm(request.POST)
if pwd_otp_form.is_valid():
success, msg = verify_password_reset_otp(
user=user,
submitted_code=pwd_otp_form.cleaned_data["code"],
request=request,
)
if success:
request.session.pop("pwd_reset_otp_requested", None)
messages.success(request, msg)
else:
messages.error(request, msg)
else:
err = pwd_otp_form.errors.get("code")
messages.error(request, err[0] if err else "Please enter a valid 6-digit code.")
return redirect(reverse("account_settings") + "#password")

elif action == "set_password_after_otp":
pwd_set_form = SetNewPasswordAfterOTPForm(request.POST)
if pwd_set_form.is_valid():
try:
change_password_via_otp_session(
user=user,
new_password=pwd_set_form.cleaned_data["new_password"],
confirm_password=pwd_set_form.cleaned_data["confirm_password"],
request=request,
)
messages.success(request, "Your master password has been successfully updated.")
except ValidationError as exc:
messages.error(request, exc.message if hasattr(exc, "message") else str(exc))
else:
err_msg = pwd_set_form.errors.get("new_password") or pwd_set_form.errors.get("confirm_password")
if err_msg:
messages.error(request, err_msg[0])
else:
messages.error(request, "Please check the password form for errors.")
return redirect(reverse("account_settings") + "#password")

elif action == "cancel_password_reset":
request.session.pop(PASSWORD_RESET_SESSION_KEY, None)
request.session.pop("pwd_reset_otp_requested", None)
EmailVerificationCode.objects.filter(
user=user,
purpose=EmailVerificationCode.PURPOSE_CHANGE_PASSWORD,
is_used=False,
).update(is_used=True)
messages.info(request, "Password reset cancelled.")
return redirect(reverse("account_settings") + "#password")

# GET request form instances and context
profile_form = ProfileForm(initial={"display_name": profile.display_name})
avatar_form = AvatarUploadForm()
Expand All @@ -640,6 +742,56 @@ def account_settings_view(request):
if elapsed < 60:
resend_cooldown_seconds = max(0, int(60 - elapsed))

# Password throttle status
is_pwd_locked, pwd_lockout_minutes, pwd_failures = get_password_change_throttle(user)

# Check 10-minute session authorization earned via OTP verification
is_pwd_reset_authorized = False
auth_str = request.session.get(PASSWORD_RESET_SESSION_KEY)
if auth_str:
try:
auth_time = datetime.fromisoformat(auth_str)
if timezone.is_naive(auth_time):
auth_time = timezone.make_aware(auth_time)
if timezone.now() <= auth_time + timedelta(minutes=PASSWORD_RESET_WINDOW_MINUTES):
is_pwd_reset_authorized = True
else:
request.session.pop(PASSWORD_RESET_SESSION_KEY, None)
except (ValueError, TypeError):
request.session.pop(PASSWORD_RESET_SESSION_KEY, None)

# Check if an OTP code is awaiting confirmation for change_password
pwd_otp_pending = False
if not is_pwd_reset_authorized:
active_pwd_code = EmailVerificationCode.objects.filter(
user=user,
purpose=EmailVerificationCode.PURPOSE_CHANGE_PASSWORD,
is_used=False,
expires_at__gt=timezone.now(),
).first()
if (active_pwd_code and not active_pwd_code.is_locked) or request.session.get("pwd_reset_otp_requested"):
pwd_otp_pending = True

pwd_resend_cooldown = 0
if pwd_otp_pending:
latest_pwd_code = (
EmailVerificationCode.objects.filter(
user=user,
purpose=EmailVerificationCode.PURPOSE_CHANGE_PASSWORD,
)
.order_by("-created_at")
.first()
)
if latest_pwd_code:
elapsed = (timezone.now() - latest_pwd_code.created_at).total_seconds()
if elapsed < 60:
pwd_resend_cooldown = max(0, int(60 - elapsed))

can_use_forgot_pwd = bool(user.email and profile.is_email_verified)
password_normal_form = PasswordChangeNormalForm()
password_otp_form = PasswordResetOTPConfirmForm()
password_set_new_form = SetNewPasswordAfterOTPForm()

context = {
"profile": profile,
"profile_form": profile_form,
Expand All @@ -654,6 +806,16 @@ def account_settings_view(request):
"has_avatar": bool(profile.avatar_path),
"avatar_url": reverse("avatar_serve_self") if profile.avatar_path else None,
}
context["is_auth_locked"] = is_pwd_locked
context["lockout_minutes"] = pwd_lockout_minutes
context["throttle_failures"] = pwd_failures
context["is_reset_authorized"] = is_pwd_reset_authorized
context["reset_otp_pending"] = pwd_otp_pending
context["reset_resend_wait"] = pwd_resend_cooldown
context["can_use_recovery"] = can_use_forgot_pwd
context["password_normal_form"] = password_normal_form
context["password_otp_form"] = password_otp_form
context["password_set_new_form"] = password_set_new_form

response = render(request, "accounts/settings.html", context)
response["Cache-Control"] = "no-store, no-cache, must-revalidate, private"
Expand Down
Loading
Loading