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
17
config.json
17
config.json
@ -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,6 +68,11 @@
|
|||||||
"database": {
|
"database": {
|
||||||
"mongo": {
|
"mongo": {
|
||||||
"url": "mongodb://localhost:27017/farmcontrol"
|
"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
|
// 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();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user