335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
// server/utils/userSync.ts
|
||
// Shared utility for syncing user data from JWT tokens to database
|
||
|
||
import Database from 'better-sqlite3';
|
||
import { join } from 'path';
|
||
import { existsSync, mkdirSync } from 'fs';
|
||
|
||
// Helper to get database path
|
||
const getDbPath = () => {
|
||
const dbDir = join(process.cwd(), 'data');
|
||
if (!existsSync(dbDir)) {
|
||
mkdirSync(dbDir, { recursive: true });
|
||
}
|
||
return join(dbDir, 'users.db');
|
||
};
|
||
|
||
// Initialize database and create table if not exists
|
||
const initDb = () => {
|
||
const dbPath = getDbPath();
|
||
const db = new Database(dbPath);
|
||
|
||
// Create table if not exists
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id TEXT PRIMARY KEY,
|
||
namaLengkap TEXT NOT NULL,
|
||
namaUser TEXT UNIQUE NOT NULL,
|
||
email TEXT,
|
||
tipeUser TEXT DEFAULT '',
|
||
lastLogin INTEGER,
|
||
roles TEXT DEFAULT '[]',
|
||
realmRoles TEXT DEFAULT '[]',
|
||
accountRoles TEXT DEFAULT '[]',
|
||
resourceRoles TEXT DEFAULT '[]',
|
||
groups TEXT DEFAULT '[]',
|
||
given_name TEXT,
|
||
family_name TEXT,
|
||
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
|
||
updatedAt INTEGER DEFAULT (strftime('%s', 'now'))
|
||
)
|
||
`);
|
||
|
||
// Migration: Check and add missing columns one by one
|
||
try {
|
||
const tableInfo = db.prepare("PRAGMA table_info(users)").all() as any[];
|
||
const columnNames = tableInfo.map(col => col.name);
|
||
|
||
// Add missing columns one by one
|
||
if (!columnNames.includes('realmRoles')) {
|
||
db.exec(`ALTER TABLE users ADD COLUMN realmRoles TEXT DEFAULT '[]'`);
|
||
console.log('✅ Added column: realmRoles');
|
||
}
|
||
|
||
if (!columnNames.includes('accountRoles')) {
|
||
db.exec(`ALTER TABLE users ADD COLUMN accountRoles TEXT DEFAULT '[]'`);
|
||
console.log('✅ Added column: accountRoles');
|
||
}
|
||
|
||
if (!columnNames.includes('resourceRoles')) {
|
||
db.exec(`ALTER TABLE users ADD COLUMN resourceRoles TEXT DEFAULT '[]'`);
|
||
console.log('✅ Added column: resourceRoles');
|
||
}
|
||
|
||
if (!columnNames.includes('lastLogin')) {
|
||
db.exec(`ALTER TABLE users ADD COLUMN lastLogin INTEGER`);
|
||
console.log('✅ Added column: lastLogin');
|
||
}
|
||
|
||
if (!columnNames.includes('given_name')) {
|
||
db.exec(`ALTER TABLE users ADD COLUMN given_name TEXT`);
|
||
console.log('✅ Added column: given_name');
|
||
}
|
||
|
||
if (!columnNames.includes('family_name')) {
|
||
db.exec(`ALTER TABLE users ADD COLUMN family_name TEXT`);
|
||
console.log('✅ Added column: family_name');
|
||
}
|
||
} catch (e: any) {
|
||
console.error('❌ Migration error:', e.message);
|
||
// Don't throw, continue with existing schema
|
||
}
|
||
|
||
return db;
|
||
};
|
||
|
||
// Helper function to decode JWT token payload
|
||
const decodeTokenPayload = (token: string | undefined): any | null => {
|
||
if (!token) return null;
|
||
try {
|
||
const parts = token.split(".");
|
||
if (parts.length < 2) return null;
|
||
const payloadBase64 = parts[1];
|
||
return JSON.parse(Buffer.from(payloadBase64, "base64").toString());
|
||
} catch (e) {
|
||
console.error("❌ Failed to decode token payload:", e);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Sync user data from JWT tokens to database
|
||
* @param idToken - The ID token from Keycloak
|
||
* @param accessToken - The access token from Keycloak
|
||
* @param loginTime - Optional login timestamp (from session createdAt). If not provided, uses current time
|
||
* @returns Object with success status and action taken
|
||
*/
|
||
export const syncUserFromTokens = (
|
||
idToken: string,
|
||
accessToken: string,
|
||
loginTime?: number
|
||
): { success: boolean; action: 'created' | 'updated' | 'unchanged'; message: string } => {
|
||
try {
|
||
// Decode tokens
|
||
const idTokenPayload = decodeTokenPayload(idToken);
|
||
const accessTokenPayload = decodeTokenPayload(accessToken);
|
||
|
||
if (!idTokenPayload) {
|
||
throw new Error('Invalid ID token');
|
||
}
|
||
|
||
// Extract user data
|
||
const userId = idTokenPayload.sub;
|
||
const namaLengkap = idTokenPayload.name ||
|
||
`${idTokenPayload.given_name || ''} ${idTokenPayload.family_name || ''}`.trim() ||
|
||
idTokenPayload.preferred_username;
|
||
const namaUser = idTokenPayload.preferred_username ||
|
||
idTokenPayload.email?.split('@')[0];
|
||
const email = idTokenPayload.email;
|
||
|
||
if (!userId || !namaUser) {
|
||
throw new Error('Missing required user data (id or username)');
|
||
}
|
||
|
||
// Extract roles from different sources
|
||
const realmRoles = accessTokenPayload?.realm_access?.roles ||
|
||
idTokenPayload?.realm_access?.roles ||
|
||
[];
|
||
|
||
const accountRoles = accessTokenPayload?.resource_access?.account?.roles ||
|
||
idTokenPayload?.resource_access?.account?.roles ||
|
||
[];
|
||
|
||
// Extract resource roles (from all resources in resource_access)
|
||
const resourceRoles: string[] = [];
|
||
if (accessTokenPayload?.resource_access) {
|
||
Object.keys(accessTokenPayload.resource_access).forEach(resourceName => {
|
||
if (resourceName !== 'account') { // Exclude account, already handled
|
||
const resourceRolesArray = accessTokenPayload.resource_access[resourceName]?.roles || [];
|
||
resourceRoles.push(...resourceRolesArray.map((role: string) => `${resourceName}:${role}`));
|
||
}
|
||
});
|
||
}
|
||
if (idTokenPayload?.resource_access) {
|
||
Object.keys(idTokenPayload.resource_access).forEach(resourceName => {
|
||
if (resourceName !== 'account') { // Exclude account, already handled
|
||
const resourceRolesArray = idTokenPayload.resource_access[resourceName]?.roles || [];
|
||
resourceRoles.push(...resourceRolesArray.map((role: string) => `${resourceName}:${role}`));
|
||
}
|
||
});
|
||
}
|
||
|
||
// Legacy: Combined roles (for backward compatibility)
|
||
const roles = [...realmRoles, ...accountRoles, ...resourceRoles];
|
||
|
||
// Keycloak uses 'groups_join' in access token, not 'groups'
|
||
const groups = accessTokenPayload?.groups_join ||
|
||
accessTokenPayload?.groups ||
|
||
idTokenPayload?.groups_join ||
|
||
idTokenPayload?.groups ||
|
||
[];
|
||
|
||
// Determine tipeUser from groups or roles if possible
|
||
// Extract from groups path (e.g., "/Instalasi STIM/Devops/Superadmin" -> "Superadmin")
|
||
let tipeUser = '';
|
||
if (Array.isArray(groups) && groups.length > 0) {
|
||
// Get the last group path and extract the last segment
|
||
const lastGroup = groups[groups.length - 1];
|
||
if (typeof lastGroup === 'string') {
|
||
const parts = lastGroup.split('/').filter(Boolean);
|
||
if (parts.length > 0) {
|
||
tipeUser = parts[parts.length - 1]; // Get last part of path
|
||
}
|
||
}
|
||
}
|
||
// If no tipeUser from groups, you can also check roles or leave empty for manual entry
|
||
|
||
// Use provided loginTime or current time (convert to seconds if in milliseconds)
|
||
const lastLoginTimestamp = loginTime
|
||
? (loginTime > 10000000000 ? Math.floor(loginTime / 1000) : loginTime) // Convert ms to seconds if needed
|
||
: Math.floor(Date.now() / 1000);
|
||
|
||
// Initialize database
|
||
const db = initDb();
|
||
|
||
// Check if user exists
|
||
const existingUser = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as any;
|
||
|
||
const rolesJson = JSON.stringify(Array.isArray(roles) ? roles : []);
|
||
const realmRolesJson = JSON.stringify(Array.isArray(realmRoles) ? realmRoles : []);
|
||
const accountRolesJson = JSON.stringify(Array.isArray(accountRoles) ? accountRoles : []);
|
||
const resourceRolesJson = JSON.stringify(Array.isArray(resourceRoles) ? resourceRoles : []);
|
||
const groupsJson = JSON.stringify(Array.isArray(groups) ? groups : []);
|
||
|
||
if (existingUser) {
|
||
// User exists - check if data needs updating
|
||
// Note: tipeUser is only updated if it's empty in database (to preserve manual edits)
|
||
const needsUpdate =
|
||
existingUser.namaLengkap !== namaLengkap ||
|
||
existingUser.namaUser !== namaUser ||
|
||
existingUser.email !== email ||
|
||
existingUser.roles !== rolesJson ||
|
||
existingUser.realmRoles !== realmRolesJson ||
|
||
existingUser.accountRoles !== accountRolesJson ||
|
||
existingUser.resourceRoles !== resourceRolesJson ||
|
||
existingUser.groups !== groupsJson ||
|
||
existingUser.given_name !== (idTokenPayload.given_name || null) ||
|
||
existingUser.family_name !== (idTokenPayload.family_name || null) ||
|
||
(existingUser.tipeUser === '' && tipeUser !== ''); // Only update if empty
|
||
|
||
// Always update lastLogin when loginTime is provided (user is logging in)
|
||
// Check if lastLogin needs updating (if loginTime provided or new timestamp is newer)
|
||
const existingLastLogin = existingUser.lastLogin || 0;
|
||
const needsLastLoginUpdate = loginTime !== undefined
|
||
? true // Always update on login when loginTime is provided
|
||
: (lastLoginTimestamp > existingLastLogin); // Only update if newer when not a login event
|
||
|
||
// Update if any data changed OR if lastLogin needs updating
|
||
if (needsUpdate || needsLastLoginUpdate) {
|
||
// Update user data
|
||
// Only update tipeUser if it's currently empty (preserve manual edits)
|
||
const updateTipeUser = existingUser.tipeUser === '' ? tipeUser : existingUser.tipeUser;
|
||
|
||
db.prepare(`
|
||
UPDATE users
|
||
SET namaLengkap = ?,
|
||
namaUser = ?,
|
||
email = ?,
|
||
roles = ?,
|
||
realmRoles = ?,
|
||
accountRoles = ?,
|
||
resourceRoles = ?,
|
||
groups = ?,
|
||
given_name = ?,
|
||
family_name = ?,
|
||
tipeUser = ?,
|
||
lastLogin = ?,
|
||
updatedAt = strftime('%s', 'now')
|
||
WHERE id = ?
|
||
`).run(
|
||
namaLengkap,
|
||
namaUser,
|
||
email || null,
|
||
rolesJson,
|
||
realmRolesJson,
|
||
accountRolesJson,
|
||
resourceRolesJson,
|
||
groupsJson,
|
||
idTokenPayload.given_name || null,
|
||
idTokenPayload.family_name || null,
|
||
updateTipeUser,
|
||
lastLoginTimestamp, // Update lastLogin timestamp from session
|
||
userId
|
||
);
|
||
|
||
if (needsLastLoginUpdate && !needsUpdate) {
|
||
console.log("✅ User lastLogin updated:", userId, "new timestamp:", lastLoginTimestamp);
|
||
} else {
|
||
console.log("✅ User data updated:", userId);
|
||
}
|
||
db.close();
|
||
return {
|
||
success: true,
|
||
action: 'updated',
|
||
message: 'User data updated successfully'
|
||
};
|
||
} else {
|
||
console.log("ℹ️ User data unchanged:", userId);
|
||
db.close();
|
||
return {
|
||
success: true,
|
||
action: 'unchanged',
|
||
message: 'User data is up to date'
|
||
};
|
||
}
|
||
} else {
|
||
// New user - insert
|
||
console.log("➕ Inserting new user:", {
|
||
userId,
|
||
namaLengkap,
|
||
namaUser,
|
||
email,
|
||
lastLoginTimestamp
|
||
});
|
||
|
||
db.prepare(`
|
||
INSERT INTO users (
|
||
id, namaLengkap, namaUser, email, roles, realmRoles, accountRoles, resourceRoles, groups,
|
||
given_name, family_name, tipeUser, lastLogin
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`).run(
|
||
userId,
|
||
namaLengkap,
|
||
namaUser,
|
||
email || null,
|
||
rolesJson,
|
||
realmRolesJson,
|
||
accountRolesJson,
|
||
resourceRolesJson,
|
||
groupsJson,
|
||
idTokenPayload.given_name || null,
|
||
idTokenPayload.family_name || null,
|
||
tipeUser, // tipeUser - extracted from groups or empty
|
||
lastLoginTimestamp // lastLogin - from session createdAt
|
||
);
|
||
|
||
console.log("✅ New user saved to database:", {
|
||
userId,
|
||
namaUser,
|
||
namaLengkap,
|
||
lastLogin: lastLoginTimestamp ? new Date(lastLoginTimestamp * 1000).toISOString() : 'null'
|
||
});
|
||
db.close();
|
||
return {
|
||
success: true,
|
||
action: 'created',
|
||
message: `New user ${namaUser} saved successfully`
|
||
};
|
||
}
|
||
} catch (error: any) {
|
||
console.error("❌ Error syncing user:", error);
|
||
throw error;
|
||
}
|
||
};
|
||
|