Skip to content

Commit 307d069

Browse files
committed
fix: stabilize auth with token invalidation, security hardening, and differentiated paste rate limits
- Invalidate old reset tokens in forgot_password (was select-only, no-op) - Invalidate old verification tokens in resend_verification_email - Fix TokenExpiredError to use actual token type instead of hardcoded "access token" - Narrow get_optional_current_user exception catch to auth-specific errors - Fix stale get_exempt_key reference in paste routes via module-level access - Add max_length=512 to LoginRequest fields to prevent Argon2 DoS - Broaden _verify_password to catch InvalidHashError and HashingError - Deduplicate password validation logic into shared function - Add differentiated rate limits: authenticated users get 20/min vs 4/min anonymous for paste creation, using slowapi key-based limit resolution
1 parent 5245243 commit 307d069

8 files changed

Lines changed: 1447 additions & 35 deletions

File tree

backend/.env.example

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ APP_RATELIMIT_DEFAULT=60/minute
103103
# APP_RATELIMIT_GET_PASTE=10/minute
104104
# APP_RATELIMIT_GET_PASTE_LEGACY=10/minute
105105
# APP_RATELIMIT_CREATE_PASTE=4/minute
106+
# APP_RATELIMIT_CREATE_PASTE_AUTHENTICATED=20/minute
106107
# APP_RATELIMIT_EDIT_PASTE=4/minute
107108
# APP_RATELIMIT_DELETE_PASTE=4/minute
108109

@@ -116,3 +117,65 @@ APP_RATELIMIT_DEFAULT=60/minute
116117
# Production deployments should set this to a strong random token
117118
# APP_METRICS_TOKEN=your_secure_random_token_here
118119
# Example generation: openssl rand -hex 32
120+
121+
# =============================================================================
122+
# Authentication Configuration
123+
# =============================================================================
124+
125+
# JWT Configuration (REQUIRED for auth)
126+
# IMPORTANT: Change this in production! Must be at least 32 characters.
127+
# Generate with: openssl rand -hex 32
128+
APP_JWT_SECRET_KEY=CHANGE_ME_IN_PRODUCTION_32_CHARS_MIN
129+
# Access token lifetime (default: 15 minutes)
130+
APP_JWT_ACCESS_TOKEN_EXPIRE_MINUTES=15
131+
# Refresh token lifetime (default: 7 days)
132+
APP_JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
133+
# JWT algorithm (default: HS256)
134+
APP_JWT_ALGORITHM=HS256
135+
136+
# SMTP Configuration (REQUIRED for email verification and password reset)
137+
# Leave empty to disable email sending (emails logged instead in dev)
138+
APP_SMTP_HOST=smtp.example.com
139+
APP_SMTP_PORT=587
140+
APP_SMTP_USERNAME=your_smtp_username
141+
APP_SMTP_PASSWORD=your_smtp_password
142+
APP_SMTP_FROM_EMAIL=noreply@devbin.dev
143+
APP_SMTP_USE_TLS=true
144+
145+
# Frontend URL (REQUIRED for email links)
146+
# Used for verification and password reset links in emails
147+
APP_FRONTEND_URL=http://localhost:3000
148+
# Path for email verification links (default: /verify-email)
149+
APP_EMAIL_VERIFY_PATH=/verify-email
150+
# Path for password reset links (default: /reset-password)
151+
APP_PASSWORD_RESET_PATH=/reset-password
152+
153+
# Email Token Expiry
154+
# Verification token lifetime in hours (default: 24)
155+
APP_EMAIL_VERIFICATION_EXPIRE_HOURS=24
156+
# Password reset token lifetime in hours (default: 1)
157+
APP_PASSWORD_RESET_EXPIRE_HOURS=1
158+
159+
# Auth Rate Limits (optional, these are defaults)
160+
# Format: <number>/<second|minute|hour|day>
161+
# APP_RATELIMIT_AUTH_REGISTER=5/hour
162+
# APP_RATELIMIT_AUTH_LOGIN=10/minute
163+
# APP_RATELIMIT_AUTH_REFRESH=20/minute
164+
# APP_RATELIMIT_AUTH_VERIFY_EMAIL=10/minute
165+
# APP_RATELIMIT_AUTH_RESEND_VERIFICATION=3/hour
166+
# APP_RATELIMIT_AUTH_FORGOT_PASSWORD=3/hour
167+
# APP_RATELIMIT_AUTH_RESET_PASSWORD=5/hour
168+
# APP_RATELIMIT_AUTH_ME=60/minute
169+
# APP_RATELIMIT_AUTH_LOGOUT=20/minute
170+
171+
# Password Requirements (optional, these are defaults)
172+
# Minimum password length
173+
# APP_PASSWORD_MIN_LENGTH=8
174+
# Require at least one uppercase letter
175+
# APP_PASSWORD_REQUIRE_UPPERCASE=true
176+
# Require at least one lowercase letter
177+
# APP_PASSWORD_REQUIRE_LOWERCASE=true
178+
# Require at least one digit
179+
# APP_PASSWORD_REQUIRE_DIGIT=true
180+
# Require at least one special character
181+
# APP_PASSWORD_REQUIRE_SPECIAL=false

