mirror of
https://github.com/zadam/trilium.git
synced 2025-11-01 02:45:54 +01:00
1175 lines
31 KiB
Markdown
Vendored
1175 lines
31 KiB
Markdown
Vendored
# Authentication and Access Control Guide
|
|
|
|
This comprehensive guide covers Trilium's authentication mechanisms, session management, API security, and access control systems. Understanding these systems is crucial for maintaining a secure Trilium installation.
|
|
|
|
## Authentication Overview
|
|
|
|
Trilium supports multiple authentication methods designed to provide flexible yet secure access control:
|
|
|
|
- **Password Authentication**: Primary method using scrypt-hashed passwords
|
|
- **Multi-Factor Authentication (MFA)**: TOTP-based secondary authentication
|
|
- **Single Sign-On (SSO)**: OpenID Connect integration for enterprise environments
|
|
- **API Token Authentication**: ETAPI tokens for programmatic access
|
|
|
|
## Password Authentication
|
|
|
|
### Password Security Architecture
|
|
|
|
#### Hashing Algorithm
|
|
- **Primary**: Scrypt with configurable parameters
|
|
- **Parameters**: N=16384, r=8, p=1 (adjustable)
|
|
- **Salt**: 32-byte cryptographically secure random salt
|
|
- **Storage**: Separate verification hash from encryption keys
|
|
|
|
#### Password Storage Schema
|
|
|
|
```sql
|
|
-- Password-related options in database
|
|
CREATE TABLE options (
|
|
name TEXT PRIMARY KEY,
|
|
value TEXT
|
|
);
|
|
|
|
-- Key storage entries
|
|
INSERT INTO options VALUES
|
|
('passwordVerificationSalt', '<32-byte-base64-salt>'),
|
|
('passwordDerivedKeySalt', '<32-byte-base64-salt>'),
|
|
('passwordVerificationHash', '<scrypt-hash-base64>'),
|
|
('encryptedDataKey', '<aes-encrypted-key>');
|
|
```
|
|
|
|
#### Password Verification Process
|
|
|
|
```typescript
|
|
// Verification flow
|
|
1. Extract stored salt and hash from options
|
|
2. Derive key from provided password + salt using scrypt
|
|
3. Compare derived hash with stored verification hash
|
|
4. Return boolean result (constant-time comparison)
|
|
```
|
|
|
|
### Password Management
|
|
|
|
#### Setting Initial Password
|
|
|
|
```typescript
|
|
// During setup or first run
|
|
function setPassword(password: string): ChangePasswordResponse {
|
|
// Generate new salts
|
|
const verificationSalt = randomSecureToken(32);
|
|
const dataKeySalt = randomSecureToken(32);
|
|
|
|
// Create verification hash
|
|
const verificationHash = scrypt(password, verificationSalt);
|
|
|
|
// Generate and encrypt data key
|
|
const dataKey = randomSecureToken(16);
|
|
const passwordKey = scrypt(password, dataKeySalt);
|
|
const encryptedDataKey = aes128Encrypt(passwordKey, dataKey);
|
|
|
|
// Store in options table
|
|
setOption('passwordVerificationSalt', verificationSalt);
|
|
setOption('passwordDerivedKeySalt', dataKeySalt);
|
|
setOption('passwordVerificationHash', verificationHash);
|
|
setOption('encryptedDataKey', encryptedDataKey);
|
|
}
|
|
```
|
|
|
|
#### Changing Password
|
|
|
|
```typescript
|
|
// Password change process
|
|
function changePassword(currentPassword: string, newPassword: string) {
|
|
// Verify current password
|
|
if (!verifyPassword(currentPassword)) {
|
|
throw new Error('Current password incorrect');
|
|
}
|
|
|
|
// Decrypt data key with current password
|
|
const dataKey = getDataKey(currentPassword);
|
|
|
|
// Generate new salts
|
|
const newVerificationSalt = randomSecureToken(32);
|
|
const newDataKeySalt = randomSecureToken(32);
|
|
|
|
// Re-encrypt data key with new password
|
|
const newPasswordKey = scrypt(newPassword, newDataKeySalt);
|
|
const newEncryptedDataKey = aes128Encrypt(newPasswordKey, dataKey);
|
|
|
|
// Update stored values
|
|
updateOptions({
|
|
passwordVerificationSalt: newVerificationSalt,
|
|
passwordDerivedKeySalt: newDataKeySalt,
|
|
passwordVerificationHash: scrypt(newPassword, newVerificationSalt),
|
|
encryptedDataKey: newEncryptedDataKey
|
|
});
|
|
}
|
|
```
|
|
|
|
#### Password Reset
|
|
|
|
```typescript
|
|
// Complete password reset (loses all protected content)
|
|
function resetPassword() {
|
|
updateOptions({
|
|
passwordVerificationSalt: '',
|
|
passwordDerivedKeySalt: '',
|
|
passwordVerificationHash: '',
|
|
encryptedDataKey: ''
|
|
});
|
|
|
|
// All protected content becomes inaccessible
|
|
// Only unprotected content remains
|
|
}
|
|
```
|
|
|
|
### Password Policy Configuration
|
|
|
|
#### Minimum Requirements
|
|
|
|
```typescript
|
|
// Default password policy
|
|
const passwordPolicy = {
|
|
minLength: 8, // Minimum character count
|
|
requireUppercase: false, // At least one uppercase letter
|
|
requireLowercase: false, // At least one lowercase letter
|
|
requireNumbers: false, // At least one number
|
|
requireSymbols: false, // At least one special character
|
|
maxAge: 0, // Days before expiration (0 = never)
|
|
preventReuse: 0 // Number of previous passwords to check
|
|
};
|
|
```
|
|
|
|
#### Configuration Options
|
|
|
|
```sql
|
|
-- Password policy options
|
|
INSERT INTO options VALUES
|
|
('passwordMinLength', '8'),
|
|
('passwordRequireUppercase', 'false'),
|
|
('passwordRequireLowercase', 'false'),
|
|
('passwordRequireNumbers', 'false'),
|
|
('passwordRequireSymbols', 'false'),
|
|
('passwordMaxAge', '0'),
|
|
('passwordPreventReuse', '0');
|
|
```
|
|
|
|
## Multi-Factor Authentication (MFA)
|
|
|
|
### TOTP Implementation
|
|
|
|
#### Algorithm Details
|
|
- **Standard**: RFC 6238 (Time-Based One-Time Password)
|
|
- **Hash Function**: SHA-1 (standard for TOTP compatibility)
|
|
- **Time Step**: 30 seconds
|
|
- **Code Length**: 6 digits
|
|
- **Clock Tolerance**: ±1 time step (±30 seconds)
|
|
|
|
#### Secret Management
|
|
|
|
```typescript
|
|
// TOTP secret lifecycle
|
|
class TotpSecretManager {
|
|
// Generate new TOTP secret
|
|
generateSecret(): string {
|
|
const secret = generateSecretBase32(); // 32-character base32
|
|
return secret;
|
|
}
|
|
|
|
// Store encrypted secret
|
|
setTotpSecret(secret: string): void {
|
|
const encryptedSecret = encryptWithDataKey(secret);
|
|
setOption('totpEncryptedSecret', encryptedSecret);
|
|
|
|
// Store verification hash
|
|
const verificationHash = sha256(secret);
|
|
setOption('totpVerificationHash', verificationHash);
|
|
}
|
|
|
|
// Retrieve and decrypt secret
|
|
getTotpSecret(): string | null {
|
|
const encrypted = getOption('totpEncryptedSecret');
|
|
if (!encrypted) return null;
|
|
|
|
return decryptWithDataKey(encrypted);
|
|
}
|
|
|
|
// Verify secret integrity
|
|
verifyTotpSecret(secret: string): boolean {
|
|
const storedHash = getOption('totpVerificationHash');
|
|
const computedHash = sha256(secret);
|
|
return constantTimeEquals(storedHash, computedHash);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### TOTP Validation
|
|
|
|
```typescript
|
|
// TOTP code verification
|
|
function validateTOTP(submittedCode: string): boolean {
|
|
const secret = getTotpSecret();
|
|
if (!secret) return false;
|
|
|
|
const currentTime = Math.floor(Date.now() / 1000);
|
|
const timeStep = 30;
|
|
|
|
// Check current time window and adjacent windows
|
|
for (let i = -1; i <= 1; i++) {
|
|
const timeWindow = Math.floor(currentTime / timeStep) + i;
|
|
const expectedCode = generateTOTP(secret, timeWindow);
|
|
|
|
if (constantTimeEquals(submittedCode, expectedCode)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
```
|
|
|
|
### Recovery Codes
|
|
|
|
#### Generation and Storage
|
|
|
|
```typescript
|
|
// Recovery code management
|
|
class RecoveryCodeManager {
|
|
generateRecoveryCodes(): string[] {
|
|
const codes = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
// Generate 24-character base64 codes ending in "=="
|
|
const code = generateRecoveryCode();
|
|
codes.push(code);
|
|
}
|
|
return codes;
|
|
}
|
|
|
|
storeRecoveryCodes(codes: string[]): void {
|
|
// Encrypt codes with AES-256-CBC
|
|
const encryptedCodes = codes.map(code =>
|
|
aes256Encrypt(getDataKey(), code)
|
|
);
|
|
|
|
setOption('recoveryCodesEncrypted', JSON.stringify(encryptedCodes));
|
|
}
|
|
|
|
verifyRecoveryCode(submittedCode: string): boolean {
|
|
const encryptedCodes = JSON.parse(getOption('recoveryCodesEncrypted') || '[]');
|
|
|
|
for (let i = 0; i < encryptedCodes.length; i++) {
|
|
const decryptedCode = aes256Decrypt(getDataKey(), encryptedCodes[i]);
|
|
|
|
if (constantTimeEquals(submittedCode, decryptedCode)) {
|
|
// Mark code as used (replace with timestamp)
|
|
encryptedCodes[i] = aes256Encrypt(getDataKey(), `used:${Date.now()}`);
|
|
setOption('recoveryCodesEncrypted', JSON.stringify(encryptedCodes));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
```
|
|
|
|
### MFA Setup Process
|
|
|
|
#### User Enrollment
|
|
|
|
```typescript
|
|
// MFA enrollment workflow
|
|
async function enrollMFA() {
|
|
// 1. Generate TOTP secret
|
|
const secret = totp.generateSecret();
|
|
|
|
// 2. Display QR code for authenticator app
|
|
const qrCodeUrl = generateQRCode({
|
|
secret: secret,
|
|
issuer: 'Trilium Notes',
|
|
account: 'user@trilium.local'
|
|
});
|
|
|
|
// 3. User scans QR code or enters secret manually
|
|
// 4. User submits test TOTP code
|
|
const testCode = await promptForTOTP();
|
|
|
|
// 5. Verify test code
|
|
if (validateTOTPWithSecret(testCode, secret)) {
|
|
// 6. Store secret and enable MFA
|
|
setTotpSecret(secret);
|
|
setOption('mfaEnabled', 'true');
|
|
setOption('mfaMethod', 'totp');
|
|
|
|
// 7. Generate and display recovery codes
|
|
const recoveryCodes = generateRecoveryCodes();
|
|
storeRecoveryCodes(recoveryCodes);
|
|
displayRecoveryCodes(recoveryCodes);
|
|
} else {
|
|
throw new Error('TOTP verification failed');
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Authentication Flow
|
|
|
|
```typescript
|
|
// MFA-enabled login process
|
|
async function authenticateWithMFA(username: string, password: string) {
|
|
// 1. Verify primary credentials
|
|
if (!verifyPassword(password)) {
|
|
throw new Error('Invalid credentials');
|
|
}
|
|
|
|
// 2. Check if MFA is enabled
|
|
if (isMFAEnabled()) {
|
|
// 3. Prompt for TOTP code
|
|
const totpCode = await promptForTOTP();
|
|
|
|
// 4. Validate TOTP
|
|
if (!validateTOTP(totpCode)) {
|
|
// 5. Allow recovery code as fallback
|
|
const recoveryCode = await promptForRecoveryCode();
|
|
|
|
if (!verifyRecoveryCode(recoveryCode)) {
|
|
throw new Error('MFA verification failed');
|
|
}
|
|
}
|
|
}
|
|
|
|
// 6. Create authenticated session
|
|
return createSession(username);
|
|
}
|
|
```
|
|
|
|
## Single Sign-On (SSO)
|
|
|
|
### OpenID Connect Integration
|
|
|
|
#### Supported Providers
|
|
|
|
```typescript
|
|
// Common OIDC provider configurations
|
|
const oidcProviders = {
|
|
google: {
|
|
issuer: 'https://accounts.google.com',
|
|
scope: 'openid email profile'
|
|
},
|
|
microsoft: {
|
|
issuer: 'https://login.microsoftonline.com/common/v2.0',
|
|
scope: 'openid email profile'
|
|
},
|
|
github: {
|
|
issuer: 'https://token.actions.githubusercontent.com',
|
|
scope: 'openid email'
|
|
},
|
|
custom: {
|
|
issuer: process.env.OIDC_ISSUER,
|
|
scope: 'openid email profile'
|
|
}
|
|
};
|
|
```
|
|
|
|
#### Configuration Setup
|
|
|
|
```ini
|
|
# config.ini - OpenID Connect settings
|
|
[OpenID]
|
|
enabled=true
|
|
issuer=https://your-provider.com
|
|
client_id=your-client-id
|
|
client_secret=your-client-secret
|
|
redirect_uri=https://your-trilium.com/auth/callback
|
|
scope=openid email profile
|
|
response_type=code
|
|
response_mode=query
|
|
```
|
|
|
|
#### Authentication Flow
|
|
|
|
```typescript
|
|
// OIDC authentication process
|
|
class OIDCAuthenticator {
|
|
async authenticate(req: Request, res: Response) {
|
|
// 1. Redirect to OIDC provider
|
|
const authUrl = buildAuthorizationUrl({
|
|
issuer: config.oidc.issuer,
|
|
clientId: config.oidc.clientId,
|
|
redirectUri: config.oidc.redirectUri,
|
|
scope: config.oidc.scope,
|
|
state: generateSecureState(),
|
|
nonce: generateSecureNonce()
|
|
});
|
|
|
|
res.redirect(authUrl);
|
|
}
|
|
|
|
async handleCallback(req: Request, res: Response) {
|
|
// 2. Receive authorization code
|
|
const { code, state } = req.query;
|
|
|
|
// 3. Verify state parameter
|
|
if (!verifyState(state)) {
|
|
throw new Error('Invalid state parameter');
|
|
}
|
|
|
|
// 4. Exchange code for tokens
|
|
const tokens = await exchangeCodeForTokens(code);
|
|
|
|
// 5. Verify ID token
|
|
const userInfo = await verifyIdToken(tokens.id_token);
|
|
|
|
// 6. Create local session
|
|
req.session.loggedIn = true;
|
|
req.session.userInfo = userInfo;
|
|
req.session.oidcTokens = tokens;
|
|
|
|
res.redirect('/app');
|
|
}
|
|
}
|
|
```
|
|
|
|
### SSO Security Features
|
|
|
|
#### Token Validation
|
|
|
|
```typescript
|
|
// ID token verification
|
|
async function verifyIdToken(idToken: string): Promise<UserInfo> {
|
|
// 1. Parse JWT header and payload
|
|
const [header, payload, signature] = idToken.split('.');
|
|
const parsedHeader = JSON.parse(base64Decode(header));
|
|
const parsedPayload = JSON.parse(base64Decode(payload));
|
|
|
|
// 2. Fetch provider's public keys
|
|
const jwks = await fetchJWKS(config.oidc.issuer);
|
|
const publicKey = findMatchingKey(jwks, parsedHeader.kid);
|
|
|
|
// 3. Verify signature
|
|
const isValid = verifyJWTSignature(idToken, publicKey);
|
|
if (!isValid) {
|
|
throw new Error('Invalid token signature');
|
|
}
|
|
|
|
// 4. Verify claims
|
|
validateClaims(parsedPayload, {
|
|
issuer: config.oidc.issuer,
|
|
audience: config.oidc.clientId,
|
|
nonce: getExpectedNonce()
|
|
});
|
|
|
|
return {
|
|
sub: parsedPayload.sub,
|
|
email: parsedPayload.email,
|
|
name: parsedPayload.name
|
|
};
|
|
}
|
|
```
|
|
|
|
#### Session Management
|
|
|
|
```typescript
|
|
// SSO session handling
|
|
class SSOSessionManager {
|
|
async refreshTokens(req: Request): Promise<void> {
|
|
const refreshToken = req.session.oidcTokens?.refresh_token;
|
|
if (!refreshToken) {
|
|
throw new Error('No refresh token available');
|
|
}
|
|
|
|
try {
|
|
const newTokens = await refreshAccessToken(refreshToken);
|
|
req.session.oidcTokens = newTokens;
|
|
} catch (error) {
|
|
// Refresh failed, require re-authentication
|
|
req.session.destroy();
|
|
throw new Error('Token refresh failed');
|
|
}
|
|
}
|
|
|
|
async logout(req: Request, res: Response): Promise<void> {
|
|
// 1. Destroy local session
|
|
const oidcTokens = req.session.oidcTokens;
|
|
req.session.destroy();
|
|
|
|
// 2. Construct logout URL
|
|
const logoutUrl = buildLogoutUrl({
|
|
issuer: config.oidc.issuer,
|
|
clientId: config.oidc.clientId,
|
|
postLogoutRedirectUri: config.oidc.postLogoutRedirectUri,
|
|
idTokenHint: oidcTokens?.id_token
|
|
});
|
|
|
|
// 3. Redirect to provider logout
|
|
res.redirect(logoutUrl);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Session Management
|
|
|
|
### Session Architecture
|
|
|
|
#### Storage Backend
|
|
|
|
```typescript
|
|
// Session store configuration
|
|
const sessionStore = new SqliteStore({
|
|
database: './data/document.db',
|
|
table: 'sessions',
|
|
createTable: true,
|
|
cleanupInterval: 3600000 // 1 hour
|
|
});
|
|
```
|
|
|
|
#### Session Schema
|
|
|
|
```sql
|
|
-- Session storage table
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY, -- Session identifier
|
|
expires INTEGER NOT NULL, -- Expiration timestamp
|
|
data TEXT NOT NULL -- JSON session data
|
|
);
|
|
|
|
CREATE INDEX idx_sessions_expires ON sessions(expires);
|
|
```
|
|
|
|
#### Session Data Structure
|
|
|
|
```typescript
|
|
// Session data interface
|
|
interface SessionData {
|
|
loggedIn: boolean;
|
|
userId?: string;
|
|
userInfo?: {
|
|
email: string;
|
|
name: string;
|
|
sub: string;
|
|
};
|
|
lastAuthState: {
|
|
totpEnabled: boolean;
|
|
ssoEnabled: boolean;
|
|
};
|
|
protectedSession?: {
|
|
active: boolean;
|
|
lastActivity: number;
|
|
};
|
|
csrf?: string;
|
|
oidcTokens?: {
|
|
access_token: string;
|
|
refresh_token: string;
|
|
id_token: string;
|
|
};
|
|
}
|
|
```
|
|
|
|
### Session Security Configuration
|
|
|
|
#### Cookie Security
|
|
|
|
```typescript
|
|
// Secure session cookie configuration
|
|
const sessionConfig = {
|
|
name: 'trilium.sid',
|
|
secret: generateSessionSecret(),
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
rolling: true,
|
|
cookie: {
|
|
httpOnly: true, // Prevent XSS access
|
|
secure: isProduction, // HTTPS only in production
|
|
sameSite: 'strict', // CSRF protection
|
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
|
domain: undefined // Current domain only
|
|
}
|
|
};
|
|
```
|
|
|
|
#### Session Validation
|
|
|
|
```typescript
|
|
// Session validation middleware
|
|
function validateSession(req: Request, res: Response, next: NextFunction) {
|
|
// 1. Check session existence
|
|
if (!req.session) {
|
|
return res.status(401).json({ error: 'No session found' });
|
|
}
|
|
|
|
// 2. Verify session integrity
|
|
if (!req.session.loggedIn) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
// 3. Check session expiration
|
|
if (isSessionExpired(req.session)) {
|
|
req.session.destroy();
|
|
return res.status(401).json({ error: 'Session expired' });
|
|
}
|
|
|
|
// 4. Validate authentication state consistency
|
|
const currentAuthState = getCurrentAuthState();
|
|
const lastAuthState = req.session.lastAuthState;
|
|
|
|
if (!authStateMatches(currentAuthState, lastAuthState)) {
|
|
req.session.destroy();
|
|
return res.status(401).json({ error: 'Authentication state changed' });
|
|
}
|
|
|
|
// 5. Update last activity
|
|
req.session.lastActivity = Date.now();
|
|
|
|
next();
|
|
}
|
|
```
|
|
|
|
### Session Lifecycle Management
|
|
|
|
#### Session Creation
|
|
|
|
```typescript
|
|
// Create new authenticated session
|
|
async function createSession(req: Request, userInfo: UserInfo): Promise<void> {
|
|
// 1. Generate session data
|
|
const sessionData: SessionData = {
|
|
loggedIn: true,
|
|
userId: userInfo.sub,
|
|
userInfo: userInfo,
|
|
lastAuthState: {
|
|
totpEnabled: isTotpEnabled(),
|
|
ssoEnabled: isSSoEnabled()
|
|
},
|
|
csrf: generateCSRFToken()
|
|
};
|
|
|
|
// 2. Store session
|
|
req.session = sessionData;
|
|
|
|
// 3. Set secure cookie
|
|
req.session.save((err) => {
|
|
if (err) {
|
|
throw new Error('Failed to save session');
|
|
}
|
|
});
|
|
|
|
// 4. Log security event
|
|
logSecurityEvent('SESSION_CREATE', {
|
|
userId: userInfo.sub,
|
|
ip: req.ip,
|
|
userAgent: req.headers['user-agent']
|
|
});
|
|
}
|
|
```
|
|
|
|
#### Session Cleanup
|
|
|
|
```typescript
|
|
// Automatic session cleanup
|
|
class SessionCleaner {
|
|
constructor(private sessionStore: SqliteStore) {
|
|
// Run cleanup every hour
|
|
setInterval(() => this.cleanup(), 3600000);
|
|
}
|
|
|
|
async cleanup(): Promise<void> {
|
|
const now = Date.now();
|
|
|
|
// Remove expired sessions
|
|
const expiredCount = await this.sessionStore.clear({
|
|
where: 'expires < ?',
|
|
params: [now]
|
|
});
|
|
|
|
// Log cleanup results
|
|
if (expiredCount > 0) {
|
|
logSecurityEvent('SESSION_CLEANUP', {
|
|
expiredSessions: expiredCount,
|
|
timestamp: now
|
|
});
|
|
}
|
|
|
|
// Optimize database
|
|
await this.sessionStore.optimize();
|
|
}
|
|
}
|
|
```
|
|
|
|
## CSRF Protection
|
|
|
|
### Double Submit Cookie Implementation
|
|
|
|
#### Token Generation
|
|
|
|
```typescript
|
|
// CSRF token management
|
|
class CSRFTokenManager {
|
|
generateToken(): string {
|
|
// Generate 32-byte random token
|
|
const tokenBytes = crypto.randomBytes(32);
|
|
return tokenBytes.toString('hex');
|
|
}
|
|
|
|
setToken(req: Request, res: Response): void {
|
|
const token = this.generateToken();
|
|
|
|
// Store in session
|
|
req.session.csrf = token;
|
|
|
|
// Send as cookie
|
|
res.cookie('_csrf', token, {
|
|
httpOnly: true,
|
|
secure: isProduction,
|
|
sameSite: 'strict',
|
|
path: '/'
|
|
});
|
|
}
|
|
|
|
validateToken(req: Request): boolean {
|
|
const sessionToken = req.session.csrf;
|
|
const cookieToken = req.cookies._csrf;
|
|
const headerToken = req.headers['x-csrf-token'] ||
|
|
req.headers['x-xsrf-token'];
|
|
|
|
// All tokens must be present and match
|
|
return sessionToken &&
|
|
cookieToken &&
|
|
headerToken &&
|
|
constantTimeEquals(sessionToken, cookieToken) &&
|
|
constantTimeEquals(sessionToken, headerToken);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### CSRF Middleware
|
|
|
|
```typescript
|
|
// CSRF protection middleware
|
|
function csrfProtection(req: Request, res: Response, next: NextFunction) {
|
|
// Skip CSRF for safe methods
|
|
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
|
|
return next();
|
|
}
|
|
|
|
// Skip CSRF for API token authentication
|
|
if (isApiTokenAuthenticated(req)) {
|
|
return next();
|
|
}
|
|
|
|
// Validate CSRF token
|
|
if (!csrfTokenManager.validateToken(req)) {
|
|
logSecurityEvent('CSRF_VIOLATION', {
|
|
ip: req.ip,
|
|
path: req.path,
|
|
method: req.method,
|
|
userAgent: req.headers['user-agent']
|
|
});
|
|
|
|
return res.status(403).json({
|
|
error: 'CSRF token validation failed'
|
|
});
|
|
}
|
|
|
|
next();
|
|
}
|
|
```
|
|
|
|
## API Authentication
|
|
|
|
### ETAPI Token System
|
|
|
|
#### Token Structure
|
|
|
|
```typescript
|
|
// ETAPI token format
|
|
interface EtapiToken {
|
|
id: string; // Unique token identifier
|
|
name: string; // Human-readable name
|
|
tokenHash: string; // SHA-256 hash of token
|
|
isDeleted: boolean; // Soft delete flag
|
|
dateCreated: string; // Creation timestamp
|
|
dateModified: string; // Last modification
|
|
}
|
|
```
|
|
|
|
#### Token Generation
|
|
|
|
```typescript
|
|
// Generate new ETAPI token
|
|
function generateEtapiToken(name: string): { token: string, tokenHash: string } {
|
|
// Generate 64-character random token
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
|
|
// Create hash for storage
|
|
const tokenHash = crypto
|
|
.createHash('sha256')
|
|
.update(token)
|
|
.digest('hex');
|
|
|
|
// Store in database
|
|
sql.execute(`
|
|
INSERT INTO etapi_tokens (id, name, tokenHash, isDeleted, dateCreated, dateModified)
|
|
VALUES (?, ?, ?, 0, datetime('now'), datetime('now'))
|
|
`, [generateId(), name, tokenHash]);
|
|
|
|
return { token, tokenHash };
|
|
}
|
|
```
|
|
|
|
#### Token Validation
|
|
|
|
```typescript
|
|
// Validate ETAPI token
|
|
function validateEtapiToken(authHeader: string): boolean {
|
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
return false;
|
|
}
|
|
|
|
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
|
|
|
|
// Hash provided token
|
|
const providedHash = crypto
|
|
.createHash('sha256')
|
|
.update(token)
|
|
.digest('hex');
|
|
|
|
// Check against stored hashes
|
|
const storedToken = sql.getValue(`
|
|
SELECT id FROM etapi_tokens
|
|
WHERE tokenHash = ? AND isDeleted = 0
|
|
`, [providedHash]);
|
|
|
|
return !!storedToken;
|
|
}
|
|
```
|
|
|
|
### Rate Limiting
|
|
|
|
#### Token-based Rate Limiting
|
|
|
|
```typescript
|
|
// Rate limiter for API endpoints
|
|
class ApiRateLimiter {
|
|
private limits = new Map<string, TokenLimit>();
|
|
|
|
constructor(
|
|
private maxRequests: number = 1000,
|
|
private windowMs: number = 60000 // 1 minute
|
|
) {}
|
|
|
|
check(tokenHash: string): boolean {
|
|
const now = Date.now();
|
|
const limit = this.limits.get(tokenHash);
|
|
|
|
if (!limit || now - limit.resetTime > this.windowMs) {
|
|
// Reset or create new limit
|
|
this.limits.set(tokenHash, {
|
|
count: 1,
|
|
resetTime: now
|
|
});
|
|
return true;
|
|
}
|
|
|
|
if (limit.count >= this.maxRequests) {
|
|
return false; // Rate limit exceeded
|
|
}
|
|
|
|
limit.count++;
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Access Control
|
|
|
|
### Permission Matrix
|
|
|
|
```typescript
|
|
// Access control matrix
|
|
enum Permission {
|
|
READ_PUBLIC = 'read_public',
|
|
READ_PROTECTED = 'read_protected',
|
|
WRITE_PUBLIC = 'write_public',
|
|
WRITE_PROTECTED = 'write_protected',
|
|
ADMIN = 'admin',
|
|
API_ACCESS = 'api_access'
|
|
}
|
|
|
|
enum AuthState {
|
|
ANONYMOUS = 'anonymous',
|
|
AUTHENTICATED = 'authenticated',
|
|
PROTECTED_SESSION = 'protected_session',
|
|
API_TOKEN = 'api_token'
|
|
}
|
|
|
|
const accessMatrix: Record<AuthState, Permission[]> = {
|
|
[AuthState.ANONYMOUS]: [
|
|
Permission.READ_PUBLIC // Only if sharing enabled
|
|
],
|
|
[AuthState.AUTHENTICATED]: [
|
|
Permission.READ_PUBLIC,
|
|
Permission.WRITE_PUBLIC,
|
|
Permission.ADMIN
|
|
],
|
|
[AuthState.PROTECTED_SESSION]: [
|
|
Permission.READ_PUBLIC,
|
|
Permission.READ_PROTECTED,
|
|
Permission.WRITE_PUBLIC,
|
|
Permission.WRITE_PROTECTED,
|
|
Permission.ADMIN
|
|
],
|
|
[AuthState.API_TOKEN]: [
|
|
Permission.READ_PUBLIC,
|
|
Permission.WRITE_PUBLIC,
|
|
Permission.API_ACCESS
|
|
]
|
|
};
|
|
```
|
|
|
|
### Authorization Middleware
|
|
|
|
```typescript
|
|
// Authorization enforcement
|
|
function requirePermission(permission: Permission) {
|
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
const authState = getAuthState(req);
|
|
const allowedPermissions = accessMatrix[authState];
|
|
|
|
if (!allowedPermissions.includes(permission)) {
|
|
logSecurityEvent('AUTHORIZATION_DENIED', {
|
|
permission: permission,
|
|
authState: authState,
|
|
ip: req.ip,
|
|
path: req.path
|
|
});
|
|
|
|
return res.status(403).json({
|
|
error: 'Insufficient permissions'
|
|
});
|
|
}
|
|
|
|
next();
|
|
};
|
|
}
|
|
|
|
// Usage examples
|
|
app.get('/api/notes/:id',
|
|
requirePermission(Permission.READ_PUBLIC),
|
|
getNoteHandler
|
|
);
|
|
|
|
app.put('/api/notes/:id',
|
|
requirePermission(Permission.WRITE_PUBLIC),
|
|
updateNoteHandler
|
|
);
|
|
|
|
app.get('/api/protected-notes/:id',
|
|
requirePermission(Permission.READ_PROTECTED),
|
|
getProtectedNoteHandler
|
|
);
|
|
```
|
|
|
|
## Security Monitoring
|
|
|
|
### Security Event Logging
|
|
|
|
```typescript
|
|
// Security event types
|
|
enum SecurityEventType {
|
|
LOGIN_SUCCESS = 'login_success',
|
|
LOGIN_FAILURE = 'login_failure',
|
|
MFA_SUCCESS = 'mfa_success',
|
|
MFA_FAILURE = 'mfa_failure',
|
|
SESSION_CREATE = 'session_create',
|
|
SESSION_DESTROY = 'session_destroy',
|
|
PROTECTED_SESSION_START = 'protected_session_start',
|
|
PROTECTED_SESSION_END = 'protected_session_end',
|
|
CSRF_VIOLATION = 'csrf_violation',
|
|
RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded',
|
|
AUTHORIZATION_DENIED = 'authorization_denied',
|
|
PASSWORD_CHANGE = 'password_change',
|
|
API_TOKEN_CREATED = 'api_token_created',
|
|
API_TOKEN_DELETED = 'api_token_deleted'
|
|
}
|
|
|
|
// Security event logger
|
|
class SecurityEventLogger {
|
|
logEvent(type: SecurityEventType, data: any): void {
|
|
const event = {
|
|
timestamp: new Date().toISOString(),
|
|
type: type,
|
|
data: data,
|
|
severity: this.getSeverity(type)
|
|
};
|
|
|
|
// Log to database
|
|
sql.execute(`
|
|
INSERT INTO security_events (timestamp, type, data, severity)
|
|
VALUES (?, ?, ?, ?)
|
|
`, [event.timestamp, event.type, JSON.stringify(event.data), event.severity]);
|
|
|
|
// Log to file
|
|
logger.info('Security Event', event);
|
|
|
|
// Send alerts for high-severity events
|
|
if (event.severity === 'HIGH') {
|
|
this.sendAlert(event);
|
|
}
|
|
}
|
|
|
|
private getSeverity(type: SecurityEventType): string {
|
|
const highSeverityEvents = [
|
|
SecurityEventType.LOGIN_FAILURE,
|
|
SecurityEventType.MFA_FAILURE,
|
|
SecurityEventType.CSRF_VIOLATION,
|
|
SecurityEventType.AUTHORIZATION_DENIED
|
|
];
|
|
|
|
return highSeverityEvents.includes(type) ? 'HIGH' : 'LOW';
|
|
}
|
|
}
|
|
```
|
|
|
|
### Intrusion Detection
|
|
|
|
```typescript
|
|
// Basic intrusion detection
|
|
class IntrusionDetector {
|
|
private failedAttempts = new Map<string, FailureRecord>();
|
|
|
|
constructor(
|
|
private maxFailures = 5,
|
|
private windowMs = 300000 // 5 minutes
|
|
) {}
|
|
|
|
recordFailure(ip: string): void {
|
|
const now = Date.now();
|
|
const record = this.failedAttempts.get(ip);
|
|
|
|
if (!record || now - record.firstAttempt > this.windowMs) {
|
|
this.failedAttempts.set(ip, {
|
|
count: 1,
|
|
firstAttempt: now,
|
|
lastAttempt: now
|
|
});
|
|
} else {
|
|
record.count++;
|
|
record.lastAttempt = now;
|
|
|
|
if (record.count >= this.maxFailures) {
|
|
this.triggerLockout(ip);
|
|
}
|
|
}
|
|
}
|
|
|
|
private triggerLockout(ip: string): void {
|
|
// Log security event
|
|
logSecurityEvent(SecurityEventType.RATE_LIMIT_EXCEEDED, {
|
|
ip: ip,
|
|
failureCount: this.maxFailures,
|
|
action: 'temporary_lockout'
|
|
});
|
|
|
|
// Implement temporary IP blocking
|
|
this.blockIP(ip, 3600000); // 1 hour block
|
|
}
|
|
}
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
### Common Authentication Issues
|
|
|
|
#### Login Failures
|
|
|
|
**Symptom**: Cannot login with correct credentials
|
|
|
|
**Diagnostic Steps**:
|
|
```bash
|
|
# Check session storage
|
|
sqlite3 document.db "SELECT COUNT(*) FROM sessions;"
|
|
|
|
# Verify password hash
|
|
sqlite3 document.db "SELECT name, length(value) FROM options WHERE name LIKE 'password%';"
|
|
|
|
# Check for session errors
|
|
tail -f logs/trilium.log | grep -i session
|
|
```
|
|
|
|
**Solutions**:
|
|
1. Clear browser cookies and cache
|
|
2. Restart Trilium server
|
|
3. Check database permissions
|
|
4. Verify password case sensitivity
|
|
|
|
#### MFA Problems
|
|
|
|
**Symptom**: TOTP codes rejected
|
|
|
|
**Diagnostic Steps**:
|
|
```bash
|
|
# Check TOTP configuration
|
|
sqlite3 document.db "SELECT value FROM options WHERE name = 'mfaEnabled';"
|
|
|
|
# Verify time synchronization
|
|
timedatectl status
|
|
|
|
# Check TOTP secret
|
|
# (In protected session only)
|
|
```
|
|
|
|
**Solutions**:
|
|
1. Synchronize system time with NTP
|
|
2. Use recovery codes
|
|
3. Regenerate TOTP secret
|
|
4. Check authenticator app configuration
|
|
|
|
#### Session Issues
|
|
|
|
**Symptom**: Frequent logouts or session errors
|
|
|
|
**Diagnostic Steps**:
|
|
```bash
|
|
# Check session configuration
|
|
sqlite3 document.db "SELECT * FROM sessions LIMIT 5;"
|
|
|
|
# Monitor session cleanup
|
|
tail -f logs/trilium.log | grep -i "session cleanup"
|
|
|
|
# Check cookie settings
|
|
# Browser Developer Tools → Application → Cookies
|
|
```
|
|
|
|
**Solutions**:
|
|
1. Increase session timeout
|
|
2. Enable secure cookies for HTTPS
|
|
3. Check browser cookie settings
|
|
4. Verify database write permissions
|
|
|
|
### Security Monitoring Queries
|
|
|
|
```sql
|
|
-- Recent login failures
|
|
SELECT timestamp, data FROM security_events
|
|
WHERE type = 'login_failure'
|
|
AND timestamp > datetime('now', '-1 hour')
|
|
ORDER BY timestamp DESC;
|
|
|
|
-- MFA bypass attempts
|
|
SELECT timestamp, data FROM security_events
|
|
WHERE type = 'mfa_failure'
|
|
AND timestamp > datetime('now', '-1 day')
|
|
ORDER BY timestamp DESC;
|
|
|
|
-- CSRF violations
|
|
SELECT timestamp, data FROM security_events
|
|
WHERE type = 'csrf_violation'
|
|
ORDER BY timestamp DESC LIMIT 10;
|
|
|
|
-- Active sessions
|
|
SELECT id, expires, length(data) as data_size
|
|
FROM sessions
|
|
WHERE expires > unixepoch('now') * 1000
|
|
ORDER BY expires ASC;
|
|
```
|
|
|
|
Remember: Proper authentication and access control are fundamental to Trilium's security. Regularly review your configuration and monitor for suspicious activity to maintain a secure environment. |