Implemented redis session storage.
This commit is contained in:
parent
78509ed3a2
commit
6e3b900423
@ -2,7 +2,6 @@ import express from 'express';
|
|||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import config from './config.js';
|
import config from './config.js';
|
||||||
import { expressSession, keycloak } from './keycloak.js';
|
|
||||||
import { dbConnect } from './database/mongo.js';
|
import { dbConnect } from './database/mongo.js';
|
||||||
import {
|
import {
|
||||||
authRoutes,
|
authRoutes,
|
||||||
@ -105,8 +104,6 @@ async function initializeApp() {
|
|||||||
app.use(cors(corsOptions));
|
app.use(cors(corsOptions));
|
||||||
app.use(bodyParser.json({ type: 'application/json', strict: false, limit: '50mb' }));
|
app.use(bodyParser.json({ type: 'application/json', strict: false, limit: '50mb' }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(expressSession);
|
|
||||||
app.use(keycloak.middleware());
|
|
||||||
app.use(populateUserMiddleware);
|
app.use(populateUserMiddleware);
|
||||||
|
|
||||||
app.get('/', function (req, res) {
|
app.get('/', function (req, res) {
|
||||||
|
|||||||
123
src/keycloak.js
123
src/keycloak.js
@ -1,21 +1,20 @@
|
|||||||
import Keycloak from 'keycloak-connect';
|
/**
|
||||||
import session from 'express-session';
|
* Authentication middleware - uses Redis session store.
|
||||||
|
* Keycloak is used only for login/refresh; session validation is done via Redis.
|
||||||
|
*/
|
||||||
import config, { getEnvironment } from './config.js';
|
import config, { getEnvironment } from './config.js';
|
||||||
import axios from 'axios';
|
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
import log4js from 'log4js';
|
import log4js from 'log4js';
|
||||||
import NodeCache from 'node-cache';
|
import NodeCache from 'node-cache';
|
||||||
import { userModel } from './database/schemas/management/user.schema.js';
|
import { userModel } from './database/schemas/management/user.schema.js';
|
||||||
import { getObject } from './database/database.js';
|
import { getObject } from './database/database.js';
|
||||||
import { hostModel } from './database/schemas/management/host.schema.js';
|
import { hostModel } from './database/schemas/management/host.schema.js';
|
||||||
|
import { getSession } from './services/misc/auth.js';
|
||||||
|
|
||||||
const logger = log4js.getLogger('Keycloak');
|
const logger = log4js.getLogger('Keycloak');
|
||||||
logger.level = config.server.logLevel || 'info';
|
logger.level = config.server.logLevel || 'info';
|
||||||
|
|
||||||
// Initialize NodeCache with 5-minute TTL
|
const userCache = new NodeCache({ stdTTL: 300 });
|
||||||
const userCache = new NodeCache({ stdTTL: 300 }); // 300 seconds = 5 minutes
|
|
||||||
|
|
||||||
// Cache event listeners for monitoring
|
|
||||||
userCache.on('expired', (key, value) => {
|
userCache.on('expired', (key, value) => {
|
||||||
logger.debug(`Cache entry expired: ${key}`);
|
logger.debug(`Cache entry expired: ${key}`);
|
||||||
});
|
});
|
||||||
@ -24,22 +23,18 @@ userCache.on('flush', () => {
|
|||||||
logger.info('Cache flushed');
|
logger.info('Cache flushed');
|
||||||
});
|
});
|
||||||
|
|
||||||
// User lookup function with caching
|
|
||||||
const lookupUser = async (preferredUsername) => {
|
const lookupUser = async (preferredUsername) => {
|
||||||
try {
|
try {
|
||||||
// Check cache first
|
|
||||||
const cachedUser = userCache.get(preferredUsername);
|
const cachedUser = userCache.get(preferredUsername);
|
||||||
if (cachedUser) {
|
if (cachedUser) {
|
||||||
logger.debug(`User found in cache: ${preferredUsername}`);
|
logger.debug(`User found in cache: ${preferredUsername}`);
|
||||||
return cachedUser;
|
return cachedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in cache, query database
|
|
||||||
logger.debug(`User not in cache, querying database: ${preferredUsername}`);
|
logger.debug(`User not in cache, querying database: ${preferredUsername}`);
|
||||||
const user = await userModel.findOne({ username: preferredUsername });
|
const user = await userModel.findOne({ username: preferredUsername });
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
// Store in cache
|
|
||||||
userCache.set(preferredUsername, user);
|
userCache.set(preferredUsername, user);
|
||||||
logger.debug(`User stored in cache: ${preferredUsername}`);
|
logger.debug(`User stored in cache: ${preferredUsername}`);
|
||||||
return user;
|
return user;
|
||||||
@ -53,71 +48,24 @@ const lookupUser = async (preferredUsername) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize Keycloak
|
/**
|
||||||
const keycloakConfig = {
|
* Middleware to check if the user is authenticated.
|
||||||
realm: config.auth.keycloak.realm,
|
* Supports: 1) Bearer token (Redis session), 2) x-host-id + x-auth-code (host auth)
|
||||||
'auth-server-url': config.auth.keycloak.url,
|
*/
|
||||||
'ssl-required': getEnvironment() === 'production' ? 'external' : 'none',
|
|
||||||
resource: config.auth.keycloak.clientId,
|
|
||||||
'confidential-port': 0,
|
|
||||||
'bearer-only': true,
|
|
||||||
'public-client': false,
|
|
||||||
'use-resource-role-mappings': true,
|
|
||||||
'verify-token-audience': true,
|
|
||||||
credentials: {
|
|
||||||
secret: config.auth.keycloak.clientSecret,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const memoryStore = new session.MemoryStore();
|
|
||||||
|
|
||||||
var expressSession = session({
|
|
||||||
secret: config.auth.sessionSecret,
|
|
||||||
resave: false,
|
|
||||||
saveUninitialized: true, // Set this to true to ensure session is initialized
|
|
||||||
store: memoryStore,
|
|
||||||
cookie: {
|
|
||||||
maxAge: 1800000, // 30 minutes
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
var keycloak = new Keycloak({ store: memoryStore }, keycloakConfig);
|
|
||||||
|
|
||||||
// Custom middleware to check if the user is authenticated
|
|
||||||
const isAuthenticated = async (req, res, next) => {
|
const isAuthenticated = async (req, res, next) => {
|
||||||
let token = null;
|
|
||||||
|
|
||||||
const authHeader = req.headers.authorization || req.headers.Authorization;
|
const authHeader = req.headers.authorization || req.headers.Authorization;
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify token with Keycloak introspection endpoint
|
const session = await getSession(token);
|
||||||
const response = await axios.post(
|
if (session && session.expiresAt > Date.now()) {
|
||||||
`${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token/introspect`,
|
req.user = session.user;
|
||||||
new URLSearchParams({
|
req.session = session;
|
||||||
token: token,
|
return next();
|
||||||
client_id: config.auth.keycloak.clientId,
|
|
||||||
client_secret: config.auth.keycloak.clientSecret,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const introspection = response.data;
|
|
||||||
if (!introspection.active) {
|
|
||||||
logger.info('Token is not active');
|
|
||||||
logger.debug('Token:', token);
|
|
||||||
return res.status(401).json({ error: 'Session Inactive', code: 'UNAUTHORIZED' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return next();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Token verification error:', error.message);
|
logger.error('Session lookup error:', error.message);
|
||||||
return res.status(401).json({ error: 'Verification Error', code: 'UNAUTHORIZED' });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,17 +73,7 @@ const isAuthenticated = async (req, res, next) => {
|
|||||||
const authCode = req.headers['x-auth-code'];
|
const authCode = req.headers['x-auth-code'];
|
||||||
if (hostId && authCode) {
|
if (hostId && authCode) {
|
||||||
const host = await getObject({ model: hostModel, id: hostId });
|
const host = await getObject({ model: hostModel, id: hostId });
|
||||||
if (host && host.authCode == authCode) {
|
if (host && host.authCode === authCode) {
|
||||||
return next();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to session-based authentication
|
|
||||||
if (req.session && req.session['keycloak-token']) {
|
|
||||||
const sessionToken = req.session['keycloak-token'];
|
|
||||||
if (sessionToken.expires_at > new Date().getTime()) {
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -143,28 +81,6 @@ const isAuthenticated = async (req, res, next) => {
|
|||||||
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
|
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to extract roles from token
|
|
||||||
function 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache management utility functions
|
|
||||||
const clearUserCache = () => {
|
const clearUserCache = () => {
|
||||||
userCache.flushAll();
|
userCache.flushAll();
|
||||||
logger.info('User cache cleared');
|
logger.info('User cache cleared');
|
||||||
@ -180,11 +96,10 @@ const removeUserFromCache = (username) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
keycloak,
|
|
||||||
expressSession,
|
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
lookupUser,
|
lookupUser,
|
||||||
clearUserCache,
|
clearUserCache,
|
||||||
getUserCacheStats,
|
getUserCacheStats,
|
||||||
removeUserFromCache,
|
removeUserFromCache,
|
||||||
|
getEnvironment,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import config from '../../config.js';
|
import config from '../../config.js';
|
||||||
import { keycloak } from '../../keycloak.js';
|
|
||||||
import log4js from 'log4js';
|
import log4js from 'log4js';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { userModel } from '../../database/schemas/management/user.schema.js';
|
import { userModel } from '../../database/schemas/management/user.schema.js';
|
||||||
@ -8,14 +7,19 @@ import { resolve } from 'path';
|
|||||||
import NodeCache from 'node-cache';
|
import NodeCache from 'node-cache';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { getAndConsumeEmailRenderTokenData } from './emailRenderAuth.js';
|
import { getAndConsumeEmailRenderTokenData } from './emailRenderAuth.js';
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
getSession,
|
||||||
|
updateSessionKeycloakTokens,
|
||||||
|
deleteSession,
|
||||||
|
} from './sessionStore.js';
|
||||||
|
|
||||||
const logger = log4js.getLogger('Auth');
|
const logger = log4js.getLogger('Auth');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
// Initialize NodeCache with 5-minute TTL for token-based user lookup
|
// Initialize NodeCache with 5-minute TTL for token-based user lookup (email render tokens)
|
||||||
const tokenUserCache = new NodeCache({ stdTTL: 300 }); // 300 seconds = 5 minutes
|
const tokenUserCache = new NodeCache({ stdTTL: 300 });
|
||||||
|
|
||||||
// Cache event listeners for monitoring
|
|
||||||
tokenUserCache.on('expired', (key, value) => {
|
tokenUserCache.on('expired', (key, value) => {
|
||||||
logger.debug(`Token user cache entry expired: ${key.substring(0, 20)}...`);
|
logger.debug(`Token user cache entry expired: ${key.substring(0, 20)}...`);
|
||||||
});
|
});
|
||||||
@ -26,32 +30,24 @@ tokenUserCache.on('flush', () => {
|
|||||||
|
|
||||||
const loginTokenRequests = new Map();
|
const loginTokenRequests = new Map();
|
||||||
|
|
||||||
// Token-based user lookup function with caching
|
// Lookup user by email-render JWT token (short-lived, for Puppeteer)
|
||||||
const lookupUserByToken = async (token) => {
|
const lookupUserByEmailRenderToken = async (token) => {
|
||||||
try {
|
try {
|
||||||
// Check cache first
|
|
||||||
const cachedUser = tokenUserCache.get(token);
|
const cachedUser = tokenUserCache.get(token);
|
||||||
if (cachedUser) {
|
if (cachedUser) {
|
||||||
logger.trace(`User found in token cache for token: ${token.substring(0, 20)}...`);
|
logger.trace(`User found in token cache for token: ${token.substring(0, 20)}...`);
|
||||||
return cachedUser;
|
return cachedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in cache, decode token and lookup user
|
|
||||||
logger.trace(`User not in token cache, decoding token: ${token.substring(0, 20)}...`);
|
|
||||||
const decodedToken = jwt.decode(token);
|
const decodedToken = jwt.decode(token);
|
||||||
|
|
||||||
if (!decodedToken || !decodedToken.preferred_username) {
|
if (!decodedToken || !decodedToken.preferred_username) {
|
||||||
logger.trace('Invalid token or missing preferred_username');
|
logger.trace('Invalid token or missing preferred_username');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query database for user
|
|
||||||
const user = await userModel.findOne({ username: decodedToken.preferred_username });
|
const user = await userModel.findOne({ username: decodedToken.preferred_username });
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
// Store in cache using token as key
|
|
||||||
tokenUserCache.set(token, user);
|
tokenUserCache.set(token, user);
|
||||||
logger.trace(`User stored in token cache for token: ${token.substring(0, 20)}...`);
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,7 +59,6 @@ const lookupUserByToken = async (token) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cache management utility functions
|
|
||||||
const clearTokenUserCache = () => {
|
const clearTokenUserCache = () => {
|
||||||
tokenUserCache.flushAll();
|
tokenUserCache.flushAll();
|
||||||
logger.info('Token user cache cleared');
|
logger.info('Token user cache cleared');
|
||||||
@ -78,66 +73,53 @@ const removeUserFromTokenCache = (token) => {
|
|||||||
logger.debug(`User removed from token cache for token: ${token.substring(0, 20)}...`);
|
logger.debug(`User removed from token cache for token: ${token.substring(0, 20)}...`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Login handler
|
// Login handler - redirect to Keycloak
|
||||||
export const loginRouteHandler = (req, res, redirectType = 'web') => {
|
export const loginRouteHandler = (req, res, redirectType = 'web') => {
|
||||||
// Get the redirect URL from form data or default to production overview
|
|
||||||
const redirectUrl = req.query.redirect_uri || '/production/overview';
|
const redirectUrl = req.query.redirect_uri || '/production/overview';
|
||||||
|
|
||||||
// Store the original URL to redirect after login
|
|
||||||
const authUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/auth`;
|
const authUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/auth`;
|
||||||
const callBackState = `/auth/${redirectType}/callback`;
|
const callBackState = `/auth/${redirectType}/callback`;
|
||||||
const callbackUrl = `${config.app.urlApi}${callBackState}`;
|
const callbackUrl = `${config.app.urlApi}${callBackState}`;
|
||||||
const state = encodeURIComponent(redirectUrl);
|
const state = encodeURIComponent(redirectUrl);
|
||||||
|
|
||||||
logger.warn(req.query.redirect_uri);
|
|
||||||
|
|
||||||
res.redirect(
|
res.redirect(
|
||||||
`${authUrl}?client_id=${config.auth.keycloak.clientId}&redirect_uri=${callbackUrl}&response_type=code&scope=openid&state=${state}`
|
`${authUrl}?client_id=${config.auth.keycloak.clientId}&redirect_uri=${callbackUrl}&response_type=code&scope=openid&state=${state}`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to fetch user from Keycloak and store in database and session
|
// Fetch user from Keycloak and create/update in database
|
||||||
const fetchAndStoreUser = async (req, token) => {
|
const fetchAndStoreUser = async (keycloakTokenData) => {
|
||||||
const userInfoUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/userinfo`;
|
const userInfoUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/userinfo`;
|
||||||
|
|
||||||
try {
|
const response = await axios.post(
|
||||||
const response = await axios.post(
|
userInfoUrl,
|
||||||
userInfoUrl,
|
new URLSearchParams({
|
||||||
new URLSearchParams({
|
client_id: config.auth.keycloak.clientId,
|
||||||
client_id: config.auth.keycloak.clientId,
|
client_secret: config.auth.keycloak.clientSecret,
|
||||||
client_secret: config.auth.keycloak.clientSecret,
|
}),
|
||||||
}),
|
{
|
||||||
{
|
headers: {
|
||||||
headers: {
|
Authorization: `Bearer ${keycloakTokenData.access_token}`,
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
},
|
||||||
},
|
}
|
||||||
}
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const userInfo = {
|
const decoded = jwt.decode(keycloakTokenData.access_token);
|
||||||
roles: token.realm_access?.roles || [],
|
const roles = decoded?.realm_access?.roles || [];
|
||||||
username: response.data.preferred_username,
|
|
||||||
email: response.data.email,
|
|
||||||
name: response.data.name,
|
|
||||||
firstName: response.data.given_name,
|
|
||||||
lastName: response.data.family_name,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create or update user in database
|
const userInfo = {
|
||||||
const user = await createOrUpdateUser(userInfo);
|
roles,
|
||||||
const fullUserInfo = { ...userInfo, _id: user._id };
|
username: response.data.preferred_username,
|
||||||
|
email: response.data.email,
|
||||||
|
name: response.data.name,
|
||||||
|
firstName: response.data.given_name,
|
||||||
|
lastName: response.data.family_name,
|
||||||
|
};
|
||||||
|
|
||||||
// Store user info in session
|
const user = await createOrUpdateUser(userInfo);
|
||||||
req.session.user = fullUserInfo;
|
return { ...userInfo, _id: user._id };
|
||||||
|
|
||||||
return fullUserInfo;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error fetching and storing user:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to exchange authorization code for tokens, fetch user, and set session
|
// Exchange auth code for tokens, create Redis session, return our session token to client
|
||||||
export const loginTokenRouteHandler = async (req, res, redirectType = 'web') => {
|
export const loginTokenRouteHandler = async (req, res, redirectType = 'web') => {
|
||||||
const code = req.query.code;
|
const code = req.query.code;
|
||||||
if (!code) {
|
if (!code) {
|
||||||
@ -152,13 +134,11 @@ export const loginTokenRouteHandler = async (req, res, redirectType = 'web') =>
|
|||||||
return res.status(200).json(emailRenderData);
|
return res.status(200).json(emailRenderData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a request for this code is already in progress, wait for it
|
|
||||||
if (loginTokenRequests.has(code)) {
|
if (loginTokenRequests.has(code)) {
|
||||||
const tokenData = await loginTokenRequests.get(code);
|
const tokenData = await loginTokenRequests.get(code);
|
||||||
return res.status(200).json(tokenData);
|
return res.status(200).json(tokenData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, start the request and store the promise
|
|
||||||
const tokenPromise = (async () => {
|
const tokenPromise = (async () => {
|
||||||
const callBackState = `/auth/${redirectType}/callback`;
|
const callBackState = `/auth/${redirectType}/callback`;
|
||||||
const callbackUrl = `${config.app.urlApi}${callBackState}`;
|
const callbackUrl = `${config.app.urlApi}${callBackState}`;
|
||||||
@ -179,35 +159,41 @@ export const loginTokenRouteHandler = async (req, res, redirectType = 'web') =>
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const tokenData = {
|
|
||||||
|
const keycloakTokenData = {
|
||||||
access_token: response.data.access_token,
|
access_token: response.data.access_token,
|
||||||
refresh_token: response.data.refresh_token,
|
refresh_token: response.data.refresh_token,
|
||||||
id_token: response.data.id_token,
|
id_token: response.data.id_token,
|
||||||
expires_at: new Date().getTime() + response.data.expires_in * 1000,
|
expires_at: new Date().getTime() + response.data.expires_in * 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
req.session['keycloak-token'] = tokenData;
|
const userData = await fetchAndStoreUser(keycloakTokenData);
|
||||||
// Fetch and store user data, set session
|
|
||||||
const userData = await fetchAndStoreUser(req, tokenData);
|
|
||||||
const userAndTokenData = { ...tokenData, ...userData };
|
|
||||||
|
|
||||||
return userAndTokenData;
|
// Create Redis session with our own token
|
||||||
|
const { sessionToken, expiresAt } = await createSession({
|
||||||
|
user: userData,
|
||||||
|
keycloakTokens: keycloakTokenData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return our session token to client (UI expects access_token, expires_at, user)
|
||||||
|
return {
|
||||||
|
access_token: sessionToken,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
...userData,
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
loginTokenRequests.set(code, tokenPromise);
|
loginTokenRequests.set(code, tokenPromise);
|
||||||
const userAndTokenData = await tokenPromise;
|
const userAndTokenData = await tokenPromise;
|
||||||
res.status(200).json(userAndTokenData);
|
res.status(200).json(userAndTokenData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
var error = err?.response?.data?.error_description || err.message;
|
const error = err?.response?.data?.error_description || err.message;
|
||||||
res.status(err?.status || 500).json({ error: error });
|
res.status(err?.response?.status || 500).json({ error: error });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Login callback handler
|
// Login callback - redirect to client with auth code
|
||||||
export const loginCallbackRouteHandler = async (req, res, redirectType = 'web') => {
|
export const loginCallbackRouteHandler = async (req, res, redirectType = 'web') => {
|
||||||
// Don't use keycloak.protect() here as it expects an already authenticated session
|
|
||||||
|
|
||||||
// Extract the code and state from the query parameters
|
|
||||||
const code = req.query.code;
|
const code = req.query.code;
|
||||||
const state = req.query.state || '/production/overview';
|
const state = req.query.state || '/production/overview';
|
||||||
|
|
||||||
@ -215,7 +201,7 @@ export const loginCallbackRouteHandler = async (req, res, redirectType = 'web')
|
|||||||
return res.status(400).send('Authorization code missing');
|
return res.status(400).send('Authorization code missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
var appUrl;
|
let appUrl;
|
||||||
switch (redirectType) {
|
switch (redirectType) {
|
||||||
case 'web':
|
case 'web':
|
||||||
appUrl = config.app.urlClient;
|
appUrl = config.app.urlClient;
|
||||||
@ -230,45 +216,37 @@ export const loginCallbackRouteHandler = async (req, res, redirectType = 'web')
|
|||||||
appUrl = config.app.urlClient;
|
appUrl = config.app.urlClient;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUriRaw = `${appUrl}${state}`;
|
const redirectUriRaw = `${appUrl}${state}`;
|
||||||
let redirectUri;
|
let redirectUri;
|
||||||
try {
|
try {
|
||||||
// Try to parse as a URL (works for http/https)
|
|
||||||
const url = new URL(redirectUriRaw);
|
const url = new URL(redirectUriRaw);
|
||||||
url.searchParams.set('authCode', code);
|
url.searchParams.set('authCode', code);
|
||||||
redirectUri = url.toString();
|
redirectUri = url.toString();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback for custom schemes (e.g., farmcontrol://app)
|
|
||||||
if (redirectUriRaw.includes('?')) {
|
if (redirectUriRaw.includes('?')) {
|
||||||
redirectUri = `${redirectUriRaw}&authCode=${encodeURIComponent(code)}`;
|
redirectUri = `${redirectUriRaw}&authCode=${encodeURIComponent(code)}`;
|
||||||
} else {
|
} else {
|
||||||
redirectUri = `${redirectUriRaw}?authCode=${encodeURIComponent(code)}`;
|
redirectUri = `${redirectUriRaw}?authCode=${encodeURIComponent(code)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Save session and redirect to the original URL
|
|
||||||
req.session.save(async () => {
|
if (redirectType === 'app-scheme') {
|
||||||
if (redirectType == 'app-scheme') {
|
const templatePath = resolve(process.cwd(), 'src/services/misc/applaunch.html');
|
||||||
// Read HTML template and inject redirectUri
|
let html = readFileSync(templatePath, 'utf8');
|
||||||
const templatePath = resolve(process.cwd(), 'src/services/misc/applaunch.html');
|
html = html.replace('__REDIRECT_URI__', redirectUri);
|
||||||
let html = readFileSync(templatePath, 'utf8');
|
res.send(html);
|
||||||
html = html.replace('__REDIRECT_URI__', redirectUri);
|
} else {
|
||||||
res.send(html);
|
res.redirect(redirectUri);
|
||||||
} else {
|
}
|
||||||
res.redirect(redirectUri);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to create or update user
|
|
||||||
const createOrUpdateUser = async (userInfo) => {
|
const createOrUpdateUser = async (userInfo) => {
|
||||||
try {
|
try {
|
||||||
const { username, email, name, firstName, lastName } = userInfo;
|
const { username, email, name, firstName, lastName } = userInfo;
|
||||||
|
|
||||||
// Find existing user by username
|
|
||||||
const existingUser = await userModel.findOne({ username });
|
const existingUser = await userModel.findOne({ username });
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
// Check if any values have changed
|
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
existingUser.email !== email ||
|
existingUser.email !== email ||
|
||||||
existingUser.name !== name ||
|
existingUser.name !== name ||
|
||||||
@ -276,35 +254,32 @@ const createOrUpdateUser = async (userInfo) => {
|
|||||||
existingUser.lastName !== lastName;
|
existingUser.lastName !== lastName;
|
||||||
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
// Update existing user only if there are changes
|
await userModel.updateOne(
|
||||||
const updateData = {
|
{ username },
|
||||||
email,
|
{
|
||||||
name,
|
$set: {
|
||||||
firstName,
|
email,
|
||||||
lastName,
|
name,
|
||||||
updatedAt: new Date(),
|
firstName,
|
||||||
};
|
lastName,
|
||||||
|
updatedAt: new Date(),
|
||||||
await userModel.updateOne({ username }, { $set: updateData });
|
},
|
||||||
|
}
|
||||||
// Fetch the updated user to return
|
);
|
||||||
return await userModel.findOne({ username });
|
return await userModel.findOne({ username });
|
||||||
}
|
}
|
||||||
|
|
||||||
return existingUser;
|
return existingUser;
|
||||||
} else {
|
|
||||||
// Create new user
|
|
||||||
const newUser = new userModel({
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
name,
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
});
|
|
||||||
|
|
||||||
await newUser.save();
|
|
||||||
return newUser;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newUser = new userModel({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
});
|
||||||
|
await newUser.save();
|
||||||
|
return newUser;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error creating/updating user:', error);
|
logger.error('Error creating/updating user:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@ -312,65 +287,96 @@ const createOrUpdateUser = async (userInfo) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const userRouteHandler = (req, res) => {
|
export const userRouteHandler = (req, res) => {
|
||||||
if (req.session && req.session.user) {
|
if (req.user) {
|
||||||
res.json(req.session.user);
|
const authHeader = req.headers.authorization || req.headers.Authorization;
|
||||||
} else {
|
const token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
|
||||||
res.status(401).json({ error: 'Not authenticated' });
|
return res.json({
|
||||||
|
access_token: token,
|
||||||
|
expires_at: req.session?.expiresAt,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
res.status(401).json({ error: 'Not authenticated' });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Logout handler
|
// Logout - delete session from Redis, redirect to Keycloak logout
|
||||||
export const logoutRouteHandler = (req, res) => {
|
export const logoutRouteHandler = async (req, res) => {
|
||||||
// Get the redirect URL from query or default to login page
|
|
||||||
const redirectUrl = req.query.redirect_uri || '/login';
|
const redirectUrl = req.query.redirect_uri || '/login';
|
||||||
|
|
||||||
// Destroy the session
|
const authHeader = req.headers.authorization || req.headers.Authorization;
|
||||||
req.session.destroy((err) => {
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
if (err) {
|
const token = authHeader.substring(7);
|
||||||
logger.error('Error destroying session:', err);
|
try {
|
||||||
return res.status(500).json({ error: 'Failed to logout' });
|
await deleteSession(token);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error deleting session:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoutUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/logout`;
|
||||||
|
const encodedRedirectUri = encodeURIComponent(`${config.app.urlClient}${redirectUrl}`);
|
||||||
|
|
||||||
|
res.redirect(
|
||||||
|
`${logoutUrl}?client_id=${config.auth.keycloak.clientId}&post_logout_redirect_uri=${encodedRedirectUri}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Middleware: require valid session token
|
||||||
|
export const validateTokenMiddleware = async (req, res, next) => {
|
||||||
|
const authHeader = req.headers.authorization || req.headers.Authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'Not authenticated', code: 'UNAUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
const session = await getSession(token);
|
||||||
|
if (!session) {
|
||||||
|
return res.status(401).json({ error: 'Session invalid or expired', code: 'UNAUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = session.user;
|
||||||
|
req.session = session;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Middleware: require specific role
|
||||||
|
export const hasRole = (role) => {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
const authHeader = req.headers.authorization || req.headers.Authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'Not authenticated', code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct the Keycloak logout URL with the redirect URI
|
const token = authHeader.substring(7);
|
||||||
const logoutUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/logout`;
|
const session = await getSession(token);
|
||||||
const encodedRedirectUri = encodeURIComponent(`${config.app.urlClient}${redirectUrl}`);
|
if (!session) {
|
||||||
|
return res.status(401).json({ error: 'Session invalid or expired', code: 'UNAUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect to Keycloak logout with the redirect URI
|
const roles = session.user?.roles || [];
|
||||||
res.redirect(
|
if (!roles.includes(role)) {
|
||||||
`${logoutUrl}?client_id=${config.auth.keycloak.clientId}&post_logout_redirect_uri=${encodedRedirectUri}`
|
return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
|
||||||
);
|
}
|
||||||
});
|
|
||||||
|
req.user = session.user;
|
||||||
|
req.session = session;
|
||||||
|
next();
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Token validation - protected route middleware
|
|
||||||
export const validateTokenMiddleware = keycloak.protect();
|
|
||||||
|
|
||||||
// Check if user has a specific role
|
|
||||||
export const hasRole = (role) => {
|
|
||||||
return keycloak.protect((token) => {
|
|
||||||
return token && token.hasRole(role);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get user info from the token
|
|
||||||
export const getUserInfoHandler = (req, res) => {
|
export const getUserInfoHandler = (req, res) => {
|
||||||
if (req.kauth && req.kauth.grant) {
|
if (req.user) {
|
||||||
const token = req.kauth.grant.access_token;
|
|
||||||
const userInfo = {
|
const userInfo = {
|
||||||
id: token.content.sub,
|
id: req.user._id,
|
||||||
email: token.content.email,
|
email: req.user.email,
|
||||||
name:
|
name: req.user.name || `${req.user.firstName || ''} ${req.user.lastName || ''}`.trim(),
|
||||||
token.content.name ||
|
roles: req.user.roles || [],
|
||||||
`${token.content.given_name || ''} ${token.content.family_name || ''}`.trim(),
|
|
||||||
roles: token.content.realm_access?.roles || [],
|
|
||||||
};
|
};
|
||||||
return res.json(userInfo);
|
return res.json(userInfo);
|
||||||
}
|
}
|
||||||
return res.status(401).json({ error: 'Not authenticated' });
|
res.status(401).json({ error: 'Not authenticated' });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register route - Since we're using Keycloak, registration should be handled there
|
|
||||||
// This endpoint will redirect to Keycloak's registration page
|
|
||||||
export const registerRouteHandler = (req, res) => {
|
export const registerRouteHandler = (req, res) => {
|
||||||
const registrationUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/registrations`;
|
const registrationUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/registrations`;
|
||||||
const redirectUri = encodeURIComponent(config.app.urlClient + '/auth/login');
|
const redirectUri = encodeURIComponent(config.app.urlClient + '/auth/login');
|
||||||
@ -380,8 +386,7 @@ export const registerRouteHandler = (req, res) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Forgot password handler - redirect to Keycloak's reset password page
|
export const forgotPasswordRouteHandler = (req, res, _email) => {
|
||||||
export const forgotPasswordRouteHandler = (req, res) => {
|
|
||||||
const resetUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/login-actions/reset-credentials`;
|
const resetUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/login-actions/reset-credentials`;
|
||||||
const redirectUri = encodeURIComponent(config.app.urlClient + '/auth/login');
|
const redirectUri = encodeURIComponent(config.app.urlClient + '/auth/login');
|
||||||
|
|
||||||
@ -390,76 +395,80 @@ export const forgotPasswordRouteHandler = (req, res) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Refresh token handler
|
// Refresh token - use Bearer token to find session, refresh via Keycloak, update Redis
|
||||||
export const refreshTokenRouteHandler = (req, res) => {
|
export const refreshTokenRouteHandler = async (req, res) => {
|
||||||
if (
|
const authHeader = req.headers.authorization || req.headers.Authorization;
|
||||||
!req.session ||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
!req.session['keycloak-token'] ||
|
return res.status(401).json({ error: 'No session token provided' });
|
||||||
!req.session['keycloak-token'].refresh_token
|
}
|
||||||
) {
|
|
||||||
|
const sessionToken = authHeader.substring(7);
|
||||||
|
const session = await getSession(sessionToken);
|
||||||
|
if (!session || !session.keycloakTokens?.refresh_token) {
|
||||||
return res.status(401).json({ error: 'No refresh token available' });
|
return res.status(401).json({ error: 'No refresh token available' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshToken = req.session['keycloak-token'].refresh_token;
|
|
||||||
const tokenUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token`;
|
const tokenUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token`;
|
||||||
|
|
||||||
axios
|
try {
|
||||||
.post(
|
const response = await axios.post(
|
||||||
tokenUrl,
|
tokenUrl,
|
||||||
new URLSearchParams({
|
new URLSearchParams({
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
client_id: config.auth.keycloak.clientId,
|
client_id: config.auth.keycloak.clientId,
|
||||||
client_secret: config.auth.keycloak.clientSecret,
|
client_secret: config.auth.keycloak.clientSecret,
|
||||||
refresh_token: refreshToken,
|
refresh_token: session.keycloakTokens.refresh_token,
|
||||||
}).toString(),
|
}).toString(),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
.then((response) => {
|
|
||||||
// Update session with new tokens
|
|
||||||
req.session['keycloak-token'] = {
|
|
||||||
...req.session['keycloak-token'],
|
|
||||||
access_token: response.data.access_token,
|
|
||||||
refresh_token: response.data.refresh_token,
|
|
||||||
expires_at: new Date().getTime() + response.data.expires_in * 1000,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save session and return new token info
|
const keycloakTokenData = {
|
||||||
req.session.save(() => {
|
access_token: response.data.access_token,
|
||||||
res.json({
|
refresh_token: response.data.refresh_token,
|
||||||
access_token: response.data.access_token,
|
id_token: response.data.id_token,
|
||||||
expires_at: req.session['keycloak-token'].expires_at,
|
expires_at: new Date().getTime() + response.data.expires_in * 1000,
|
||||||
});
|
};
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
logger.error('Token refresh error:', error.response?.data || error.message);
|
|
||||||
|
|
||||||
// If refresh token is invalid, clear the session
|
await updateSessionKeycloakTokens(sessionToken, keycloakTokenData);
|
||||||
if (error.response?.status === 400) {
|
|
||||||
req.session.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({ error: 'Failed to refresh token' });
|
res.json({
|
||||||
|
access_token: sessionToken,
|
||||||
|
expires_at: keycloakTokenData.expires_at,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Token refresh error:', error.response?.data || error.message);
|
||||||
|
|
||||||
|
if (error.response?.status === 400) {
|
||||||
|
await deleteSession(sessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({ error: 'Failed to refresh token' });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Middleware to populate req.user from session or token
|
// Middleware to populate req.user from Bearer token (Redis session or email-render JWT)
|
||||||
export const populateUserMiddleware = async (req, res, next) => {
|
export const populateUserMiddleware = async (req, res, next) => {
|
||||||
const authHeader = req.headers.authorization || req.headers.Authorization;
|
const authHeader = req.headers.authorization || req.headers.Authorization;
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
const token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use token-based cache to lookup user
|
// 1. Try Redis session first
|
||||||
const user = await lookupUserByToken(token);
|
const session = await getSession(token);
|
||||||
|
if (session) {
|
||||||
|
req.user = session.user;
|
||||||
|
req.session = session;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try email-render JWT (short-lived)
|
||||||
|
const user = await lookupUserByEmailRenderToken(token);
|
||||||
if (user) {
|
if (user) {
|
||||||
req.user = user;
|
req.user = user;
|
||||||
// Also set session user for compatibility
|
|
||||||
req.session.user = user;
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -467,40 +476,14 @@ export const populateUserMiddleware = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to session-based authentication
|
req.user = null;
|
||||||
if (req.session && req.session.user) {
|
|
||||||
req.user = req.session.user;
|
|
||||||
} else {
|
|
||||||
req.user = null;
|
|
||||||
}
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export cache management functions
|
export {
|
||||||
export { lookupUserByToken, clearTokenUserCache, getTokenUserCacheStats, removeUserFromTokenCache };
|
lookupUserByEmailRenderToken as lookupUserByToken,
|
||||||
|
clearTokenUserCache,
|
||||||
// Example of how to set up your routes in Express
|
getTokenUserCacheStats,
|
||||||
/*
|
removeUserFromTokenCache,
|
||||||
import express from "express";
|
getSession,
|
||||||
const app = express();
|
};
|
||||||
|
|
||||||
// Apply session middleware
|
|
||||||
app.use(sessionMiddleware);
|
|
||||||
|
|
||||||
// Initialize Keycloak middleware
|
|
||||||
app.use(keycloak.middleware());
|
|
||||||
|
|
||||||
// Set up routes
|
|
||||||
app.get('/auth/login', loginRouteHandler);
|
|
||||||
app.get('/auth/logout', logoutRouteHandler);
|
|
||||||
app.get('/auth/register', registerRouteHandler);
|
|
||||||
app.get('/auth/forgot-password', forgotPasswordRouteHandler);
|
|
||||||
|
|
||||||
// Protected route example
|
|
||||||
app.get('/api/profile', validateTokenMiddleware, getUserInfoHandler);
|
|
||||||
|
|
||||||
// Admin-only route example
|
|
||||||
app.get('/api/admin', hasRole('admin'), (req, res) => {
|
|
||||||
res.json({ message: 'Admin access granted' });
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
|
|||||||
127
src/services/misc/sessionStore.js
Normal file
127
src/services/misc/sessionStore.js
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Redis-backed session store.
|
||||||
|
* Sessions are created after Keycloak authentication. We generate our own session tokens
|
||||||
|
* and use Redis as the source of truth. Keycloak tokens are stored for refresh.
|
||||||
|
*/
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
import config from '../../config.js';
|
||||||
|
import { redisServer } from '../../database/redis.js';
|
||||||
|
|
||||||
|
const logger = log4js.getLogger('SessionStore');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
const SESSION_KEY_PREFIX = 'session:';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a cryptographically secure session token
|
||||||
|
*/
|
||||||
|
function generateSessionToken() {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get TTL in seconds from expiresAt timestamp
|
||||||
|
*/
|
||||||
|
function getTtlSeconds(expiresAt) {
|
||||||
|
const now = Date.now();
|
||||||
|
const ttlMs = expiresAt - now;
|
||||||
|
return Math.max(Math.ceil(ttlMs / 1000), 60); // minimum 60 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new session in Redis after Keycloak authentication
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {Object} params.user - User object (from createOrUpdateUser)
|
||||||
|
* @param {Object} params.keycloakTokens - { access_token, refresh_token, id_token, expires_at }
|
||||||
|
* @returns {{ sessionToken: string, expiresAt: number }}
|
||||||
|
*/
|
||||||
|
export async function createSession({ user, keycloakTokens }) {
|
||||||
|
const sessionToken = generateSessionToken();
|
||||||
|
const expiresAt = keycloakTokens.expires_at;
|
||||||
|
|
||||||
|
const sessionData = {
|
||||||
|
sessionToken,
|
||||||
|
user: userToSessionUser(user),
|
||||||
|
keycloakTokens: {
|
||||||
|
access_token: keycloakTokens.access_token,
|
||||||
|
refresh_token: keycloakTokens.refresh_token,
|
||||||
|
id_token: keycloakTokens.id_token,
|
||||||
|
expires_at: keycloakTokens.expires_at,
|
||||||
|
},
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const key = SESSION_KEY_PREFIX + sessionToken;
|
||||||
|
const ttlSeconds = getTtlSeconds(expiresAt);
|
||||||
|
|
||||||
|
await redisServer.setKey(key, sessionData, ttlSeconds);
|
||||||
|
logger.debug(`Created session for user ${user.username}, expires in ${ttlSeconds}s`);
|
||||||
|
|
||||||
|
return { sessionToken, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session by token. Returns null if not found or expired.
|
||||||
|
*/
|
||||||
|
export async function getSession(sessionToken) {
|
||||||
|
if (!sessionToken) return null;
|
||||||
|
|
||||||
|
const key = SESSION_KEY_PREFIX + sessionToken;
|
||||||
|
const session = await redisServer.getKey(key);
|
||||||
|
|
||||||
|
if (!session) return null;
|
||||||
|
if (session.expiresAt && session.expiresAt <= Date.now()) {
|
||||||
|
await redisServer.deleteKey(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update session with new Keycloak tokens (after refresh)
|
||||||
|
*/
|
||||||
|
export async function updateSessionKeycloakTokens(sessionToken, keycloakTokens) {
|
||||||
|
const session = await getSession(sessionToken);
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
const updatedSession = {
|
||||||
|
...session,
|
||||||
|
keycloakTokens: {
|
||||||
|
access_token: keycloakTokens.access_token,
|
||||||
|
refresh_token: keycloakTokens.refresh_token,
|
||||||
|
id_token: keycloakTokens.id_token || session.keycloakTokens?.id_token,
|
||||||
|
expires_at: keycloakTokens.expires_at,
|
||||||
|
},
|
||||||
|
expiresAt: keycloakTokens.expires_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
const key = SESSION_KEY_PREFIX + sessionToken;
|
||||||
|
const ttlSeconds = getTtlSeconds(keycloakTokens.expires_at);
|
||||||
|
await redisServer.setKey(key, updatedSession, ttlSeconds);
|
||||||
|
|
||||||
|
return updatedSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a session (logout)
|
||||||
|
*/
|
||||||
|
export async function deleteSession(sessionToken) {
|
||||||
|
if (!sessionToken) return;
|
||||||
|
const key = SESSION_KEY_PREFIX + sessionToken;
|
||||||
|
await redisServer.deleteKey(key);
|
||||||
|
logger.debug(`Deleted session for token ${sessionToken.substring(0, 12)}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize user object for session storage (ensure _id is serializable for Redis)
|
||||||
|
*/
|
||||||
|
function userToSessionUser(user) {
|
||||||
|
if (!user) return null;
|
||||||
|
const u = { ...user };
|
||||||
|
if (u._id && typeof u._id === 'object' && u._id.toString) {
|
||||||
|
u._id = u._id;
|
||||||
|
}
|
||||||
|
return JSON.parse(JSON.stringify(u));
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user