Implemented redis session storage.
All checks were successful
farmcontrol/farmcontrol-ws/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-03-01 17:06:17 +00:00
parent 840aa0781b
commit 289673813a
2 changed files with 53 additions and 99 deletions

View File

@ -17,7 +17,11 @@
"mongo": { "mongo": {
"url": "mongodb://127.0.0.1:27017/farmcontrol" "url": "mongodb://127.0.0.1:27017/farmcontrol"
}, },
"redis": { "host": "localhost", "port": 6379, "password": "" } "redis": {
"host": "localhost",
"port": 6379,
"password": ""
}
}, },
"otpExpiryMins": 0.5 "otpExpiryMins": 0.5
}, },
@ -39,7 +43,11 @@
"mongo": { "mongo": {
"url": "mongodb://127.0.0.1:27017/farmcontrol-test" "url": "mongodb://127.0.0.1:27017/farmcontrol-test"
}, },
"redis": { "host": "localhost", "port": 6379, "password": "" } "redis": {
"host": "localhost",
"port": 6379,
"password": ""
}
}, },
"otpExpiryMins": 0.5 "otpExpiryMins": 0.5
}, },
@ -60,7 +68,12 @@
"database": { "database": {
"mongo": { "mongo": {
"url": "mongodb://localhost:27017/farmcontrol" "url": "mongodb://localhost:27017/farmcontrol"
},
"redis": {
"host": "localhost",
"port": 6379,
"password": ""
} }
} }
} }
} }

View File

@ -1,9 +1,7 @@
// auth.js - Keycloak authentication handler // auth.js - Redis session authentication (shared with API)
import axios from 'axios';
import jwt from 'jsonwebtoken';
import log4js from 'log4js'; import log4js from 'log4js';
// Load configuration
import { loadConfig } from '../config.js'; import { loadConfig } from '../config.js';
import { redisServer } from '../database/redis.js';
import { editObject, getObject, listObjects } from '../database/database.js'; import { editObject, getObject, listObjects } from '../database/database.js';
import { hostModel } from '../database/schemas/management/host.schema.js'; import { hostModel } from '../database/schemas/management/host.schema.js';
import { userModel } from '../database/schemas/management/user.schema.js'; import { userModel } from '../database/schemas/management/user.schema.js';
@ -14,123 +12,69 @@ const config = loadConfig();
const logger = log4js.getLogger('Auth'); const logger = log4js.getLogger('Auth');
logger.level = config.server.logLevel; 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() { constructor() {
this.config = config.auth; 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) { async verifyToken(token) {
// Check cache first if (!token) return { valid: false };
if (this.tokenCache.has(token)) { if (this.tokenCache.has(token)) {
const cachedInfo = this.tokenCache.get(token); const cached = this.tokenCache.get(token);
if (cachedInfo.expiresAt > Date.now()) { if (cached.expiresAt > Date.now()) {
return { valid: true, user: cachedInfo.user }; return { valid: true, user: cached.user };
} else {
// Token expired, remove from cache
this.tokenCache.delete(token);
} }
this.tokenCache.delete(token);
} }
try { try {
// Verify token with Keycloak introspection endpoint const key = SESSION_KEY_PREFIX + token;
const response = await axios.post( const session = await redisServer.getKey(key);
`${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 introspection = response.data; if (!session || !session.user) {
logger.info('Session not found or invalid');
if (!introspection.active) { return { valid: false };
logger.info('Token is not active'); }
if (session.expiresAt && session.expiresAt <= Date.now()) {
logger.info('Session expired');
return { valid: false }; return { valid: false };
} }
// Verify required roles if configured
if (this.config.requiredRoles && this.config.requiredRoles.length > 0) { if (this.config.requiredRoles && this.config.requiredRoles.length > 0) {
const hasRequiredRole = this.checkRoles( const roles = session.user?.roles || [];
introspection, const hasRole = this.config.requiredRoles.some((r) => roles.includes(r));
this.config.requiredRoles if (!hasRole) {
);
if (!hasRequiredRole) {
logger.info("User doesn't have required roles"); logger.info("User doesn't have required roles");
return { valid: false }; return { valid: false };
} }
} }
// Parse token to extract user info this.tokenCache.set(token, {
const decodedToken = jwt.decode(token); expiresAt: session.expiresAt,
const decodedUser = { user: session.user
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 }
}); });
// Cache the verified token return { valid: true, user: session.user };
const expiresAt = introspection.exp * 1000; // Convert to milliseconds
this.tokenCache.set(token, { expiresAt, user: user[0] });
return { valid: true, user: user[0] };
} catch (error) { } catch (error) {
logger.error('Token verification error:', error.message); logger.error('Session verification error:', error.message);
return { valid: false }; 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 { export class CodeAuth {
// Verify a code with the database
async verifyCode(id, authCode) { async verifyCode(id, authCode) {
try { try {
logger.trace('Verifying code:', { id, authCode }); logger.trace('Verifying code:', { id, authCode });
@ -212,12 +156,9 @@ export class CodeAuth {
} }
} }
// Socket.IO middleware for authentication
export function createAuthMiddleware(socketUser) { export function createAuthMiddleware(socketUser) {
return async (packet, next) => { return async (packet, next) => {
const [event] = packet; // event name is always first element const [event] = packet;
// Allow the 'authenticate' event through without checks
if (event === 'authenticate') { if (event === 'authenticate') {
next(); next();