Skip to content

Latest commit

 

History

History
733 lines (587 loc) · 16.6 KB

File metadata and controls

733 lines (587 loc) · 16.6 KB

Security & Authentication Specification

ZapBB Forum Platform

Version: 1.0
Status: Draft
Date: January 20, 2026
Reference: PRD 34c32848.md


Overview

This document specifies the security architecture, authentication mechanisms, authorization model, and security best practices for ZapBB.

Authentication System

NextAuth.js Sessions + JWT Authentication

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

Token Structure (JWT for API Clients)

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

Password Hashing

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)

Authentication Flow

Web App Flow (NextAuth.js)

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

Registration Flow (JWT for API Clients)

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

Login Flow (JWT for API Clients)

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

Token Refresh Flow (JWT for API Clients)

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

Session Management

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

Authorization System

Role-Based Access Control (RBAC)

Default Roles

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 Model

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

Permission Checking

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)
}

Middleware Implementation

// 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)
}

Content Security

XSS Prevention

Input Sanitization

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>

Output Encoding

Frontend (React):

  • React automatically escapes by default
  • Use dangerouslySetInnerHTML only for sanitized content
export function PostContent({ content_html }: { content_html: string }) {
  return (
    <div
      className="prose"
      dangerouslySetInnerHTML={{ __html: content_html }}
    />
  );
}

CSRF Protection

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)

SQL Injection Prevention

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

File Upload Security

Upload Validation

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)

Virus Scanning

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))
        }
    }
}

Secure File Storage

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()
}

Rate Limiting

Implementation

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),
    }
}

Rate Limit Policies

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

Security Headers

HTTP Security Headers

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:;"
        ),
    );
}

Data Privacy & GDPR

Data Handling

User Rights:

  1. Right to Access: Export user data (JSON)
  2. Right to Erasure: Delete account + anonymize posts
  3. Right to Rectification: Update user information
  4. 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(())
}

Audit Logging

Security Event Logging

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(())
}

Security Testing

Automated Security Scans

  1. Dependency Scanning:

    cargo audit
  2. Static Analysis:

    cargo clippy -- -W clippy::security
  3. OWASP ZAP Scanning:

    • Automated penetration testing
    • API security testing

Security Checklist

  • 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)

References


Document Status: Draft
Next Review: Upon implementation start