Refactor password reset to use dedicated Keycloak admin client
All checks were successful
Build And Push Image / docker (push) Successful in 2m55s
All checks were successful
Build And Push Image / docker (push) Successful in 2m55s
- Add Keycloak admin credentials configuration to environment variables - Extract Keycloak admin operations into reusable utility module - Refactor forgot-password endpoint to use new admin client utility - Add documentation for Keycloak custom login implementation - Add password reset fix summary documentation This improves code organization by separating admin operations from business logic and provides proper admin credentials for Keycloak API operations instead of using regular client credentials.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { createKeycloakAdminClient } from '~/server/utils/keycloak-admin';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('🔄 Forgot password endpoint called at:', new Date().toISOString());
|
||||
|
||||
@@ -23,62 +25,20 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
// Validate Keycloak configuration
|
||||
if (!config.keycloak?.issuer || !config.keycloak?.clientId || !config.keycloak?.clientSecret) {
|
||||
console.error('❌ Missing Keycloak configuration');
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Authentication service configuration error'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('🔧 Using Keycloak config for password reset:', {
|
||||
issuer: config.keycloak.issuer,
|
||||
clientId: config.keycloak.clientId
|
||||
});
|
||||
const config = useRuntimeConfig() as any;
|
||||
|
||||
try {
|
||||
// Get admin token for Keycloak admin API
|
||||
const adminTokenResponse = await fetch(`${config.keycloak.issuer}/protocol/openid-connect/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: config.keycloak.clientId,
|
||||
client_secret: config.keycloak.clientSecret
|
||||
})
|
||||
});
|
||||
// Create Keycloak admin client
|
||||
const adminClient = createKeycloakAdminClient();
|
||||
|
||||
console.log('🔧 Using Keycloak admin client for password reset');
|
||||
|
||||
if (!adminTokenResponse.ok) {
|
||||
console.error('❌ Failed to get admin token:', adminTokenResponse.status);
|
||||
throw new Error('Failed to authenticate with admin service');
|
||||
}
|
||||
|
||||
const adminToken = await adminTokenResponse.json();
|
||||
// Get admin token
|
||||
const adminToken = await adminClient.getAdminToken();
|
||||
console.log('✅ Admin token obtained');
|
||||
|
||||
// Find user by email using Keycloak admin API
|
||||
const realmName = config.keycloak.issuer.split('/realms/')[1];
|
||||
const adminBaseUrl = config.keycloak.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const usersResponse = await fetch(`${adminBaseUrl}/users?email=${encodeURIComponent(email)}&exact=true`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken.access_token}`,
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!usersResponse.ok) {
|
||||
console.error('❌ Failed to search users:', usersResponse.status);
|
||||
throw new Error('Failed to search for user');
|
||||
}
|
||||
|
||||
const users = await usersResponse.json();
|
||||
// Find user by email
|
||||
const users = await adminClient.findUserByEmail(email, adminToken);
|
||||
console.log('🔍 User search result:', { found: users.length > 0 });
|
||||
|
||||
if (users.length === 0) {
|
||||
@@ -93,56 +53,13 @@ export default defineEventHandler(async (event) => {
|
||||
const userId = users[0].id;
|
||||
console.log('👤 Found user:', { id: userId, email: users[0].email });
|
||||
|
||||
// Send reset password email using Keycloak's execute-actions-email
|
||||
// Add query parameters for better email template rendering
|
||||
const resetUrl = new URL(`${adminBaseUrl}/users/${userId}/execute-actions-email`);
|
||||
resetUrl.searchParams.set('clientId', config.keycloak.clientId);
|
||||
resetUrl.searchParams.set('redirectUri', `${config.keycloak.callbackUrl.replace('/auth/callback', '/login')}`);
|
||||
resetUrl.searchParams.set('lifespan', '43200'); // 12 hours
|
||||
|
||||
console.log('🔄 Sending password reset email with parameters:', {
|
||||
clientId: config.keycloak.clientId,
|
||||
redirectUri: resetUrl.searchParams.get('redirectUri'),
|
||||
lifespan: resetUrl.searchParams.get('lifespan')
|
||||
});
|
||||
|
||||
// Create AbortController for timeout handling
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||
|
||||
const resetResponse = await fetch(resetUrl.toString(), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
},
|
||||
body: JSON.stringify(['UPDATE_PASSWORD']),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!resetResponse.ok) {
|
||||
console.error('❌ Failed to send reset email:', resetResponse.status);
|
||||
const errorText = await resetResponse.text().catch(() => 'Unknown error');
|
||||
console.error('Reset email error details:', errorText);
|
||||
|
||||
// Enhanced error handling for different scenarios
|
||||
if (resetResponse.status === 500) {
|
||||
console.error('🚨 SMTP server error detected - this usually indicates email configuration issues in Keycloak');
|
||||
console.error('💡 Suggestion: Check Keycloak Admin Console → Realm Settings → Email tab');
|
||||
|
||||
// For now, still return success to user for security, but log the issue
|
||||
console.log('🔄 Returning success message to user despite email failure for security');
|
||||
return {
|
||||
success: true,
|
||||
message: 'If the email exists in our system, a reset link has been sent. If you don\'t receive an email, please contact your administrator.'
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Failed to send reset email');
|
||||
}
|
||||
// Send password reset email
|
||||
await adminClient.sendPasswordResetEmail(
|
||||
userId,
|
||||
adminToken,
|
||||
config.keycloak.clientId,
|
||||
config.keycloak.callbackUrl
|
||||
);
|
||||
|
||||
console.log('✅ Password reset email sent successfully');
|
||||
|
||||
@@ -164,7 +81,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// Handle SMTP/email server errors
|
||||
if (keycloakError.message?.includes('send reset email') || keycloakError.message?.includes('SMTP')) {
|
||||
if (keycloakError.message?.includes('send reset email') || keycloakError.message?.includes('SMTP') || keycloakError.message?.includes('500')) {
|
||||
console.error('📧 Email server error detected, but user search was successful');
|
||||
return {
|
||||
success: true,
|
||||
@@ -172,6 +89,16 @@ export default defineEventHandler(async (event) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Handle permission errors
|
||||
if (keycloakError.message?.includes('403') || keycloakError.message?.includes('Forbidden')) {
|
||||
console.error('🔒 Permission error detected - admin client may not have proper roles');
|
||||
console.error('💡 Suggestion: Check that admin-cli client has view-users and manage-users roles');
|
||||
return {
|
||||
success: true,
|
||||
message: 'Password reset service is temporarily unavailable. Please contact your administrator.'
|
||||
};
|
||||
}
|
||||
|
||||
// For security, don't reveal specific errors to the user
|
||||
return {
|
||||
success: true,
|
||||
|
||||
110
server/utils/keycloak-admin.ts
Normal file
110
server/utils/keycloak-admin.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { KeycloakAdminConfig } from '~/utils/types';
|
||||
|
||||
export class KeycloakAdminClient {
|
||||
private config: KeycloakAdminConfig;
|
||||
|
||||
constructor(config: KeycloakAdminConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an admin access token using client credentials grant
|
||||
*/
|
||||
async getAdminToken(): Promise<string> {
|
||||
const response = await fetch(`${this.config.issuer}/protocol/openid-connect/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to get admin token: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const tokenData = await response.json();
|
||||
return tokenData.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a user by email address
|
||||
*/
|
||||
async findUserByEmail(email: string, adminToken: string): Promise<any[]> {
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/users?email=${encodeURIComponent(email)}&exact=true`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to search users: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email to a user
|
||||
*/
|
||||
async sendPasswordResetEmail(userId: string, adminToken: string, portalClientId: string, callbackUrl: string): Promise<void> {
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
const resetUrl = new URL(`${adminBaseUrl}/users/${userId}/execute-actions-email`);
|
||||
|
||||
// Add query parameters for better email template rendering
|
||||
resetUrl.searchParams.set('clientId', portalClientId);
|
||||
resetUrl.searchParams.set('redirectUri', callbackUrl.replace('/auth/callback', '/login'));
|
||||
resetUrl.searchParams.set('lifespan', '43200'); // 12 hours
|
||||
|
||||
// Create AbortController for timeout handling
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||
|
||||
try {
|
||||
const response = await fetch(resetUrl.toString(), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
},
|
||||
body: JSON.stringify(['UPDATE_PASSWORD']),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to send reset email: ${response.status} - ${errorText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createKeycloakAdminClient(): KeycloakAdminClient {
|
||||
const config = useRuntimeConfig() as any;
|
||||
|
||||
if (!config.keycloakAdmin?.clientId || !config.keycloakAdmin?.clientSecret || !config.keycloak?.issuer) {
|
||||
throw new Error('Missing Keycloak admin configuration');
|
||||
}
|
||||
|
||||
return new KeycloakAdminClient({
|
||||
issuer: config.keycloak.issuer,
|
||||
clientId: config.keycloakAdmin.clientId,
|
||||
clientSecret: config.keycloakAdmin.clientSecret
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user