ZapBB Forum Platform
Version: 1.0
Status: Draft
Date: January 20, 2026
Reference: PRD 34c32848.md
This document specifies the security architecture, authentication mechanisms, authorization model, and security best practices for ZapBB.
Web App Sessions (NextAuth.js):
- Cookie-based sessions managed by NextAuth.js (Auth.js)
- Supports credentials and OAuth providers
- Session used by Next.js for SSR and backend API calls
API Clients / Service Tokens (JWT):
- Bearer JWTs issued by the backend for non-browser clients
- Short-lived access tokens with refresh token rotation
Access Token (JWT):
{
"sub": "user_uuid",
"email": "user@example.com",
"role": "member",
"permissions": ["thread.create", "post.create"],
"iat": 1234567890,
"exp": 1234570890 // 15 minutes validity
}Refresh Token:
- Stored in Redis with 7-day TTL
- One-time use (rotated on each refresh)
- Tied to specific device/session
Algorithm: Argon2id
Configuration:
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2
};
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(password.as_bytes(), &salt)?
.to_string();
Ok(password_hash)
}
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(hash)?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}Requirements:
- Minimum 8 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one number
- At least one special character (optional but recommended)
1. User signs in via credentials or OAuth
2. NextAuth validates provider/credentials
3. Session is created (httpOnly cookie)
4. Next.js uses session to access backend APIs
1. User submits registration form
├─→ Validate email format
├─→ Check username uniqueness
├─→ Check email uniqueness
└─→ Validate password strength
2. Hash password with Argon2
└─→ Generate salt
└─→ Hash password
3. Create user record
├─→ Insert into database
└─→ Set email_verified = false
4. Generate email verification token
└─→ Store in Redis (24h TTL)
5. Send verification email
└─→ SMTP transport
6. Generate JWT + Refresh token
└─→ Return to client
7. Store refresh token in Redis
└─→ Key: refresh_token:{token_id}
└─→ Value: {user_id, device_info}
└─→ TTL: 7 days
1. User submits credentials
├─→ Rate limit check (5 attempts/min)
└─→ Validate input format
2. Lookup user by email/username
└─→ Return 401 if not found (don't leak info)
3. Verify password
├─→ Compare with Argon2
└─→ Return 401 if invalid
4. Check user status
├─→ Is banned?
└─→ Is email verified? (optional enforcement)
5. Generate JWT + Refresh token
└─→ Sign with secret key
6. Store refresh token in Redis
└─→ Invalidate old tokens for device
7. Update last_seen timestamp
└─→ Record IP address
8. Return tokens to client
└─→ JWT in response body
└─→ Optional: Set httpOnly cookie
1. Client sends refresh token
└─→ Extract from request body
2. Validate token signature
└─→ Check not expired
3. Lookup in Redis
├─→ Return 401 if not found (revoked)
└─→ Get user_id
4. Generate new JWT + Refresh token
└─→ Rotate tokens
5. Delete old refresh token
└─→ Remove from Redis
6. Store new refresh token
└─→ Save to Redis
7. Return new tokens
Storage:
- NextAuth.js session store (database adapter or JWT strategy)
- Redis for JWT refresh tokens (API clients)
Session Data Structure (API Refresh Tokens):
{
"user_id": "uuid",
"device_info": {
"user_agent": "Mozilla/5.0...",
"ip_address": "192.168.1.1",
"device_id": "uuid"
},
"created_at": "2026-01-20T04:00:00Z",
"last_activity": "2026-01-20T04:30:00Z"
}Session Expiration:
- Idle timeout: 30 minutes (sliding window)
- Absolute timeout: 7 days
- Refresh on each request
| Role | Description | Default Permissions |
|---|---|---|
| Guest | Unauthenticated users | category.view (public) |
| Member | Registered users | thread.create, post.create, post.edit_own, thread.edit_own |
| Moderator | Forum moderators | Member + post.moderate, thread.moderate, user.warn |
| Admin | Site administrators | All permissions |
Permission Hierarchy:
admin.* (all admin permissions)
├── admin.settings (modify site settings)
├── admin.users (manage users)
└── admin.plugins (manage plugins)
moderation.*
├── thread.lock
├── thread.pin
├── thread.move
├── post.moderate
├── post.delete_any
├── user.warn
└── user.ban
content.*
├── thread.create
├── thread.edit_own
├── thread.delete_own
├── post.create
├── post.edit_own
└── post.delete_own
pub async fn check_permission(
user: &User,
permission: &str,
resource: Option<&Resource>,
) -> Result<bool> {
// 1. Check if user has permission via role
if user.role.permissions.contains(&permission.to_string()) {
return Ok(true);
}
// 2. Check wildcard permissions
let permission_parts: Vec<&str> = permission.split('.').collect();
if permission_parts.len() > 1 {
let wildcard = format!("{}.*", permission_parts[0]);
if user.role.permissions.contains(&wildcard) {
return Ok(true);
}
}
// 3. Check resource-specific permissions (e.g., category)
if let Some(resource) = resource {
if let Some(category_perms) = get_category_permissions(
user.role.id,
resource.category_id
).await? {
// Check category-specific override
return Ok(category_perms.has_permission(permission));
}
}
Ok(false)
}// Axum middleware for authentication
pub async fn auth_middleware(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
// Extract JWT from Authorization header
let auth_header = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok());
let token = match auth_header {
Some(header) if header.starts_with("Bearer ") => {
&header[7..]
}
_ => return Err(StatusCode::UNAUTHORIZED),
};
// Validate and decode JWT
let claims = match validate_jwt(token, &state.jwt_secret).await {
Ok(claims) => claims,
Err(_) => return Err(StatusCode::UNAUTHORIZED),
};
// Load user from database
let user = state.db
.get_user_by_id(&claims.sub)
.await?
.ok_or(StatusCode::UNAUTHORIZED)?;
// Check if user is banned
if user.is_banned {
return Err(StatusCode::FORBIDDEN);
}
// Inject user into request extensions
req.extensions_mut().insert(user);
Ok(next.run(req).await)
}use ammonia::clean;
pub fn sanitize_html(input: &str) -> String {
clean(input)
}
pub fn sanitize_markdown(input: &str) -> String {
// Convert markdown to HTML
let html = markdown::to_html(input);
// Sanitize HTML
clean(&html)
}Allowed HTML Tags:
- Text:
<p>,<br>,<span> - Formatting:
<strong>,<em>,<u>,<code>,<pre> - Lists:
<ul>,<ol>,<li> - Links:
<a>(with href whitelist) - Images:
<img>(with src whitelist) - Quotes:
<blockquote>
Frontend (React):
- React automatically escapes by default
- Use
dangerouslySetInnerHTMLonly for sanitized content
export function PostContent({ content_html }: { content_html: string }) {
return (
<div
className="prose"
dangerouslySetInnerHTML={{ __html: content_html }}
/>
);
}Token-Based CSRF:
// Generate CSRF token
pub fn generate_csrf_token() -> String {
use rand::{thread_rng, Rng};
let token: String = thread_rng()
.sample_iter(&Alphanumeric)
.take(32)
.map(char::from)
.collect();
token
}
// Validate CSRF token
pub fn validate_csrf_token(
session_token: &str,
request_token: &str,
) -> bool {
use subtle::ConstantTimeEq;
session_token.as_bytes().ct_eq(request_token.as_bytes()).into()
}Implementation:
- CSRF token stored in session (Redis)
- Token included in forms as hidden field
- Token validated on state-changing requests (POST, PUT, DELETE)
Using sqlx with Compile-Time Checking:
// SAFE: Parameterized query
let users = sqlx::query_as!(
User,
r#"
SELECT * FROM users
WHERE email = $1
"#,
email
)
.fetch_all(&pool)
.await?;
// UNSAFE: Don't do this
let query = format!("SELECT * FROM users WHERE email = '{}'", email);Query Validation:
- All queries use parameterized statements
- sqlx validates queries at compile time
- No dynamic SQL construction
pub struct FileUploadConfig {
pub max_size: usize, // 5 MB
pub allowed_mime_types: Vec<String>,
pub scan_virus: bool,
}
pub async fn validate_upload(
file: &[u8],
config: &FileUploadConfig,
) -> Result<()> {
// 1. Check file size
if file.len() > config.max_size {
return Err(Error::FileTooLarge);
}
// 2. Detect MIME type from content
let detected_mime = tree_magic_mini::from_u8(file);
// 3. Validate MIME type
if !config.allowed_mime_types.contains(&detected_mime.to_string()) {
return Err(Error::InvalidFileType);
}
// 4. Virus scan (if enabled)
if config.scan_virus {
scan_file_for_virus(file).await?;
}
Ok(())
}Allowed File Types:
- Images:
image/jpeg,image/png,image/gif,image/webp - Documents:
application/pdf - Archives:
application/zip(with size limits)
ClamAV Integration:
use clamav_client::ClamAVClient;
pub async fn scan_file_for_virus(data: &[u8]) -> Result<()> {
let client = ClamAVClient::new("localhost:3310")?;
let result = client.scan_bytes(data).await?;
match result {
ScanResult::Clean => Ok(()),
ScanResult::Infected(virus) => {
Err(Error::VirusDetected(virus))
}
}
}Storage Path Structure:
uploads/
├── attachments/
│ ├── 2026/
│ │ ├── 01/
│ │ │ ├── 20/
│ │ │ │ └── {uuid}.{ext}
Filename Sanitization:
pub fn sanitize_filename(filename: &str) -> String {
filename
.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => c,
_ => '_',
})
.collect()
}use governor::{Quota, RateLimiter};
pub struct RateLimitConfig {
pub requests_per_minute: u32,
pub burst_size: u32,
}
pub async fn rate_limit_middleware(
State(limiter): State<Arc<RateLimiter>>,
req: Request,
next: Next,
) -> Result<Response> {
let ip = get_client_ip(&req);
match limiter.check_key(&ip) {
Ok(_) => Ok(next.run(req).await),
Err(_) => Err(StatusCode::TOO_MANY_REQUESTS),
}
}| Endpoint Type | Limit | Window |
|---|---|---|
| Authentication | 5 requests | 1 minute |
| Registration | 3 requests | 1 hour |
| Read Operations | 100 requests | 1 minute |
| Write Operations | 30 requests | 1 minute |
| Search | 20 requests | 1 minute |
| File Upload | 10 requests | 1 minute |
pub fn add_security_headers(response: &mut Response) {
let headers = response.headers_mut();
headers.insert(
"X-Content-Type-Options",
HeaderValue::from_static("nosniff"),
);
headers.insert(
"X-Frame-Options",
HeaderValue::from_static("DENY"),
);
headers.insert(
"X-XSS-Protection",
HeaderValue::from_static("1; mode=block"),
);
headers.insert(
"Strict-Transport-Security",
HeaderValue::from_static("max-age=31536000; includeSubDomains"),
);
headers.insert(
"Content-Security-Policy",
HeaderValue::from_static(
"default-src 'self'; \
script-src 'self' 'unsafe-inline'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data: https:; \
font-src 'self' data:;"
),
);
}User Rights:
- Right to Access: Export user data (JSON)
- Right to Erasure: Delete account + anonymize posts
- Right to Rectification: Update user information
- Right to Portability: Export in machine-readable format
Implementation:
pub async fn export_user_data(user_id: Uuid) -> Result<UserDataExport> {
let user = get_user(user_id).await?;
let posts = get_user_posts(user_id).await?;
let threads = get_user_threads(user_id).await?;
let reactions = get_user_reactions(user_id).await?;
Ok(UserDataExport {
user,
posts,
threads,
reactions,
export_date: Utc::now(),
})
}
pub async fn delete_user_data(user_id: Uuid) -> Result<()> {
// Soft delete user
sqlx::query!(
"UPDATE users SET is_deleted = TRUE WHERE id = $1",
user_id
)
.execute(&pool)
.await?;
// Anonymize posts (keep content for discussion continuity)
sqlx::query!(
r#"
UPDATE posts
SET author_id = '00000000-0000-0000-0000-000000000000'
WHERE author_id = $1
"#,
user_id
)
.execute(&pool)
.await?;
Ok(())
}pub struct AuditLog {
pub event_type: AuditEventType,
pub user_id: Option<Uuid>,
pub ip_address: IpAddr,
pub metadata: serde_json::Value,
pub timestamp: DateTime<Utc>,
}
pub enum AuditEventType {
LoginSuccess,
LoginFailure,
PasswordChange,
PermissionChange,
DataExport,
DataDeletion,
AdminAction,
}
pub async fn log_audit_event(event: AuditLog) -> Result<()> {
sqlx::query!(
r#"
INSERT INTO audit_logs (event_type, user_id, ip_address, metadata)
VALUES ($1, $2, $3, $4)
"#,
event.event_type.to_string(),
event.user_id,
event.ip_address.to_string(),
event.metadata
)
.execute(&pool)
.await?;
Ok(())
}-
Dependency Scanning:
cargo audit
-
Static Analysis:
cargo clippy -- -W clippy::security
-
OWASP ZAP Scanning:
- Automated penetration testing
- API security testing
- All passwords hashed with Argon2
- NextAuth.js sessions configured with secure cookies
- JWT tokens signed and validated (API clients)
- CSRF protection on all state-changing requests
- Rate limiting on all endpoints
- Input validation and sanitization
- Output encoding
- SQL injection prevention (parameterized queries)
- File upload validation and virus scanning
- Security headers configured
- HTTPS enforced in production
- Session management secure
- Audit logging implemented
- GDPR compliance (data export/deletion)
- PRD Document: 34c32848.md
- OWASP Top 10: https://owasp.org/www-project-top-ten/
- NIST Cybersecurity Framework: https://www.nist.gov/cyberframework
Document Status: Draft
Next Review: Upon implementation start