This guide outlines security best practices for deploying and maintaining a secure Neuron CMS installation. The CMS implements multiple layers of security, and proper configuration is essential for production environments.
The CMS uses the PasswordHasher service which enforces minimum password requirements:
Requirements:
Implementation:
use Neuron\Cms\Auth\PasswordHasher;
$hasher = new PasswordHasher();
// Validate password meets requirements
if( !$hasher->meetsRequirements( $password ) )
{
throw new Exception( 'Password must be at least 8 characters and contain uppercase, ' .
'lowercase, number, and special character' );
}
// Hash valid password
$hash = $hasher->hash( $password );
Customizing Requirements:
To increase security, modify PasswordHasher::meetsRequirements():
public function meetsRequirements( string $password ): bool
{
// Minimum 12 characters (increased from 8)
if( strlen( $password ) < 12 )
{
return false;
}
// Must have uppercase
if( !preg_match( '/[A-Z]/', $password ) )
{
return false;
}
// Must have lowercase
if( !preg_match( '/[a-z]/', $password ) )
{
return false;
}
// Must have number
if( !preg_match( '/[0-9]/', $password ) )
{
return false;
}
// Must have special character
if( !preg_match( '/[!@#$%^&*()_+\-=\[\]{}|;:\'",.<>?]/', $password ) )
{
return false;
}
return true;
}
The CMS uses Argon2id by default, which is the recommended algorithm as of 2025:
// In PasswordHasher
public function hash( string $password ): string
{
return password_hash( $password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536, // 64 MB
'time_cost' => 4, // Iterations
'threads' => 2 // Parallel threads
] );
}
Algorithm Comparison:
| Algorithm | Memory Hard | Year Introduced | Recommended |
|---|---|---|---|
| MD5 | No | 1992 | ❌ Never use |
| SHA1 | No | 1995 | ❌ Never use |
| Bcrypt | Limited | 1999 | ✓ Acceptable |
| Argon2id | Yes | 2015 | ✓✓ Best choice |
Why Argon2id:
The Authentication service automatically rehashes passwords when needed:
// In Authentication::attempt()
if( $this->_passwordHasher->verify( $password, $user->getPasswordHash() ) )
{
// Check if password needs rehashing with updated algorithm/costs
if( password_needs_rehash( $user->getPasswordHash(), PASSWORD_ARGON2ID ) )
{
$user->setPasswordHash( $this->_passwordHasher->hash( $password ) );
$this->_userRepo->update( $user );
}
// Authentication successful
return true;
}
This ensures passwords are automatically upgraded to stronger hashing when users log in.
In php.ini or .htaccess:
; Session cookie settings
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = "Strict"
; Use strong session ID
session.entropy_length = 32
session.hash_function = sha256
; Restrict session to IP address
session.use_strict_mode = 1
; Regenerate session ID
session.use_trans_sid = 0
The Authentication service regenerates session IDs on login:
// In Authentication::login()
session_regenerate_id( true );
$_SESSION['user_id'] = $user->getId();
$_SESSION['role'] = $user->getRole();
$_SESSION['logged_in_at'] = microtime( true );
This prevents session fixation attacks.
Implement automatic session timeout:
// In authentication filter or middleware
$timeout = 30 * 60; // 30 minutes
if( isset( $_SESSION['last_activity'] ) )
{
if( time() - $_SESSION['last_activity'] > $timeout )
{
// Session expired
session_destroy();
header( 'Location: /login?reason=timeout' );
exit;
}
}
$_SESSION['last_activity'] = time();
The Authentication service includes built-in brute force protection:
Configuration:
$auth = new Authentication( $userRepo, $sessionMgr, $passwordHasher );
$auth->setMaxLoginAttempts( 5 ); // Lock after 5 attempts
$auth->setLockoutDuration( 15 ); // Lock for 15 minutes
How It Works:
failed_login_attemptslocked_until timestampManual Account Unlock:
$user = $userRepo->findByEmail( '[email protected]' );
$user->resetFailedLoginAttempts();
$userRepo->update( $user );
IP-Based Rate Limiting:
For additional protection, implement IP-based rate limiting (see Rate Limiting section).
The User model includes 2FA support:
Enable 2FA for User:
use PragmaRX\Google2FA\Google2FA;
$google2fa = new Google2FA();
// Generate secret
$secret = $google2fa->generateSecretKey();
$user->setTwoFactorSecret( $secret );
// Generate recovery codes (10 codes)
$recoveryCodes = [];
for( $i = 0; $i < 10; $i++ )
{
$recoveryCodes[] = bin2hex( random_bytes( 8 ) );
}
$user->setTwoFactorRecoveryCodes( json_encode( $recoveryCodes ) );
$userRepo->update( $user );
// Display QR code to user
$qrCodeUrl = $google2fa->getQRCodeUrl( 'Neuron CMS',
$user->getEmail(),
$secret
);
Verify 2FA Code:
// After password authentication
if( $user->getTwoFactorSecret() )
{
$code = $_POST['2fa_code'] ?? '';
if( $google2fa->verifyKey( $user->getTwoFactorSecret(), $code ) )
{
// 2FA verified
$auth->login( $user );
}
else
{
// Check if it's a recovery code
$recoveryCodes = json_decode( $user->getTwoFactorRecoveryCodes(), true );
if( in_array( $code, $recoveryCodes ) )
{
// Remove used recovery code
$recoveryCodes = array_filter( $recoveryCodes, fn($c ) => $c !== $code );
$user->setTwoFactorRecoveryCodes( json_encode( array_values( $recoveryCodes ) ) );
$userRepo->update( $user );
$auth->login( $user );
}
else
{
throw new Exception( 'Invalid 2FA code' );
}
}
}
The Authentication service implements secure "remember me" functionality:
Security Features:
Implementation:
// In Authentication::setRememberToken()
private function setRememberToken( User $user ): void
{
// Generate secure random token
$token = bin2hex( random_bytes( 32 ) ); // 64 characters
// Store SHA-256 hash in database
$user->setRememberToken( hash( 'sha256', $token ) );
$this->_userRepo->update( $user );
// Set cookie with plain token
setcookie( 'remember_token',
$token,
[
'expires' => time() + (30 * 24 * 60 * 60), // 30 days
'path' => '/',
'domain' => '',
'secure' => true, // HTTPS only
'httponly' => true, // No JavaScript access
'samesite' => 'Strict'
] );
}
Token Rotation:
Remember tokens should be rotated periodically:
// Rotate token every 7 days
if( $user->getRememberTokenCreatedAt() < new DateTimeImmutable( '-7 days' ) )
{
$this->setRememberToken( $user );
}
The CMS includes a CsrfToken service for CSRF protection:
Generate Token:
use Neuron\Cms\Services\Auth\CsrfToken;
$csrf = new CsrfToken( $sessionManager );
$token = $csrf->getToken();
In Form:
<form method="POST" action="/admin/post/create">
<input type="hidden" name="csrf_token" value="<?= $token ?>">
<!-- other fields -->
<button type="submit">Create Post</button>
</form>
Validate Token:
if( !$csrf->validate( $_POST['csrf_token'] ?? '' ) )
{
throw new Exception( 'CSRF validation failed' );
}
// Process form
AJAX Requests:
// Include token in AJAX headers
fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify(data)
});
Timing-Safe Comparison:
The CsrfToken service uses hash_equals() for timing-safe token comparison:
public function validate( string $token ): bool
{
$stored = $_SESSION['csrf_token'] ?? '';
return hash_equals( $stored, $token );
}
This prevents timing attacks that could leak token information.
Always escape output:
// HTML context
echo htmlspecialchars( $userInput, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' );
// JavaScript context
echo json_encode( $userInput, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT );
// URL context
echo urlencode( $userInput );
// CSS context
// Avoid user input in CSS, but if necessary, strictly validate
Implement Content Security Policy headers:
// In application bootstrap or middleware
header( "Content-Security-Policy: " . implode( '; ', [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://cdn.example.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: https:",
"font-src 'self' https://fonts.gstatic.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'"
] ) );
The EditorJsRenderer sanitizes inline HTML:
private function sanitizeInlineHtml( string $html ): string
{
$allowedTags = '<b><strong><i><em><u><a><code><mark>';
return strip_tags( $html, $allowedTags );
}
For additional security, use HTML Purifier:
use HTMLPurifier;
$config = HTMLPurifier_Config::createDefault();
$config->set( 'HTML.Allowed', 'b,strong,i,em,u,a[href],code,mark' );
$purifier = new HTMLPurifier( $config );
$clean = $purifier->purify( $html );
The CMS uses PDO with prepared statements:
// Safe: Prepared statement with parameter binding
public function findByEmail( string $email ): ?User
{
$stmt = $this->_db->prepare( "SELECT * FROM users WHERE email = :email" );
$stmt->bindValue( ':email', $email, PDO::PARAM_STR );
$stmt->execute();
return $this->hydrate( $stmt->fetch( PDO::FETCH_ASSOC ) );
}
// NEVER do this:
// $query = "SELECT * FROM users WHERE email = '{$email}'"; // VULNERABLE!
Best Practices:
Dynamic Queries:
When you need dynamic table/column names (which can't be parameterized):
public function findByField( string $field, $value ): array
{
// Whitelist allowed fields
$allowedFields = ['username', 'email', 'status'];
if( !in_array( $field, $allowedFields ) )
{
throw new Exception( 'Invalid field name' );
}
// Safe: $field is whitelisted
$stmt = $this->_db->prepare( "SELECT * FROM users WHERE {$field} = :value" );
$stmt->bindValue( ':value', $value );
$stmt->execute();
return $stmt->fetchAll( PDO::FETCH_ASSOC );
}
Always validate on the server (never trust client-side validation):
use Neuron\Dto\Dto;
// Use DTOs for validation
$dto = $dtoFactory->createRegisterUser();
$dto->username = $_POST['username'] ?? '';
$dto->email = $_POST['email'] ?? '';
$dto->password = $_POST['password'] ?? '';
if( !$dto->validate() )
{
$errors = $dto->getValidationErrors();
// Display errors
return;
}
// DTO is valid, proceed
$user = $registrationService->registerWithDto( $dto );
if( !filter_var( $email, FILTER_VALIDATE_EMAIL ) )
{
throw new Exception( 'Invalid email address' );
}
// Additional check: Verify domain exists
[$user, $domain] = explode( '@', $email );
if( !checkdnsrr( $domain, 'MX' ) && !checkdnsrr( $domain, 'A' ) )
{
throw new Exception( 'Email domain does not exist' );
}
if( !filter_var( $url, FILTER_VALIDATE_URL ) )
{
throw new Exception( 'Invalid URL' );
}
// Additional check: Verify scheme
$parsed = parse_url( $url );
if( !in_array( $parsed['scheme'] ?? '', ['http', 'https'] ) )
{
throw new Exception( 'URL must use HTTP or HTTPS' );
}
function validateUpload( array $file ): void
{
// Check for upload errors
if( $file['error'] !== UPLOAD_ERR_OK )
{
throw new Exception( 'File upload failed' );
}
// Validate file size (5MB max)
$maxSize = 5 * 1024 * 1024;
if( $file['size'] > $maxSize )
{
throw new Exception( 'File too large (max 5MB )' );
}
// Validate MIME type
$allowedMimes = [
'image/jpeg',
'image/png',
'image/gif',
'application/pdf'
];
$finfo = finfo_open( FILEINFO_MIME_TYPE );
$mimeType = finfo_file( $finfo, $file['tmp_name'] );
finfo_close( $finfo );
if( !in_array( $mimeType, $allowedMimes ) )
{
throw new Exception( 'Invalid file type' );
}
// Validate file extension
$allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];
$ext = strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) );
if( !in_array( $ext, $allowedExts ) )
{
throw new Exception( 'Invalid file extension' );
}
}
// Generate random filename to prevent directory traversal
$extension = pathinfo( $file['name'], PATHINFO_EXTENSION );
$filename = bin2hex( random_bytes( 16 ) ) . '.' . $extension;
// Store outside web root
$uploadDir = '/var/www/uploads/'; // Not in public/
$destination = $uploadDir . $filename;
if( !move_uploaded_file( $file['tmp_name'], $destination ) )
{
throw new Exception( 'Failed to save file' );
}
// Set restrictive permissions
chmod( $destination, 0644 );
// Never allow direct access to uploads/
// Serve through PHP script with authentication
public function download( Request $request ): string
{
$filename = $request->getRouteParameter( 'file' );
// Validate filename (prevent directory traversal)
if( strpos( $filename, '..' ) !== false || strpos( $filename, '/' ) !== false )
{
return $this->render404( $request );
}
$filePath = '/var/www/uploads/' . $filename;
if( !file_exists( $filePath ) )
{
return $this->render404( $request );
}
// Check authentication/authorization
if( !$this->_auth->check() )
{
return $this->render403( $request );
}
// Serve file
header( 'Content-Type: ' . mime_content_type( $filePath ) );
header( 'Content-Length: ' . filesize( $filePath ) );
header( 'Content-Disposition: attachment; filename="' . basename( $filename ) . '"' );
readfile( $filePath );
exit;
}
Store sensitive data in environment variables, not in code:
// .env file (never commit to version control)
DB_HOST=localhost
DB_NAME=cms_database
DB_USER=cms_user
DB_PASS=secure_password
SMTP_PASSWORD=email_password
API_KEY=secret_api_key
// Load environment variables
use Neuron\Data\Env;
Env::loadFile( __DIR__ . '/.env' );
$dbPassword = Env::get( 'DB_PASS' );
$apiKey = Env::get( 'API_KEY' );
For additional sensitive data, use encryption:
// Generate encryption key (store securely)
$key = sodium_crypto_secretbox_keygen();
// Encrypt
function encrypt( string $data, string $key ): string
{
$nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
$encrypted = sodium_crypto_secretbox( $data, $nonce, $key );
return base64_encode( $nonce . $encrypted );
}
// Decrypt
function decrypt( string $encrypted, string $key ): string
{
$decoded = base64_decode( $encrypted );
$nonce = substr( $decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
$ciphertext = substr( $decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
return sodium_crypto_secretbox_open( $ciphertext, $nonce, $key );
}
// In application bootstrap
if( !isset( $_SERVER['HTTPS'] ) || $_SERVER['HTTPS'] !== 'on' )
{
$redirect = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
header( 'Location: ' . $redirect, true, 301 );
exit;
}
header( 'Strict-Transport-Security: max-age=31536000; includeSubDomains; preload' );
This tells browsers to only connect via HTTPS for the next year.
// Prevent clickjacking
header( 'X-Frame-Options: DENY' );
// Prevent MIME sniffing
header( 'X-Content-Type-Options: nosniff' );
// Enable XSS protection
header( 'X-XSS-Protection: 1; mode=block' );
// Referrer policy
header( 'Referrer-Policy: strict-origin-when-cross-origin' );
// Permissions policy
header( 'Permissions-Policy: geolocation=(), microphone=(), camera=()' );
# Application directory
chown -R www-data:www-data /var/www/cms
chmod -R 755 /var/www/cms
# Writable directories
chmod -R 775 /var/www/cms/storage
chmod -R 775 /var/www/cms/logs
chmod -R 775 /var/www/cms/uploads
# Configuration files
chmod 640 /var/www/cms/config/*.yaml
chmod 600 /var/www/cms/.env
# Public directory
chmod 755 /var/www/cms/public
#!/bin/bash
# scripts/check-permissions.sh
ERRORS=0
# Check that config files are not world-readable
if find config/ -type f -perm /004 | grep -q .; then
echo "ERROR: Config files are world-readable"
ERRORS=$((ERRORS + 1))
fi
# Check that .env is not readable by group/others
if [ -f .env ] && [ "$(stat -c %a .env)" != "600" ]; then
echo "ERROR: .env has incorrect permissions"
ERRORS=$((ERRORS + 1))
fi
# Check that storage/logs are writable
if [ ! -w storage ] || [ ! -w logs ]; then
echo "ERROR: storage/ or logs/ not writable"
ERRORS=$((ERRORS + 1))
fi
exit $ERRORS
.htaccess for Apache:
# Block access to sensitive directories
<DirectoryMatch "^/(config|storage|logs|vendor)">
Require all denied
</DirectoryMatch>
# Block access to dot files
<FilesMatch "^\.">
Require all denied
</FilesMatch>
Nginx configuration:
# Block access to sensitive directories
location ~ ^/(config|storage|logs|vendor) {
deny all;
return 404;
}
# Block access to dot files
location ~ /\. {
deny all;
return 404;
}
In config/neuron.yaml:
app:
debug: false
environment: production
// In Application.php
public function handleException( \Throwable $e ): string
{
if( $this->getEnvironment() === 'production' )
{
// Log error details
Log::error( "Exception: " . $e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString()
]);
// Show generic error page
http_response_code( 500 );
return file_get_contents( $this->getBasePath() . '/public/500.html' );
}
else
{
// Show detailed error in development
return $this->beautifyException( $e );
}
}
-- Create database user with minimal permissions
CREATE USER 'cms_user'@'localhost' IDENTIFIED BY 'secure_password';
-- Grant only necessary permissions
GRANT SELECT, INSERT, UPDATE, DELETE ON cms_database.* TO 'cms_user'@'localhost';
-- No GRANT, DROP, CREATE, ALTER permissions for application user
-- Use separate admin user for migrations
-- Restrict connections to localhost only
CREATE USER 'cms_user'@'localhost' IDENTIFIED BY 'secure_password';
-- Or specific IP
CREATE USER 'cms_user'@'192.168.1.100' IDENTIFIED BY 'secure_password';
Store backup user separately with read-only access:
CREATE USER 'cms_backup'@'localhost' IDENTIFIED BY 'backup_password';
GRANT SELECT, LOCK TABLES ON cms_database.* TO 'cms_backup'@'localhost';
// In controller
public function deletePost( Request $request ): string
{
if( !$this->_auth->isEditorOrHigher() )
{
return $this->renderJson( 403, ['error' => 'Forbidden'] );
}
$postId = $request->getRouteParameter( 'id' );
$post = $this->_postRepo->findById( $postId );
// Authors can only delete their own posts
if( $this->_auth->user()->getRole() === User::ROLE_AUTHOR )
{
if( $post->getAuthorId() !== $this->_auth->id() )
{
return $this->renderJson( 403, ['error' => 'Forbidden' );
}
}
$this->_postDeleter->delete( $post );
return $this->renderJson( 200, ['success' => true] );
}
use Neuron\Routing\Attributes\Get;
class AdminController extends Controller
{
// Requires admin role
#[Get('/admin/users', name: 'admin.users', filters: ['auth', 'admin'])]
public function users(Request $request): string
{
// Only admins can access
}
}
class AuthorController extends Controller
{
// Requires author or higher role
#[Get('/author/posts', name: 'author.posts', filters: ['auth', 'author'])]
public function posts(Request $request): string
{
// Only authors and admins can access
}
}
// Create AdminIpFilter
class AdminIpFilter implements IFilter
{
private array $_allowedIps = [
'127.0.0.1',
'192.168.1.100',
'203.0.113.0/24'
];
public function execute( Request $request ): bool
{
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
foreach( $this->_allowedIps as $allowed )
{
if( $this->ipInRange( $ip, $allowed ) )
{
return true;
}
}
// IP not allowed
http_response_code( 403 );
echo "Access denied";
return false;
}
private function ipInRange( string $ip, string $range ): bool
{
if( strpos( $range, '/' ) === false )
{
return $ip === $range;
}
[$subnet, $bits] = explode( '/', $range );
$ip = ip2long( $ip );
$subnet = ip2long( $subnet );
$mask = -1 << (32 - $bits);
return ($ip & $mask) === ($subnet & $mask);
}
}
// API key authentication
class ApiKeyFilter implements IFilter
{
private IUserRepository $_userRepo;
public function execute( Request $request ): bool
{
$apiKey = $request->getHeader( 'X-API-Key' );
if( !$apiKey )
{
http_response_code( 401 );
echo json_encode( ['error' => 'API key required'] );
return false;
}
$user = $this->_userRepo->findByApiKey( $apiKey );
if( !$user )
{
http_response_code( 401 );
echo json_encode( ['error' => 'Invalid API key'] );
return false;
}
// Store user in request
$request->setParameter( 'api_user', $user );
return true;
}
}
The CMS includes rate limiting support (requires neuron-php/routing rate limit extension):
# config/neuron.yaml
api_limit:
enabled: true
max_requests: 100
window: 60
scope: ip
// Apply rate limiting with attributes
use Neuron\Routing\Attributes\Get;
class ApiController extends Controller
{
#[Get('/api/posts', name: 'api.posts', filters: ['api_limit'])]
public function posts(Request $request): string
{
// Rate limited API endpoint
return $this->renderJson(OK, ['posts' => $this->postRepository->all()]);
}
}
Add to DNS:
example.com. IN TXT "v=spf1 mx a ip4:203.0.113.5 include:_spf.google.com ~all"
Configure DKIM in PHPMailer:
$mail->DKIM_domain = 'example.com';
$mail->DKIM_private = '/path/to/private.key';
$mail->DKIM_selector = 'default';
$mail->DKIM_passphrase = '';
Add to DNS:
_dmarc.example.com. IN TXT "v=DMARC1; p=quarantine; rua=mailto:[email protected]"
// In PasswordResetter::requestReset()
public function requestReset( string $email ): bool
{
$user = $this->_userRepo->findByEmail( $email );
if( !$user )
{
// Don't reveal that email doesn't exist
// Return success but don't send email
return true;
}
// Send reset email
// ...
return true;
}
// Limit password reset requests per IP
$key = 'password_reset:' . $_SERVER['REMOTE_ADDR'];
$requests = $cache->get( $key, 0 );
if( $requests >= 5 )
{
throw new Exception( 'Too many requests. Please try again later.' );
}
$cache->set( $key, $requests + 1, 3600 ); // 1 hour window
# Check for outdated packages
composer outdated
# Update dependencies
composer update
# Update specific package
composer update neuron-php/mvc
# Check for known vulnerabilities
composer audit
# Use Roave Security Advisories
composer require --dev roave/security-advisories:dev-latest
This prevents installation of packages with known vulnerabilities.
Add to CI/CD pipeline:
# .github/workflows/security.yml
name: Security Audit
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Security Audit
run: composer audit
use Neuron\Log\Log;
// Successful login
Log::info( "User logged in", [
'user_id' => $user->getId(),
'username' => $user->getUsername(),
'ip' => $_SERVER['REMOTE_ADDR'],
'user_agent' => $_SERVER['HTTP_USER_AGENT']
]);
// Failed login
Log::warning( "Failed login attempt", [
'username' => $username,
'ip' => $_SERVER['REMOTE_ADDR'],
'reason' => 'invalid_password'
] );
// Account lockout
Log::warning( "Account locked due to too many failed attempts", [
'user_id' => $user->getId(),
'username' => $user->getUsername(),
'ip' => $_SERVER['REMOTE_ADDR']
]);
// Privilege escalation attempt
Log::error( "Unauthorized access attempt", [
'user_id' => $user->getId(),
'required_role' => 'admin',
'user_role' => $user->getRole(),
'route' => $_SERVER['REQUEST_URI'],
'ip' => $_SERVER['REMOTE_ADDR']
]);
# Multiple failed login attempts from same IP
grep "Failed login attempt" logs/application.log | \
grep -oP 'ip":\s*"\K[^"]+' | \
sort | uniq -c | sort -rn | head -20
# Privilege escalation attempts
grep "Unauthorized access attempt" logs/application.log | tail -50
# Account lockouts
grep "Account locked" logs/application.log | tail -20
// scripts/monitor-failed-logins.php
<?php
require __DIR__ . '/../vendor/autoload.php';
$logFile = __DIR__ . '/../logs/application.log';
$threshold = 10; // Alert after 10 failed attempts
$handle = fopen( $logFile, 'r' );
$failedLogins = [];
while( ($line = fgets( $handle )) !== false )
{
if( strpos( $line, 'Failed login attempt' ) !== false )
{
preg_match( '/"ip":\s*"([^"]+ )"/', $line, $matches );
$ip = $matches[1] ?? 'unknown';
$failedLogins[$ip] = ($failedLogins[$ip] ?? 0) + 1;
}
}
fclose( $handle );
foreach( $failedLogins as $ip => $count )
{
if( $count >= $threshold )
{
// Send alert
mail( '[email protected]',
"Security Alert: Multiple Failed Logins",
"IP {$ip} has {$count} failed login attempts" );
}
}
Run via cron:
*/5 * * * * /usr/bin/php /path/to/scripts/monitor-failed-logins.php
# Create baseline
find /var/www/cms -type f -exec sha256sum {} \; > /var/backups/file-integrity.txt
# Check for changes (run daily)
#!/bin/bash
CURRENT=$(mktemp)
find /var/www/cms -type f -exec sha256sum {} \; > "$CURRENT"
if ! diff -q /var/backups/file-integrity.txt "$CURRENT" > /dev/null; then
echo "WARNING: File integrity check failed"
diff /var/backups/file-integrity.txt "$CURRENT" | mail -s "File Integrity Alert" [email protected]
fi
rm "$CURRENT"
# Immediately disable compromised accounts
php scripts/disable-user.php [email protected]
# Block attacking IP addresses
iptables -A INPUT -s 203.0.113.50 -j DROP
# Enable maintenance mode
# Edit config/neuron.yaml: maintenance.enabled: true
# Daily automated backup
0 2 * * * /path/to/scripts/backup-db.sh
0 3 * * * rsync -av /var/www/cms /backup/cms-$(date +\%Y\%m\%d)
# Sync to remote server
rsync -avz -e ssh /backup/ [email protected]:/backups/cms/
# Or use cloud storage
aws s3 sync /backup/ s3://my-backups/cms/
# Quarterly restore tests
# scripts/test-restore.sh
#!/bin/bash
# Restore to test environment
# Verify data integrity
# Document process
Authentication
Application Security
Infrastructure
Access Control
Email Security
Dependencies
Logging & Monitoring
Backups
Documentation
Right to Access:
// Export user data
public function exportUserData( int $userId ): array
{
$user = $this->_userRepo->findById( $userId );
$posts = $this->_postRepo->findByAuthorId( $userId );
return [
'user' => $user->toArray(),
'posts' => array_map( fn($p ) => $p->toArray(), $posts ),
'exported_at' => date( 'Y-m-d H:i:s' )
];
}
Right to Erasure ("Right to be Forgotten"):
// Delete user and associated data
public function deleteUserData( int $userId ): void
{
// Anonymize or delete posts
$posts = $this->_postRepo->findByAuthorId( $userId );
foreach( $posts as $post )
{
$post->setAuthorId( null ); // Anonymize
$post->setAuthor( 'Deleted User' );
$this->_postRepo->update( $post );
}
// Delete user
$this->_userDeleter->delete( $userId );
}
Right to Data Portability:
// Export in machine-readable format
public function exportUserDataJson( int $userId ): string
{
$data = $this->exportUserData( $userId );
return json_encode( $data, JSON_PRETTY_PRINT );
}
// Track consent
$user->setPrivacyPolicyAccepted( true );
$user->setPrivacyPolicyAcceptedAt( new DateTimeImmutable() );
$user->setMarketingEmailsConsent( $_POST['marketing_consent'] === '1' );
$userRepo->update( $user );
Note: If handling payment card data, PCI DSS compliance is required.
Key Requirements:
Recommended Approach:
Use tokenization through payment processor:
// Never handle card data directly
// Use payment processor's JavaScript SDK to tokenize
// Server-side: Process token only
$token = $_POST['payment_token'];
$amount = $_POST['amount'];
$charge = $paymentProcessor->createCharge( [
'amount' => $amount,
'token' => $token,
'description' => 'Order #12345'
] );