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
5 changes: 5 additions & 0 deletions blog_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ class PasswordsDoNotMatchError(ValueError):
pass


class WeakPasswordError(ValueError):
"""Raised when password does not meet strength requirements (length, uppercase, lowercase, special char)."""
pass


class CommentEmptyError(ValueError):
"""Raised when comment content is empty."""
pass
Expand Down
Empty file removed flask_setup/__init__.py
Empty file.
4 changes: 3 additions & 1 deletion frontend/core/components/ArticleForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,8 @@ export default function ArticleForm() {
if (res.ok) {
const data = await res.json();
window.location.href = `/articles/${data.id || articleId}`;
} else if (res.status === 401) {
window.location.href = '/';
} else {
const err = await res.json();
setError(err.error || _('Failed to save.'));
Expand Down Expand Up @@ -435,7 +437,7 @@ export default function ArticleForm() {
<span className="article-editor-label">{_('Description')}</span>
</div>
<div className="article-editor-section-header">
<span className="desc-limit-hint">{_('Maximum 300 characters')}</span>
<span className="field-hint">{_('Maximum 300 characters')}</span>
<span className="char-counter">{description.length}/300</span>
</div>
<textarea
Expand Down
28 changes: 19 additions & 9 deletions frontend/static/css/article.css
Original file line number Diff line number Diff line change
Expand Up @@ -436,13 +436,12 @@ input[type=number] {
.modal-input-group {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
gap: 0;
}

.modal-input-group label {
font-size: var(--font-body-base);
color: var(--on-surface-variant);
font-family: var(--font-mono);
}

.stepper-container {
Expand Down Expand Up @@ -742,13 +741,6 @@ a.comment-author:hover {
text-decoration: underline;
}

.comment-limit-hint,
.desc-limit-hint {
display: block;
font-size: var(--font-body-sm);
color: var(--text-secondary);
}

.comments-section {
margin-top: var(--spacing-xl);
}
Expand Down Expand Up @@ -1838,4 +1830,22 @@ a.comment-author:hover {
}
}

/* Comment Rules */
.comment-rules {
font-size: 0.8125rem;
color: var(--on-surface-variant);
background: var(--surface-container);
border: 1px solid var(--outline-variant);
border-radius: var(--radius-lg);
padding: var(--spacing-sm) var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.comment-rules p {
margin: 0 0 var(--spacing-xs);
}
.comment-rules ul {
margin: 0;
padding-left: var(--spacing-lg);
}


8 changes: 8 additions & 0 deletions frontend/static/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -778,3 +778,11 @@ hr {
.nav-item-home {
font-size: var(--font-headline-md);
}

.field-hint {
display: block;
font-size: var(--font-body-xs);
color: var(--text-secondary);
text-align: justify;
margin-bottom: 0.125rem;
}
3 changes: 1 addition & 2 deletions frontend/static/css/profile.css
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,12 @@
.modal-input-group {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
gap: 0;
}

.modal-input-group label {
font-size: var(--font-body-base);
color: var(--on-surface-variant);
font-family: var(--font-mono);
}

.back-link {
Expand Down
3 changes: 1 addition & 2 deletions frontend/static/css/user-list.css
Original file line number Diff line number Diff line change
Expand Up @@ -378,13 +378,12 @@ input.search-input {
.dialog .modal-input-group {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
gap: 0;
}

.dialog .modal-input-group label {
font-size: var(--font-body-base);
color: var(--on-surface-variant);
font-family: var(--font-mono);
}

.dialog .stepper-container {
Expand Down
12 changes: 11 additions & 1 deletion frontend/templates/article_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,17 @@ <h3 class="comments-title">{{ _('Comments (%(count)s)', count=comment_count) }}<
{% if current_user %}
<div class="comment-join-section">
<h4 class="comment-join-title" id="comment-form-title">{{ _('Join the discussion') }}</h4>
<span class="comment-limit-hint">{{ _('Comment must be between 1 and 5000 characters') }}</span>
<div class="comment-rules">
<p><strong>{{ _('Comment rules:') }}</strong></p>
<ul>
<li>{{ _('No links, advertising, or spam.') }}</li>
<li>{{ _('No profanity, insults, or hate speech.') }}</li>
<li>{{ _('Do not post the same message multiple times.') }}</li>
<li>{{ _('Stay respectful and on topic.') }}</li>
<li><strong>{{ _('Violation results in account ban without notice.') }}</strong></li>
</ul>
</div>
<span class="field-hint">{{ _('Comment must be between 1 and 5000 characters') }}</span>
<form action="{{ url_for('comment.create_comment', article_id=article.article_id) }}" method="POST"
id="comment-form"
data-create-url="{{ url_for('comment.create_comment', article_id=article.article_id) }}"
Expand Down
2 changes: 1 addition & 1 deletion frontend/templates/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ <h1 class="account-title">{{ _('Welcome Back') }}</h1>
<div class="form-group account-form-group">
<label for="login-password">{{ _('Password') }}</label>
<div class="password-wrapper">
<input type="password" id="login-password" name="password" placeholder="{{ _('e.g., MyP@ssw0rd!') }}" autocomplete="current-password" required>
<input type="password" id="login-password" name="password" placeholder="{{ _('e.g., MyP@ssw0rd!') }}" autocomplete="current-password" minlength="8" required>
<button type="button" class="password-toggle" aria-label="{{ _('Toggle password visibility') }}" aria-pressed="false">{{ icon('visibility') }}</button>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion frontend/templates/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,9 @@ <h2 class="modal-title">{{ _('Update Password') }}</h2>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-input-group">
<label for="new-password-input">{{ _('New Password') }}</label>
<span class="field-hint">Minimum 8 caractères : au moins une majuscule, une minuscule et un caractère spécial</span>
<div class="password-wrapper">
<input type="password" id="new-password-input" name="new_password" placeholder="{{ _('e.g., MyP@ssw0rd!') }}" required>
<input type="password" id="new-password-input" name="new_password" placeholder="{{ _('e.g., MyP@ssw0rd!') }}" minlength="8" required>
<button type="button" class="password-toggle" aria-label="{{ _('Toggle password visibility') }}" aria-pressed="false">{{ icon('visibility') }}</button>
</div>
</div>
Expand Down
21 changes: 18 additions & 3 deletions frontend/templates/registration.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,38 @@ <h1 class="account-title">{{ _('Join DevJournal') }}</h1>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="registration-username">{{ _('Choose Username') }}</label>
<input type="text" id="registration-username" name="username" placeholder="{{ _('e.g., dev_journey') }}" autocomplete="username" value="{{ username or '' }}" required>
<span class="field-hint">{{ _('3\u201330 characters: letters, numbers, underscores, hyphens') }}</span>
<input type="text" id="registration-username" name="username"
placeholder="{{ _('e.g., dev_journey') }}"
autocomplete="username"
minlength="3" maxlength="30"
pattern="[a-zA-Z0-9_-]{3,30}"
title="{{ _('3-30 characters: letters, numbers, underscores, hyphens') }}"
value="{{ username or '' }}" required>
</div>
<div class="form-group">
<label for="registration-email">{{ _('Email Address') }}</label>
<input type="email" id="registration-email" name="email" placeholder="{{ _('e.g., user@example.com') }}" autocomplete="email" value="{{ email or '' }}" required>
</div>
<div class="form-group">
<label for="registration-password">{{ _('Password') }}</label>
<span class="field-hint">{{ _('Minimum 8 characters: at least one uppercase, one lowercase, and one special character') }}</span>
<div class="password-wrapper">
<input type="password" id="registration-password" name="password" placeholder="{{ _('e.g., MyP@ssw0rd!') }}" autocomplete="new-password" required>
<input type="password" id="registration-password" name="password"
placeholder="{{ _('e.g., MyP@ssw0rd!') }}"
autocomplete="new-password"
minlength="8" required>
<button type="button" class="password-toggle" aria-label="{{ _('Toggle password visibility') }}" aria-pressed="false">{{ icon('visibility') }}</button>
</div>
</div>
<div class="form-group account-form-group">
<label for="registration-confirm-password">{{ _('Confirm Password') }}</label>
<span class="field-hint">{{ _('Minimum 8 characters: at least one uppercase, one lowercase, and one special character') }}</span>
<div class="password-wrapper">
<input type="password" id="registration-confirm-password" name="confirm_password" placeholder="{{ _('e.g., MyP@ssw0rd!') }}" autocomplete="new-password" required>
<input type="password" id="registration-confirm-password" name="confirm_password"
placeholder="{{ _('e.g., MyP@ssw0rd!') }}"
autocomplete="new-password"
minlength="8" required>
<button type="button" class="password-toggle" aria-label="{{ _('Toggle password visibility') }}" aria-pressed="false">{{ icon('visibility') }}</button>
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions migrations/V18__add_session_token_and_username_limit.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ALTER TABLE accounts
ADD COLUMN session_token VARCHAR(64);

ALTER TABLE accounts
ALTER COLUMN account_username TYPE VARCHAR(30);
Empty file removed src/__init__.py
Empty file.
Empty file removed src/application/__init__.py
Empty file.
Empty file removed src/application/domain/__init__.py
Empty file.
4 changes: 4 additions & 0 deletions src/application/domain/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class Account:
avatar_file_id (str | None): UUID of the avatar file in uploaded_files, or None.
is_banned (bool): Whether the account is currently banned.
ban_reason (str | None): Optional reason provided by admin when banning.
session_token (str | None): Random token for unique session enforcement.
"""

def __init__(
Expand All @@ -39,6 +40,7 @@ def __init__(
avatar_file_id: str | None = None,
is_banned: bool = False,
ban_reason: str | None = None,
session_token: str | None = None,
):
"""
Initialize a user account.
Expand All @@ -53,6 +55,7 @@ def __init__(
avatar_file_id (str | None): UUID of the avatar file in uploaded_files, or None.
is_banned (bool): Whether the account is currently banned. Defaults to False.
ban_reason (str | None): Optional reason provided by admin when banning.
session_token (str | None): Random token for unique session enforcement.
"""
self.account_id = account_id
self.account_username = account_username
Expand All @@ -63,3 +66,4 @@ def __init__(
self.avatar_file_id = avatar_file_id
self.is_banned = is_banned
self.ban_reason = ban_reason
self.session_token = session_token
Empty file.
Empty file.
14 changes: 14 additions & 0 deletions src/application/output_ports/account_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,20 @@ def update_ban_status(self, account_id: int, is_banned: bool, ban_reason: str |
"""
pass

@abstractmethod
def update_session_token(self, account_id: int, token: str | None) -> None:
"""
Updates the session token for a given account.

Setting token to None effectively invalidates all existing sessions,
forcing the user to re-authenticate on next login attempt.

Args:
account_id: The ID of the account to update.
token: The new session token, or None to clear.
"""
pass

@abstractmethod
def delete(self, account_id: int) -> None:
"""
Expand Down
Empty file.
35 changes: 31 additions & 4 deletions src/application/services/login_service.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import re
import secrets

from blog_exceptions import (
AccountBannedError,
AccountNotFoundError,
AuthenticationError,
AuthorizationError,
EmailAlreadyTakenError,
WeakPasswordError,
)
from src.application.domain.account import Account, AccountRole
from src.application.input_ports.account_session_management import AccountSessionManagementPort
Expand Down Expand Up @@ -43,7 +47,9 @@ def authenticate_user(self, username: str, password: str) -> Account:
"""
Authenticates a user by verifying credentials.

If successful, the account is saved in the session.
If successful, a new session token is generated and persisted
in the database (invalidating any previous session), and the
account is saved in the session cookie.
If the existing password hash uses outdated parameters,
it is seamlessly upgraded to the current Argon2 settings.

Expand Down Expand Up @@ -72,6 +78,9 @@ def authenticate_user(self, username: str, password: str) -> Account:
account.account_password = new_hash
self.account_repository.save(account)

token = secrets.token_urlsafe(32)
account.session_token = token
self.account_repository.update_session_token(account.account_id, token)
self.session_repository.save_account(account)
return account

Expand All @@ -90,7 +99,13 @@ def get_current_account(self) -> Account | None:
def terminate_session(self) -> None:
"""
Terminates the current active session, effectively logging the user out.

Clears the session token in the database before wiping the session
cookie, ensuring the token cannot be reused.
"""
current = self.get_current_account()
if current:
self.account_repository.update_session_token(current.account_id, None)
self.session_repository.clear()

def get_account_by_username(self, username: str) -> Account | None:
Expand Down Expand Up @@ -166,15 +181,16 @@ def update_password(self, new_password: str) -> None:
"""
Updates the password for the currently logged-in account.

Retrieves the current account from the session, hashes the new
password using the password hasher, and persists the change
via the account repository.
Validates password strength (lowercase, uppercase, special char),
then hashes and persists via the account repository.

Args:
new_password: The new plaintext password to set.

Raises:
AuthenticationError: If the user is not signed in.
WeakPasswordError: If password lacks lowercase, uppercase,
or special character.
"""
account = self.get_current_account()
if not account:
Expand All @@ -183,6 +199,13 @@ def update_password(self, new_password: str) -> None:
if not new_password:
return

if not re.search(r"[a-z]", new_password):
raise WeakPasswordError("Password must contain at least one lowercase letter.")
if not re.search(r"[A-Z]", new_password):
raise WeakPasswordError("Password must contain at least one uppercase letter.")
if not re.search(r"[^a-zA-Z0-9]", new_password):
raise WeakPasswordError("Password must contain at least one special character.")

new_hash = self.password_hasher_repository.hash(new_password)
self.account_repository.update_password(account.account_id, new_hash)

Expand Down Expand Up @@ -292,6 +315,9 @@ def ban_account(self, admin_id: int, target_account_id: int, ban_reason: str | N
"""
Bans a user account. Only admins can ban non-admin accounts.

Clears the session token to force immediate disconnection
on the next request from any active session.

Args:
admin_id: The unique identifier of the admin performing the action.
target_account_id: The unique identifier of the account to ban.
Expand All @@ -314,6 +340,7 @@ def ban_account(self, admin_id: int, target_account_id: int, ban_reason: str | N
raise AuthorizationError("Cannot ban another admin.")

self.account_repository.update_ban_status(target_account_id, True, ban_reason)
self.account_repository.update_session_token(target_account_id, None)

def unban_account(self, admin_id: int, target_account_id: int) -> None:
"""
Expand Down
Empty file removed src/infrastructure/__init__.py
Empty file.
Empty file.
2 changes: 1 addition & 1 deletion src/infrastructure/input_adapters/dto/login_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ class LoginRequest(BaseModel):
"""

username: str = Field(..., min_length=1, description="The account username.")
password: str = Field(..., min_length=1, description="The account password.")
password: str = Field(..., min_length=8, description="The account password. Minimum 8 characters.")
Loading
Loading