Implemented redis session storage.
All checks were successful
farmcontrol/farmcontrol-ws/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-ws/pipeline/head This commit looks good
This commit is contained in:
parent
840aa0781b
commit
289673813a
19
config.json
19
config.json
@ -17,7 +17,11 @@
|
||||
"mongo": {
|
||||
"url": "mongodb://127.0.0.1:27017/farmcontrol"
|
||||
},
|
||||
"redis": { "host": "localhost", "port": 6379, "password": "" }
|
||||
"redis": {
|
||||
"host": "localhost",
|
||||
"port": 6379,
|
||||
"password": ""
|
||||
}
|
||||
},
|
||||
"otpExpiryMins": 0.5
|
||||
},
|
||||
@ -39,7 +43,11 @@
|
||||
"mongo": {
|
||||
"url": "mongodb://127.0.0.1:27017/farmcontrol-test"
|
||||
},
|
||||
"redis": { "host": "localhost", "port": 6379, "password": "" }
|
||||
"redis": {
|
||||
"host": "localhost",
|
||||
"port": 6379,
|
||||
"password": ""
|
||||
}
|
||||
},
|
||||
"otpExpiryMins": 0.5
|
||||
},
|
||||
@ -60,7 +68,12 @@
|
||||
"database": {
|
||||
"mongo": {
|
||||
"url": "mongodb://localhost:27017/farmcontrol"
|
||||
},
|
||||
"redis": {
|
||||
"host": "localhost",
|
||||
"port": 6379,
|
||||
"password": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
133
src/auth/auth.js
133
src/auth/auth.js
@ -1,9 +1,7 @@
|
||||
// auth.js - Keycloak authentication handler
|
||||
import axios from 'axios';
|
||||
import jwt from 'jsonwebtoken';
|
||||
// auth.js - Redis session authentication (shared with API)
|
||||
import log4js from 'log4js';
|
||||
// Load configuration
|
||||
import { loadConfig } from '../config.js';
|
||||
import { redisServer } from '../database/redis.js';
|
||||
import { editObject, getObject, listObjects } from '../database/database.js';
|
||||
import { hostModel } from '../database/schemas/management/host.schema.js';
|
||||
import { userModel } from '../database/schemas/management/user.schema.js';
|
||||
@ -14,123 +12,69 @@ const config = loadConfig();
|
||||
const logger = log4js.getLogger('Auth');
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
export class KeycloakAuth {
|
||||
const SESSION_KEY_PREFIX = 'session:';
|
||||
|
||||
/**
|
||||
* SessionAuth - validates tokens by looking up Redis session (created by API after Keycloak login).
|
||||
* Same session key format as API: session:{sessionToken}
|
||||
*/
|
||||
export class SessionAuth {
|
||||
constructor() {
|
||||
this.config = config.auth;
|
||||
this.tokenCache = new Map(); // Cache for verified tokens
|
||||
this.tokenCache = new Map();
|
||||
}
|
||||
|
||||
// Verify a token with Keycloak server
|
||||
async verifyToken(token) {
|
||||
// Check cache first
|
||||
if (!token) return { valid: false };
|
||||
|
||||
if (this.tokenCache.has(token)) {
|
||||
const cachedInfo = this.tokenCache.get(token);
|
||||
if (cachedInfo.expiresAt > Date.now()) {
|
||||
return { valid: true, user: cachedInfo.user };
|
||||
} else {
|
||||
// Token expired, remove from cache
|
||||
this.tokenCache.delete(token);
|
||||
const cached = this.tokenCache.get(token);
|
||||
if (cached.expiresAt > Date.now()) {
|
||||
return { valid: true, user: cached.user };
|
||||
}
|
||||
this.tokenCache.delete(token);
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify token with Keycloak introspection endpoint
|
||||
const response = await axios.post(
|
||||
`${this.config.keycloak.url}/realms/${this.config.keycloak.realm}/protocol/openid-connect/token/introspect`,
|
||||
new URLSearchParams({
|
||||
token,
|
||||
client_id: this.config.keycloak.clientId,
|
||||
client_secret: this.config.keycloak.clientSecret
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
);
|
||||
const key = SESSION_KEY_PREFIX + token;
|
||||
const session = await redisServer.getKey(key);
|
||||
|
||||
const introspection = response.data;
|
||||
|
||||
if (!introspection.active) {
|
||||
logger.info('Token is not active');
|
||||
if (!session || !session.user) {
|
||||
logger.info('Session not found or invalid');
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
if (session.expiresAt && session.expiresAt <= Date.now()) {
|
||||
logger.info('Session expired');
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
// Verify required roles if configured
|
||||
if (this.config.requiredRoles && this.config.requiredRoles.length > 0) {
|
||||
const hasRequiredRole = this.checkRoles(
|
||||
introspection,
|
||||
this.config.requiredRoles
|
||||
);
|
||||
if (!hasRequiredRole) {
|
||||
const roles = session.user?.roles || [];
|
||||
const hasRole = this.config.requiredRoles.some((r) => roles.includes(r));
|
||||
if (!hasRole) {
|
||||
logger.info("User doesn't have required roles");
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Parse token to extract user info
|
||||
const decodedToken = jwt.decode(token);
|
||||
const decodedUser = {
|
||||
id: decodedToken.sub,
|
||||
username: decodedToken.preferred_username,
|
||||
email: decodedToken.email,
|
||||
name: decodedToken.name,
|
||||
roles: this.extractRoles(decodedToken)
|
||||
};
|
||||
|
||||
const user = await listObjects({
|
||||
model: userModel,
|
||||
filter: { username: decodedUser.username }
|
||||
this.tokenCache.set(token, {
|
||||
expiresAt: session.expiresAt,
|
||||
user: session.user
|
||||
});
|
||||
|
||||
// Cache the verified token
|
||||
const expiresAt = introspection.exp * 1000; // Convert to milliseconds
|
||||
this.tokenCache.set(token, { expiresAt, user: user[0] });
|
||||
|
||||
return { valid: true, user: user[0] };
|
||||
return { valid: true, user: session.user };
|
||||
} catch (error) {
|
||||
logger.error('Token verification error:', error.message);
|
||||
logger.error('Session verification error:', error.message);
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Extract roles from token
|
||||
extractRoles(token) {
|
||||
const roles = [];
|
||||
|
||||
// Extract realm roles
|
||||
if (token.realm_access && token.realm_access.roles) {
|
||||
roles.push(...token.realm_access.roles);
|
||||
}
|
||||
|
||||
// Extract client roles
|
||||
if (token.resource_access) {
|
||||
for (const client in token.resource_access) {
|
||||
if (token.resource_access[client].roles) {
|
||||
roles.push(
|
||||
...token.resource_access[client].roles.map(
|
||||
role => `${client}:${role}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
// Check if user has required roles
|
||||
checkRoles(tokenInfo, requiredRoles) {
|
||||
// Extract roles from token
|
||||
const userRoles = this.extractRoles(tokenInfo);
|
||||
|
||||
// Check if user has any of the required roles
|
||||
return requiredRoles.some(role => userRoles.includes(role));
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use SessionAuth - kept for backward compatibility */
|
||||
export class KeycloakAuth extends SessionAuth {}
|
||||
|
||||
export class CodeAuth {
|
||||
// Verify a code with the database
|
||||
async verifyCode(id, authCode) {
|
||||
try {
|
||||
logger.trace('Verifying code:', { id, authCode });
|
||||
@ -212,12 +156,9 @@ export class CodeAuth {
|
||||
}
|
||||
}
|
||||
|
||||
// Socket.IO middleware for authentication
|
||||
export function createAuthMiddleware(socketUser) {
|
||||
return async (packet, next) => {
|
||||
const [event] = packet; // event name is always first element
|
||||
|
||||
// Allow the 'authenticate' event through without checks
|
||||
const [event] = packet;
|
||||
|
||||
if (event === 'authenticate') {
|
||||
next();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user