@latanda/auth-middleware 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +29 -0
- package/README.md +438 -0
- package/examples/basic-usage.js +90 -0
- package/package.json +66 -0
- package/sql/schema.sql +126 -0
- package/src/index.js +43 -0
- package/src/jwt.js +186 -0
- package/src/middleware.js +256 -0
- package/src/rbac.js +180 -0
package/sql/schema.sql
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
-- @latanda/auth-middleware PostgreSQL Schema
|
|
2
|
+
-- Production-tested schema from latanda.online
|
|
3
|
+
--
|
|
4
|
+
-- This schema supports:
|
|
5
|
+
-- - User authentication with JWT tokens
|
|
6
|
+
-- - Role-based access control (RBAC)
|
|
7
|
+
-- - Session management
|
|
8
|
+
-- - Password security with bcrypt
|
|
9
|
+
|
|
10
|
+
-- Users table
|
|
11
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
12
|
+
id SERIAL PRIMARY KEY,
|
|
13
|
+
email VARCHAR(255) UNIQUE NOT NULL,
|
|
14
|
+
password_hash VARCHAR(255) NOT NULL,
|
|
15
|
+
full_name VARCHAR(100),
|
|
16
|
+
role VARCHAR(20) DEFAULT 'USER' CHECK (role IN ('ADMIN', 'MIT', 'IT', 'USER')),
|
|
17
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
18
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
19
|
+
last_login TIMESTAMP,
|
|
20
|
+
is_active BOOLEAN DEFAULT true,
|
|
21
|
+
email_verified BOOLEAN DEFAULT false
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
-- Create index for faster email lookups
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
|
|
28
|
+
|
|
29
|
+
-- Sessions table (optional - for tracking active sessions)
|
|
30
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
31
|
+
id SERIAL PRIMARY KEY,
|
|
32
|
+
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
33
|
+
token_hash VARCHAR(255) NOT NULL, -- Store hash of JWT token
|
|
34
|
+
ip_address INET,
|
|
35
|
+
user_agent TEXT,
|
|
36
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
37
|
+
expires_at TIMESTAMP NOT NULL,
|
|
38
|
+
is_valid BOOLEAN DEFAULT true
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
-- Create index for faster session lookups
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_valid ON sessions(is_valid);
|
|
45
|
+
|
|
46
|
+
-- User permissions table (for custom per-user permissions beyond role defaults)
|
|
47
|
+
CREATE TABLE IF NOT EXISTS user_permissions (
|
|
48
|
+
id SERIAL PRIMARY KEY,
|
|
49
|
+
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
50
|
+
permission VARCHAR(100) NOT NULL,
|
|
51
|
+
granted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
52
|
+
granted_by INTEGER REFERENCES users(id),
|
|
53
|
+
UNIQUE(user_id, permission)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
-- Create index for permission lookups
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_user_permissions_user_id ON user_permissions(user_id);
|
|
58
|
+
|
|
59
|
+
-- Audit log table (track authentication events)
|
|
60
|
+
CREATE TABLE IF NOT EXISTS auth_audit_log (
|
|
61
|
+
id SERIAL PRIMARY KEY,
|
|
62
|
+
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
63
|
+
event_type VARCHAR(50) NOT NULL, -- login, logout, token_refresh, failed_login, etc.
|
|
64
|
+
ip_address INET,
|
|
65
|
+
user_agent TEXT,
|
|
66
|
+
success BOOLEAN DEFAULT true,
|
|
67
|
+
error_message TEXT,
|
|
68
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
-- Create index for audit log queries
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON auth_audit_log(user_id);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_event_type ON auth_audit_log(event_type);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON auth_audit_log(created_at DESC);
|
|
75
|
+
|
|
76
|
+
-- Function to update updated_at timestamp
|
|
77
|
+
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
78
|
+
RETURNS TRIGGER AS $$
|
|
79
|
+
BEGIN
|
|
80
|
+
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
81
|
+
RETURN NEW;
|
|
82
|
+
END;
|
|
83
|
+
$$ language 'plpgsql';
|
|
84
|
+
|
|
85
|
+
-- Trigger to auto-update updated_at
|
|
86
|
+
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
|
87
|
+
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
|
88
|
+
|
|
89
|
+
-- Function to clean up expired sessions (call periodically)
|
|
90
|
+
CREATE OR REPLACE FUNCTION cleanup_expired_sessions()
|
|
91
|
+
RETURNS INTEGER AS $$
|
|
92
|
+
DECLARE
|
|
93
|
+
deleted_count INTEGER;
|
|
94
|
+
BEGIN
|
|
95
|
+
DELETE FROM sessions WHERE expires_at < CURRENT_TIMESTAMP;
|
|
96
|
+
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
|
97
|
+
RETURN deleted_count;
|
|
98
|
+
END;
|
|
99
|
+
$$ LANGUAGE plpgsql;
|
|
100
|
+
|
|
101
|
+
-- Sample data (optional - remove in production)
|
|
102
|
+
-- Admin user: admin@latanda.online / Admin123!
|
|
103
|
+
-- Password hash is bcrypt for "Admin123!"
|
|
104
|
+
INSERT INTO users (email, password_hash, full_name, role, email_verified)
|
|
105
|
+
VALUES (
|
|
106
|
+
'admin@latanda.online',
|
|
107
|
+
'$2b$10$7jKH8TmkqzN.sJX8YvJ6eOdXqY7ZKYqWX8LqU3TqJXqVqYqWq',
|
|
108
|
+
'System Administrator',
|
|
109
|
+
'ADMIN',
|
|
110
|
+
true
|
|
111
|
+
) ON CONFLICT (email) DO NOTHING;
|
|
112
|
+
|
|
113
|
+
-- Demo user: demo@latanda.online / demo123
|
|
114
|
+
INSERT INTO users (email, password_hash, full_name, role, email_verified)
|
|
115
|
+
VALUES (
|
|
116
|
+
'demo@latanda.online',
|
|
117
|
+
'$2b$10$demo_password_hash_placeholder',
|
|
118
|
+
'Demo User',
|
|
119
|
+
'USER',
|
|
120
|
+
true
|
|
121
|
+
) ON CONFLICT (email) DO NOTHING;
|
|
122
|
+
|
|
123
|
+
COMMENT ON TABLE users IS 'User accounts with authentication credentials';
|
|
124
|
+
COMMENT ON TABLE sessions IS 'Active JWT token sessions for tracking and revocation';
|
|
125
|
+
COMMENT ON TABLE user_permissions IS 'Custom per-user permissions beyond role defaults';
|
|
126
|
+
COMMENT ON TABLE auth_audit_log IS 'Audit trail of authentication events';
|
package/src/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @latanda/auth-middleware
|
|
3
|
+
* Production-ready JWT authentication middleware for Node.js + PostgreSQL + Nginx
|
|
4
|
+
*
|
|
5
|
+
* Battle-tested with 30+ users at https://latanda.online
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const { createAuthMiddleware, requireRole } = require('@latanda/auth-middleware');
|
|
9
|
+
*
|
|
10
|
+
* app.use('/api/*', createAuthMiddleware({ jwtSecret: process.env.JWT_SECRET }));
|
|
11
|
+
* app.use('/api/admin/*', requireRole('ADMIN'));
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const jwt = require('./jwt');
|
|
15
|
+
const rbac = require('./rbac');
|
|
16
|
+
const middleware = require('./middleware');
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
// JWT functions
|
|
20
|
+
generateToken: jwt.generateToken,
|
|
21
|
+
validateToken: jwt.validateToken,
|
|
22
|
+
decodeToken: jwt.decodeToken,
|
|
23
|
+
isTokenExpiringSoon: jwt.isTokenExpiringSoon,
|
|
24
|
+
refreshToken: jwt.refreshToken,
|
|
25
|
+
|
|
26
|
+
// RBAC functions
|
|
27
|
+
ROLES: rbac.ROLES,
|
|
28
|
+
hasPermission: rbac.hasPermission,
|
|
29
|
+
hasAnyPermission: rbac.hasAnyPermission,
|
|
30
|
+
hasAllPermissions: rbac.hasAllPermissions,
|
|
31
|
+
hasRoleLevel: rbac.hasRoleLevel,
|
|
32
|
+
getRolePermissions: rbac.getRolePermissions,
|
|
33
|
+
isValidRole: rbac.isValidRole,
|
|
34
|
+
canAccessResource: rbac.canAccessResource,
|
|
35
|
+
canPerformGroupAction: rbac.canPerformGroupAction,
|
|
36
|
+
|
|
37
|
+
// Express middleware
|
|
38
|
+
createAuthMiddleware: middleware.createAuthMiddleware,
|
|
39
|
+
requirePermission: middleware.requirePermission,
|
|
40
|
+
requireRole: middleware.requireRole,
|
|
41
|
+
requireOwnership: middleware.requireOwnership,
|
|
42
|
+
optionalAuth: middleware.optionalAuth
|
|
43
|
+
};
|
package/src/jwt.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT Token Generation and Validation
|
|
3
|
+
* Extracted from La Tanda production system (latanda.online)
|
|
4
|
+
* Battle-tested with 30+ users, 16+ groups
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const jwt = require('jsonwebtoken');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a JWT token with user data and claims
|
|
11
|
+
* @param {Object} user - User object from database
|
|
12
|
+
* @param {string} secret - JWT secret key
|
|
13
|
+
* @param {Object} options - Additional options
|
|
14
|
+
* @returns {string} JWT token
|
|
15
|
+
*/
|
|
16
|
+
function generateToken(user, secret, options = {}) {
|
|
17
|
+
const {
|
|
18
|
+
expiresIn = '8h',
|
|
19
|
+
issuer = 'latanda.online',
|
|
20
|
+
audience = 'latanda-web-app'
|
|
21
|
+
} = options;
|
|
22
|
+
|
|
23
|
+
// Build JWT payload with required claims
|
|
24
|
+
const payload = {
|
|
25
|
+
user_id: user.id || user.user_id,
|
|
26
|
+
email: user.email,
|
|
27
|
+
role: user.role || 'USER',
|
|
28
|
+
permissions: user.permissions || []
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Sign token with HS256 algorithm
|
|
32
|
+
// iss, aud, iat, exp are added automatically by jwt.sign()
|
|
33
|
+
return jwt.sign(payload, secret, {
|
|
34
|
+
algorithm: 'HS256',
|
|
35
|
+
expiresIn,
|
|
36
|
+
issuer,
|
|
37
|
+
audience
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate JWT token with comprehensive checks
|
|
43
|
+
* @param {string} token - JWT token to validate
|
|
44
|
+
* @param {string} secret - JWT secret key
|
|
45
|
+
* @param {Object} options - Validation options
|
|
46
|
+
* @returns {Object} Validation result with decoded token or error
|
|
47
|
+
*/
|
|
48
|
+
function validateToken(token, secret, options = {}) {
|
|
49
|
+
const {
|
|
50
|
+
issuer = 'latanda.online',
|
|
51
|
+
audience = 'latanda-web-app'
|
|
52
|
+
} = options;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
// 1. Check token format (must have 3 parts)
|
|
56
|
+
if (!token || typeof token !== 'string') {
|
|
57
|
+
return { valid: false, error: 'Invalid token format' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const parts = token.split('.');
|
|
61
|
+
if (parts.length !== 3) {
|
|
62
|
+
return { valid: false, error: 'Malformed token structure' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 2. Decode and verify token
|
|
66
|
+
const decoded = jwt.verify(token, secret, {
|
|
67
|
+
algorithms: ['HS256'],
|
|
68
|
+
issuer,
|
|
69
|
+
audience
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// 3. Validate required claims
|
|
73
|
+
const requiredClaims = ['user_id', 'email', 'role', 'iss', 'aud', 'exp', 'iat'];
|
|
74
|
+
for (const claim of requiredClaims) {
|
|
75
|
+
if (!decoded[claim]) {
|
|
76
|
+
return { valid: false, error: `Missing required claim: ${claim}` };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 4. Check expiration (already done by jwt.verify, but double-check)
|
|
81
|
+
const now = Math.floor(Date.now() / 1000);
|
|
82
|
+
if (decoded.exp <= now) {
|
|
83
|
+
return { valid: false, error: 'Token expired' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 5. Validate issuer and audience
|
|
87
|
+
if (decoded.iss !== issuer) {
|
|
88
|
+
return { valid: false, error: 'Invalid issuer' };
|
|
89
|
+
}
|
|
90
|
+
if (decoded.aud !== audience) {
|
|
91
|
+
return { valid: false, error: 'Invalid audience' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
valid: true,
|
|
96
|
+
decoded,
|
|
97
|
+
user_id: decoded.user_id,
|
|
98
|
+
email: decoded.email,
|
|
99
|
+
role: decoded.role,
|
|
100
|
+
permissions: decoded.permissions || []
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error.name === 'TokenExpiredError') {
|
|
105
|
+
return { valid: false, error: 'Token expired', expired: true };
|
|
106
|
+
}
|
|
107
|
+
if (error.name === 'JsonWebTokenError') {
|
|
108
|
+
return { valid: false, error: 'Invalid token signature' };
|
|
109
|
+
}
|
|
110
|
+
return { valid: false, error: error.message };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Decode JWT token without verification (for inspection only)
|
|
116
|
+
* @param {string} token - JWT token to decode
|
|
117
|
+
* @returns {Object|null} Decoded token or null if invalid
|
|
118
|
+
*/
|
|
119
|
+
function decodeToken(token) {
|
|
120
|
+
try {
|
|
121
|
+
return jwt.decode(token, { complete: true });
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check if token is expiring soon (within threshold)
|
|
129
|
+
* @param {string} token - JWT token to check
|
|
130
|
+
* @param {number} thresholdMinutes - Minutes before expiration to consider "expiring soon"
|
|
131
|
+
* @returns {boolean} True if token expires within threshold
|
|
132
|
+
*/
|
|
133
|
+
function isTokenExpiringSoon(token, thresholdMinutes = 15) {
|
|
134
|
+
const decoded = decodeToken(token);
|
|
135
|
+
if (!decoded || !decoded.payload || !decoded.payload.exp) {
|
|
136
|
+
return true; // Treat invalid tokens as expiring
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const now = Math.floor(Date.now() / 1000);
|
|
140
|
+
const exp = decoded.payload.exp;
|
|
141
|
+
const thresholdSeconds = thresholdMinutes * 60;
|
|
142
|
+
|
|
143
|
+
return (exp - now) <= thresholdSeconds;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Refresh a token (generate new token with same user data)
|
|
148
|
+
* @param {string} oldToken - Current token to refresh
|
|
149
|
+
* @param {string} secret - JWT secret key
|
|
150
|
+
* @param {Object} options - Refresh options
|
|
151
|
+
* @returns {Object} Result with new token or error
|
|
152
|
+
*/
|
|
153
|
+
function refreshToken(oldToken, secret, options = {}) {
|
|
154
|
+
const validation = validateToken(oldToken, secret, options);
|
|
155
|
+
|
|
156
|
+
if (!validation.valid && !validation.expired) {
|
|
157
|
+
return { success: false, error: 'Invalid token cannot be refreshed' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Extract user data from old token
|
|
161
|
+
const decoded = validation.decoded || decodeToken(oldToken).payload;
|
|
162
|
+
const user = {
|
|
163
|
+
id: decoded.user_id,
|
|
164
|
+
email: decoded.email,
|
|
165
|
+
role: decoded.role,
|
|
166
|
+
permissions: decoded.permissions
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Generate new token
|
|
170
|
+
const newToken = generateToken(user, secret, options);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
success: true,
|
|
174
|
+
token: newToken,
|
|
175
|
+
user_id: user.id,
|
|
176
|
+
expires_in: options.expiresIn || '8h'
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
generateToken,
|
|
182
|
+
validateToken,
|
|
183
|
+
decodeToken,
|
|
184
|
+
isTokenExpiringSoon,
|
|
185
|
+
refreshToken
|
|
186
|
+
};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express Middleware for JWT Authentication
|
|
3
|
+
* Production-ready authentication middleware from latanda.online
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { validateToken } = require('./jwt');
|
|
7
|
+
const { hasPermission, hasRoleLevel, isValidRole } = require('./rbac');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create authentication middleware
|
|
11
|
+
* @param {Object} config - Configuration options
|
|
12
|
+
* @param {string} config.jwtSecret - JWT secret key
|
|
13
|
+
* @param {string} [config.issuer='latanda.online'] - Token issuer
|
|
14
|
+
* @param {string} [config.audience='latanda-web-app'] - Token audience
|
|
15
|
+
* @param {Function} [config.onUnauthorized] - Custom unauthorized handler
|
|
16
|
+
* @returns {Function} Express middleware
|
|
17
|
+
*/
|
|
18
|
+
function createAuthMiddleware(config) {
|
|
19
|
+
const {
|
|
20
|
+
jwtSecret,
|
|
21
|
+
issuer = 'latanda.online',
|
|
22
|
+
audience = 'latanda-web-app',
|
|
23
|
+
onUnauthorized
|
|
24
|
+
} = config;
|
|
25
|
+
|
|
26
|
+
if (!jwtSecret) {
|
|
27
|
+
throw new Error('@latanda/auth-middleware: jwtSecret is required');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return function authMiddleware(req, res, next) {
|
|
31
|
+
// Extract token from Authorization header
|
|
32
|
+
const authHeader = req.headers.authorization;
|
|
33
|
+
|
|
34
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
35
|
+
const error = { message: 'Missing or invalid Authorization header', code: 'NO_TOKEN' };
|
|
36
|
+
|
|
37
|
+
if (onUnauthorized) {
|
|
38
|
+
return onUnauthorized(req, res, error);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return res.status(401).json({
|
|
42
|
+
success: false,
|
|
43
|
+
error: 'Authentication required',
|
|
44
|
+
code: 'NO_TOKEN'
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
|
|
49
|
+
|
|
50
|
+
// Validate token
|
|
51
|
+
const validation = validateToken(token, jwtSecret, { issuer, audience });
|
|
52
|
+
|
|
53
|
+
if (!validation.valid) {
|
|
54
|
+
const error = { message: validation.error, code: 'INVALID_TOKEN' };
|
|
55
|
+
|
|
56
|
+
if (onUnauthorized) {
|
|
57
|
+
return onUnauthorized(req, res, error);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return res.status(401).json({
|
|
61
|
+
success: false,
|
|
62
|
+
error: validation.error,
|
|
63
|
+
code: 'INVALID_TOKEN',
|
|
64
|
+
expired: validation.expired || false
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Attach user data to request object
|
|
69
|
+
req.user = {
|
|
70
|
+
id: validation.user_id,
|
|
71
|
+
email: validation.email,
|
|
72
|
+
role: validation.role,
|
|
73
|
+
permissions: validation.permissions
|
|
74
|
+
};
|
|
75
|
+
req.token = token;
|
|
76
|
+
|
|
77
|
+
next();
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Middleware to require specific permission
|
|
83
|
+
* Must be used AFTER authMiddleware
|
|
84
|
+
* @param {string|string[]} requiredPermissions - Permission(s) required
|
|
85
|
+
* @param {Object} options - Options
|
|
86
|
+
* @param {boolean} [options.requireAll=false] - Require all permissions vs any
|
|
87
|
+
* @returns {Function} Express middleware
|
|
88
|
+
*/
|
|
89
|
+
function requirePermission(requiredPermissions, options = {}) {
|
|
90
|
+
const { requireAll = false } = options;
|
|
91
|
+
const permissions = Array.isArray(requiredPermissions) ? requiredPermissions : [requiredPermissions];
|
|
92
|
+
|
|
93
|
+
return function permissionMiddleware(req, res, next) {
|
|
94
|
+
if (!req.user) {
|
|
95
|
+
return res.status(401).json({
|
|
96
|
+
success: false,
|
|
97
|
+
error: 'Authentication required before permission check',
|
|
98
|
+
code: 'NO_AUTH'
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const userRole = req.user.role;
|
|
103
|
+
|
|
104
|
+
// Check permissions
|
|
105
|
+
const hasAccess = requireAll
|
|
106
|
+
? permissions.every(perm => hasPermission(userRole, perm))
|
|
107
|
+
: permissions.some(perm => hasPermission(userRole, perm));
|
|
108
|
+
|
|
109
|
+
if (!hasAccess) {
|
|
110
|
+
return res.status(403).json({
|
|
111
|
+
success: false,
|
|
112
|
+
error: 'Insufficient permissions',
|
|
113
|
+
code: 'FORBIDDEN',
|
|
114
|
+
required: permissions,
|
|
115
|
+
userRole
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
next();
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Middleware to require specific role level or higher
|
|
125
|
+
* Must be used AFTER authMiddleware
|
|
126
|
+
* @param {string} minimumRole - Minimum role required (ADMIN, MIT, IT, USER)
|
|
127
|
+
* @returns {Function} Express middleware
|
|
128
|
+
*/
|
|
129
|
+
function requireRole(minimumRole) {
|
|
130
|
+
if (!isValidRole(minimumRole)) {
|
|
131
|
+
throw new Error(`Invalid role: ${minimumRole}. Must be ADMIN, MIT, IT, or USER`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return function roleMiddleware(req, res, next) {
|
|
135
|
+
if (!req.user) {
|
|
136
|
+
return res.status(401).json({
|
|
137
|
+
success: false,
|
|
138
|
+
error: 'Authentication required before role check',
|
|
139
|
+
code: 'NO_AUTH'
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const userRole = req.user.role;
|
|
144
|
+
|
|
145
|
+
if (!hasRoleLevel(userRole, minimumRole)) {
|
|
146
|
+
return res.status(403).json({
|
|
147
|
+
success: false,
|
|
148
|
+
error: `Requires ${minimumRole} role or higher`,
|
|
149
|
+
code: 'INSUFFICIENT_ROLE',
|
|
150
|
+
required: minimumRole,
|
|
151
|
+
current: userRole
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
next();
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Middleware to enforce resource ownership
|
|
161
|
+
* Ensures user can only access their own resources (unless ADMIN)
|
|
162
|
+
* Must be used AFTER authMiddleware
|
|
163
|
+
* @param {Function} getResourceOwnerId - Function to extract owner ID from request
|
|
164
|
+
* @returns {Function} Express middleware
|
|
165
|
+
*/
|
|
166
|
+
function requireOwnership(getResourceOwnerId) {
|
|
167
|
+
return async function ownershipMiddleware(req, res, next) {
|
|
168
|
+
if (!req.user) {
|
|
169
|
+
return res.status(401).json({
|
|
170
|
+
success: false,
|
|
171
|
+
error: 'Authentication required',
|
|
172
|
+
code: 'NO_AUTH'
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ADMIN bypasses ownership checks
|
|
177
|
+
if (req.user.role === 'ADMIN') {
|
|
178
|
+
return next();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
// Get resource owner ID
|
|
183
|
+
const resourceOwnerId = typeof getResourceOwnerId === 'function'
|
|
184
|
+
? await getResourceOwnerId(req)
|
|
185
|
+
: getResourceOwnerId;
|
|
186
|
+
|
|
187
|
+
if (req.user.id !== resourceOwnerId) {
|
|
188
|
+
return res.status(403).json({
|
|
189
|
+
success: false,
|
|
190
|
+
error: 'You can only access your own resources',
|
|
191
|
+
code: 'NOT_OWNER'
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
next();
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return res.status(500).json({
|
|
198
|
+
success: false,
|
|
199
|
+
error: 'Failed to verify resource ownership',
|
|
200
|
+
code: 'OWNERSHIP_CHECK_FAILED'
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Optional authentication middleware
|
|
208
|
+
* Validates token if present, but doesn't require it
|
|
209
|
+
* Useful for endpoints that work with or without auth
|
|
210
|
+
*/
|
|
211
|
+
function optionalAuth(config) {
|
|
212
|
+
const {
|
|
213
|
+
jwtSecret,
|
|
214
|
+
issuer = 'latanda.online',
|
|
215
|
+
audience = 'latanda-web-app'
|
|
216
|
+
} = config;
|
|
217
|
+
|
|
218
|
+
if (!jwtSecret) {
|
|
219
|
+
throw new Error('@latanda/auth-middleware: jwtSecret is required');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return function optionalAuthMiddleware(req, res, next) {
|
|
223
|
+
const authHeader = req.headers.authorization;
|
|
224
|
+
|
|
225
|
+
// No token provided - continue without user
|
|
226
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
227
|
+
req.user = null;
|
|
228
|
+
return next();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const token = authHeader.substring(7);
|
|
232
|
+
const validation = validateToken(token, jwtSecret, { issuer, audience });
|
|
233
|
+
|
|
234
|
+
if (validation.valid) {
|
|
235
|
+
req.user = {
|
|
236
|
+
id: validation.user_id,
|
|
237
|
+
email: validation.email,
|
|
238
|
+
role: validation.role,
|
|
239
|
+
permissions: validation.permissions
|
|
240
|
+
};
|
|
241
|
+
req.token = token;
|
|
242
|
+
} else {
|
|
243
|
+
req.user = null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
next();
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
createAuthMiddleware,
|
|
252
|
+
requirePermission,
|
|
253
|
+
requireRole,
|
|
254
|
+
requireOwnership,
|
|
255
|
+
optionalAuth
|
|
256
|
+
};
|