backend/app/api/dto/auth_dto.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Authentication DTOs for request/response models."""
2+
3+
import re
4+
from datetime import datetime
5+
6+
from pydantic import UUID4, BaseModel, EmailStr, Field, field_validator
7+
8+
from app.config import config
9+
10+
11+
def validate_password_requirements(v: str) -> str:
12+
"""Validate password meets configured requirements."""
13+
errors = []
14+
15+
if config.PASSWORD_REQUIRE_UPPERCASE and not re.search(r"[A-Z]", v):
16+
errors.append("at least one uppercase letter")
17+
18+
if config.PASSWORD_REQUIRE_LOWERCASE and not re.search(r"[a-z]", v):
19+
errors.append("at least one lowercase letter")
20+
21+
if config.PASSWORD_REQUIRE_DIGIT and not re.search(r"\d", v):
22+
errors.append("at least one digit")
23+
24+
if config.PASSWORD_REQUIRE_SPECIAL and not re.search(
25+
r"[!@#$%^&*(),.?\":{}|<>]", v
26+
):
27+
errors.append("at least one special character")
28+
29+
if errors:
30+
raise ValueError(f"Password must contain {', '.join(errors)}")
31+
32+
return v
33+
34+
35+
class RegisterRequest(BaseModel):
36+
"""Request model for user registration."""
37+
38+
username: str = Field(
39+
min_length=3,
40+
max_length=50,
41+
description="Username (3-50 characters, alphanumeric and underscores)",
42+
examples=["john_doe"],
43+
)
44+
email: EmailStr = Field(
45+
description="Valid email address",
46+
examples=["john@example.com"],
47+
)
48+
password: str = Field(
49+
min_length=config.PASSWORD_MIN_LENGTH,
50+
max_length=128,
51+
description="Password meeting security requirements",
52+
)
53+
54+
@field_validator("username")
55+
@classmethod
56+
def validate_username(cls, v: str) -> str:
57+
"""Validate username format."""
58+
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", v):
59+
raise ValueError(
60+
"Username must start with a letter and contain only letters, numbers, and underscores"
61+
)
62+
return v.lower()
63+
64+
@field_validator("password")
65+
@classmethod
66+
def validate_password(cls, v: str) -> str:
67+
"""Validate password meets requirements."""
68+
return validate_password_requirements(v)
69+
70+
71+
class LoginRequest(BaseModel):
72+
"""Request model for user login."""
73+
74+
username: str = Field(
75+
min_length=1,
76+
max_length=512,
77+
description="Username or email",
78+
examples=["john_doe"],
79+
)
80+
password: str = Field(
81+
min_length=1,
82+
max_length=512,
83+
description="User password",
84+
)
85+
86+
87+
class TokenResponse(BaseModel):
88+
"""Response model for authentication tokens."""
89+
90+
access_token: str = Field(description="JWT access token")
91+
refresh_token: str = Field(
92+
description="JWT refresh token for obtaining new access tokens"
93+
)
94+
token_type: str = Field(default="Bearer", description="Token type")
95+
expires_in: int = Field(description="Access token expiration time in seconds")
96+
97+
98+
class RefreshTokenRequest(BaseModel):
99+
"""Request model for token refresh."""
100+
101+
refresh_token: str = Field(description="Refresh token to exchange for new tokens")
102+
103+
104+
class VerifyEmailRequest(BaseModel):
105+
"""Request model for email verification."""
106+
107+
token: str = Field(description="Email verification token")
108+
109+
110+
class ForgotPasswordRequest(BaseModel):
111+
"""Request model for forgot password."""
112+
113+
email: EmailStr = Field(
114+
description="Email address associated with the account",
115+
examples=["john@example.com"],
116+
)
117+
118+
119+
class ResetPasswordRequest(BaseModel):
120+
"""Request model for password reset."""
121+
122+
token: str = Field(description="Password reset token")
123+
new_password: str = Field(
124+
min_length=config.PASSWORD_MIN_LENGTH,
125+
max_length=128,
126+
description="New password meeting security requirements",
127+
)
128+
129+
@field_validator("new_password")
130+
@classmethod
131+
def validate_password(cls, v: str) -> str:
132+
"""Validate password meets requirements."""
133+
return validate_password_requirements(v)
134+
135+
136+
class ResendVerificationRequest(BaseModel):
137+
"""Request model for resending verification email."""
138+
139+
email: EmailStr = Field(
140+
description="Email address to resend verification to",
141+
examples=["john@example.com"],
142+
)
143+
144+
145+
class UserResponse(BaseModel):
146+
"""Response model for user profile."""
147+
148+
id: UUID4 = Field(description="User UUID")
149+
username: str = Field(description="Username")
150+
email: EmailStr = Field(description="Email address")
151+
is_verified: bool = Field(description="Whether email is verified")
152+
created_at: datetime = Field(description="Account creation timestamp")
153+
last_login_at: datetime | None = Field(description="Last login timestamp")
154+
155+
model_config = {"from_attributes": True}
156+
157+
158+
class MessageResponse(BaseModel):
159+
"""Generic message response."""
160+
161+
message: str = Field(description="Response message")

backend/app/api/subroutes/pastes.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from starlette.requests import Request
1010
from starlette.responses import PlainTextResponse, Response
1111

12+
import app.ratelimit as ratelimit
1213
from app.api.dto.Error import ErrorResponse
1314
from app.api.dto.paste_dto import (
1415
CreatePaste,
@@ -19,8 +20,9 @@
1920
)
2021
from app.config import config
2122
from app.containers import Container
23+
from app.dependencies.auth import get_optional_current_user
2224
from app.exceptions import PasteNotFoundError
23-
from app.ratelimit import create_limit_resolver, get_exempt_key, limiter
25+
from app.ratelimit import create_auth_aware_key_func, create_auth_aware_limit_resolver, create_limit_resolver, limiter
2426
from app.services.paste_service import PasteService
2527
from app.utils.LRUMemoryCache import LRUMemoryCache
2628
from app.utils.metrics import cache_operations
@@ -43,6 +45,15 @@ def set_cache(cache_instance: "RedisCache | LRUMemoryCache"):
4345
cache = cache_instance
4446

4547

48+
async def _resolve_optional_user(
49+
request: Request,
50+
user=Depends(get_optional_current_user),
51+
):
52+
"""Resolve optional user and attach to request.state for rate limiter access."""
53+
request.state.current_user = user
54+
return user
55+
56+
4657
edit_token_key_header = APIKeyHeader(name="Authorization", scheme_name="Edit Token")
4758
delete_token_key_header = APIKeyHeader(name="Authorization", scheme_name="Delete Token")
4859

@@ -53,7 +64,7 @@ def set_cache(cache_instance: "RedisCache | LRUMemoryCache"):
5364
summary="Get legacy Hastebin-format paste",
5465
description="Retrieve a paste stored in legacy Hastebin format by its ID.",
5566
)
56-
@limiter.limit(create_limit_resolver(config, "get_paste_legacy"), key_func=get_exempt_key)
67+
@limiter.limit(create_limit_resolver(config, "get_paste_legacy"), key_func=lambda r: ratelimit.get_exempt_key(r))
5768
@inject
5869
async def get_legacy_paste(
5970
request: Request,
@@ -96,7 +107,7 @@ async def get_legacy_paste(
96107
summary="Get paste by UUID",
97108
description="Retrieve a paste by its UUID identifier.",
98109
)
99-
@limiter.limit(create_limit_resolver(config, "get_paste"), key_func=get_exempt_key)
110+
@limiter.limit(create_limit_resolver(config, "get_paste"), key_func=lambda r: ratelimit.get_exempt_key(r))
100111
@inject
101112
async def get_paste_by_uuid(
102113
request: Request,
@@ -140,7 +151,7 @@ async def get_paste_by_uuid(
140151
summary="Get raw paste content",
141152
description="Retrieve only the raw text content of a paste. Useful for curl/wget users.",
142153
)
143-
@limiter.limit(create_limit_resolver(config, "get_paste"), key_func=get_exempt_key)
154+
@limiter.limit(create_limit_resolver(config, "get_paste"), key_func=lambda r: ratelimit.get_exempt_key(r))
144155
@inject
145156
async def get_paste_raw(
146157
request: Request,
@@ -189,12 +200,16 @@ async def get_paste_raw(
189200
summary="Create a new paste",
190201
description="Create a new paste with the provided content and metadata.",
191202
)
192-
@limiter.limit(create_limit_resolver(config, "create_paste"), key_func=get_exempt_key)
203+
@limiter.limit(
204+
create_auth_aware_limit_resolver(config, "create_paste", "create_paste_authenticated"),
205+
key_func=create_auth_aware_key_func(config),
206+
)
193207
@inject
194208
async def create_paste(
195209
request: Request,
196210
create_paste_body: CreatePaste,
197211
paste_service: PasteService = Depends(Provide[Container.paste_service]),
212+
_current_user=Depends(_resolve_optional_user),
198213
):
199214
"""Create a new paste and return edit/delete tokens."""
200215
return await paste_service.create_paste(create_paste_body, request.state.user_metadata)
@@ -206,7 +221,7 @@ async def create_paste(
206221
summary="Edit an existing paste",
207222
description="Update a paste's content or metadata. Requires a valid edit token.",
208223
)
209-
@limiter.limit(create_limit_resolver(config, "edit_paste"), key_func=get_exempt_key)
224+
@limiter.limit(create_limit_resolver(config, "edit_paste"), key_func=lambda r: ratelimit.get_exempt_key(r))
210225
@inject
211226
async def edit_paste(
212227
request: Request,
@@ -230,7 +245,7 @@ async def edit_paste(
230245
summary="Delete a paste",
231246
description="Permanently delete a paste. Requires a valid delete token.",
232247
)
233-
@limiter.limit(create_limit_resolver(config, "delete_paste"), key_func=get_exempt_key)
248+
@limiter.limit(create_limit_resolver(config, "delete_paste"), key_func=lambda r: ratelimit.get_exempt_key(r))
234249
@inject
235250
async def delete_paste(
236251
request: Request,

0 commit comments

Comments
 (0)