Introduction
Single Sign-On (SSO) is a crucial feature in modern web applications that allows users to navigate between related systems without the friction of multiple logins. In this article, I'll walk through how to implement a secure SSO mechanism between two vue and nodejs applications using AES-256-CBC encryption.
The implementation I'm sharing came from a real-world project where we needed to allow users to seamlessly switch between a Client Panel and a Partner/IB Panel while maintaining proper authentication and security.
The Problem
Our application had two separate panels:
- Client Panel: Where regular users manage their accounts
- Partner/IB Panel: Where partners (Introducing Brokers) manage their business
Some users had access to both panels, and we wanted to provide a smooth experience for them to switch between panels without having to log in multiple times.
Solution Overview
Our solution uses:
- AES-256-CBC encryption to securely pass user identity between panels
- Shared encryption key stored in the database
- Token-based authentication to maintain separate sessions in each panel
- Vue.js and Vuetify for the frontend components
The basic flow works like this:
- User clicks "Switch to Partner Panel" in the Client Panel
- Client Panel encrypts the user's ID and redirects to Partner Panel with the encrypted token
- Partner Panel decrypts the token, verifies the user, and logs them in automatically
- The same process works in reverse for switching back to the Client Panel
Database Setup
First, we need to set up the database to store our encryption key and panel URLs:
- Add an encryption key to the
broker_settings
table:
INSERT INTO broker_settings (name, value)
VALUES ('CUSTOM_ENCRYPTION_KEY', '66f7a1a9-675c-8010-b25a-48230661874a');
- Ensure you have the panel URLs in your
general_informations
table:
-- Make sure these columns exist
ALTER TABLE general_informations ADD COLUMN client_portal_url VARCHAR(255);
ALTER TABLE general_informations ADD COLUMN partner_portal_url VARCHAR(255);
-- Set the URLs
UPDATE general_informations
SET client_portal_url = 'https://client.example.com',
partner_portal_url = 'https://partner.example.com'
WHERE id = 1;
Backend Implementation
Client Panel AuthController Methods
Let's add three methods to the Client Panel's AuthController:
- switchToPartnerPanel: Generates an encrypted token and provides the URL to redirect to the Partner Panel
async switchToPartnerPanel({ auth, response }) {
try {
// Ensure user is authenticated
const user = await auth.getUser()
if (!user) {
return response.status(401).json({
type: 'error',
message: 'User not authenticated'
})
}
// Check if user has IB status (partner privileges)
if (!user.ib_status || user.ib_status !== 1) {
return response.status(403).json({
type: 'error',
message: 'You do not have access to the Partner Panel'
})
}
// Get encryption key from database
const encryptionKey = await BrokerSitting.query()
.where('name', 'CUSTOM_ENCRYPTION_KEY')
.first()
if (!encryptionKey || !encryptionKey.value) {
return response.status(500).json({
type: 'error',
message: 'SSO configuration error'
})
}
// Create a proper 32-byte key using SHA-256
const crypto = require('crypto')
const hash = crypto.createHash('sha256')
hash.update(encryptionKey.value)
const keyBuffer = hash.digest() // This gives a 32-byte buffer
// Use the first 16 bytes of the key as the IV
const iv = keyBuffer.slice(0, 16)
// Get user ID to encrypt
const userId = user.id
// Encrypt the user ID using AES-256-CBC
const cipher = crypto.createCipheriv('aes-256-cbc', keyBuffer, iv)
let token = cipher.update(String(userId), 'utf8', 'base64')
token += cipher.final('base64')
// Get partner panel URL from database
const partnerPortalInfo = await Database
.table('general_informations')
.select('partner_portal_url')
.first()
if (!partnerPortalInfo || !partnerPortalInfo.partner_portal_url) {
return response.status(500).json({
type: 'error',
message: 'Partner portal URL not configured'
})
}
// Create the SSO URL with the encrypted token
const ssoUrl = `${partnerPortalInfo.partner_portal_url.replace(/\/$/, '')}/sso-login?token=${encodeURIComponent(token)}`
// Return the URL to redirect to
return response.status(200).json({
type: 'success',
ssoUrl
})
} catch (error) {
console.error('SSO error:', error)
return response.status(500).json({
type: 'error',
message: 'Internal server error',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
})
}
}
- handleSSOLogin: Receives an encrypted token from the Partner Panel, decrypts it, and logs the user in
async handleSSOLogin({ request, response, auth, session }) {
try {
const { token } = request.all()
if (!token) {
return response.status(400).json({
type: 'error',
message: 'SSO token is required'
})
}
// Get encryption key from database
const encryptionKey = await BrokerSitting.query()
.where('name', 'CUSTOM_ENCRYPTION_KEY')
.first()
if (!encryptionKey || !encryptionKey.value) {
return response.status(500).json({
type: 'error',
message: 'SSO configuration error'
})
}
// Create a proper 32-byte key using SHA-256
const crypto = require('crypto')
const hash = crypto.createHash('sha256')
hash.update(encryptionKey.value)
const keyBuffer = hash.digest() // This gives a 32-byte buffer
// Use the first 16 bytes of the key as the IV
const iv = keyBuffer.slice(0, 16)
// Decrypt the token to get user ID
let userId
try {
const decipher = crypto.createDecipheriv('aes-256-cbc', keyBuffer, iv)
userId = decipher.update(token, 'base64', 'utf8')
userId += decipher.final('utf8')
} catch (error) {
return response.status(400).json({
type: 'error',
message: 'Invalid SSO token'
})
}
// Find the user in database
const user = await Account.query()
.where('id', userId)
.first()
if (!user) {
return response.status(404).json({
type: 'error',
message: 'User not found'
})
}
// Check if user is active
if (user.status === 0) {
return response.status(403).json({
type: 'error',
message: 'Account is blocked'
})
}
// Check if email is verified
if (user.email_status === 0) {
return response.status(403).json({
type: 'error',
message: 'Email not verified'
})
}
// Log in the user
const authToken = await auth.generate(user)
// Record login activity
const now = moment().format('YYYY-MM-DD HH:mm:ss')
const userAgentString = request.header('user-agent')
const userAgent = UserAgent.parse(userAgentString)
const userDevice = `${userAgent.family} ${userAgent.major}`
const sessionId = uuidv4()
session.put('loginActivitySessionId', sessionId)
const userActivityService = new LoginActivityService()
await userActivityService.logLoginActivity(
user.id,
1, // Panel = 1 for client panel
sessionId,
now,
null,
userDevice,
request.header('referer'),
request.ip()
)
// Get logout time from settings
const logOutTimeSetting = await BrokerSitting.query()
.where('name', 'log_out_time_client')
.first()
const logOutTime = logOutTimeSetting ?
parseInt(logOutTimeSetting.value) * 60 * 1000 :
30 * 60 * 1000 // Default to 30 minutes
return response.status(200).json({
type: 'success',
message: 'SSO login successful',
logOutTime: logOutTime,
client: authToken
})
} catch (error) {
console.error('SSO login error:', error)
return response.status(500).json({
type: 'error',
message: 'Internal server error',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
})
}
}
- logoutAndRedirectToPartner: Logs the user out of the Client Panel and redirects to the Partner Panel's logout page
async logoutAndRedirectToPartner({ auth, response }) {
const loginUser = await auth.user.id
const loginActivity = await LoginActivity.query()
.where('user_id', loginUser)
.where('panel', 1)
.where('logout_time', null)
.orderBy('id', 'desc')
.first()
if (loginActivity) {
const userActivityService = new LoginActivityService()
await userActivityService.logLogoutActivity(
loginUser,
1,
loginActivity.session_id
)
}
await auth.logout()
// Get partner panel URL from database
const partnerPortalInfo = await Database
.table('general_informations')
.select('partner_portal_url')
.first()
if (!partnerPortalInfo || !partnerPortalInfo.partner_portal_url) {
return response.status(500).json({
type: 'error',
message: 'Partner portal URL not configured'
})
}
return response.status(200).json({
type: 'success',
message: 'Logged out successfully',
redirectUrl: `${partnerPortalInfo.partner_portal_url.replace(/\/$/, '')}/logout`
})
}
Partner Panel AuthController Methods
Now let's implement the corresponding methods in the Partner Panel's AuthController:
- handleSSOLogin: Receives an encrypted token from the Client Panel
async handleSSOLogin({ request, response, auth, session }) {
try {
const { token } = request.all();
if (!token) {
return response.status(400).json({
type: 'error',
message: 'SSO token is required'
});
}
// Get encryption key from database
const encryptionKey = await BrokerSitting.query()
.where('name', 'CUSTOM_ENCRYPTION_KEY')
.first();
if (!encryptionKey || !encryptionKey.value) {
return response.status(500).json({
type: 'error',
message: 'SSO configuration error'
});
}
// Create a proper 32-byte key using SHA-256
const crypto = require('crypto');
const hash = crypto.createHash('sha256');
hash.update(encryptionKey.value);
const keyBuffer = hash.digest();
// Use the first 16 bytes of the key as the IV
const iv = keyBuffer.slice(0, 16);
// Decrypt the token to get user ID
let userId;
try {
const decipher = crypto.createDecipheriv('aes-256-cbc', keyBuffer, iv);
userId = decipher.update(token, 'base64', 'utf8');
userId += decipher.final('utf8');
} catch (error) {
console.error('Token decryption error:', error);
return response.status(400).json({
type: 'error',
message: 'Invalid SSO token'
});
}
// Find the user in database
const user = await Account.query()
.where('id', userId)
.first();
if (!user) {
return response.status(404).json({
type: 'error',
message: 'User not found'
});
}
// Verify the user has IB status
if (!user.ib_status || user.ib_status !== 1) {
return response.status(403).json({
type: 'error',
message: 'You do not have access to the Partner Panel'
});
}
// Check if user is active
if (user.status === 0) {
return response.status(403).json({
type: 'error',
message: 'Account is blocked'
});
}
// Log in the user
await auth.loginViaId(user.id);
// Record login activity
const now = moment().format("YYYY-MM-DD HH:mm:ss");
const userAgentString = request.header("user-agent");
const userAgent = UserAgent.parse(userAgentString);
const userDevice = `${userAgent.family} ${userAgent.major}`;
const sessionId = uuidv4();
session.put("loginActivitySessionId", sessionId);
const userActivityService = new LoginActivityService();
await userActivityService.logLoginActivity(
user.id,
2, // Panel = 2 for partner panel
sessionId,
now,
null,
userDevice,
request.header("referer"),
request.ip()
);
// Redirect to dashboard
return response.redirect('/dashboard');
} catch (error) {
console.error('SSO login error:', error);
return response.status(500).json({
type: 'error',
message: 'Internal server error'
});
}
}
- switchToClientPanel: Generates an encrypted token and provides the URL to redirect to the Client Panel
async switchToClientPanel({ auth, response }) {
try {
// Ensure user is authenticated
const user = await auth.getUser();
if (!user) {
return response.status(401).json({
type: 'error',
message: 'User not authenticated'
});
}
// Get encryption key from database
const encryptionKey = await BrokerSitting.query()
.where('name', 'CUSTOM_ENCRYPTION_KEY')
.first();
if (!encryptionKey || !encryptionKey.value) {
return response.status(500).json({
type: 'error',
message: 'SSO configuration error'
});
}
// Create a proper 32-byte key using SHA-256
const crypto = require('crypto');
const hash = crypto.createHash('sha256');
hash.update(encryptionKey.value);
const keyBuffer = hash.digest();
// Use the first 16 bytes of the key as the IV
const iv = keyBuffer.slice(0, 16);
// Get user ID to encrypt
const userId = user.id;
// Encrypt the user ID using AES-256-CBC
const cipher = crypto.createCipheriv('aes-256-cbc', keyBuffer, iv);
let token = cipher.update(String(userId), 'utf8', 'base64');
token += cipher.final('base64');
// Get client panel URL from database
const clientPortalInfo = await Database
.table('general_informations')
.select('client_portal_url')
.first();
if (!clientPortalInfo || !clientPortalInfo.client_portal_url) {
return response.status(500).json({
type: 'error',
message: 'Client portal URL not configured'
});
}
// Create the SSO URL with the encrypted token
const ssoUrl = `${clientPortalInfo.client_portal_url.replace(/\/$/, '')}/sso-login?token=${encodeURIComponent(token)}`;
// Return the URL to redirect to
return response.status(200).json({
type: 'success',
ssoUrl
});
} catch (error) {
console.error('SSO error:', error);
return response.status(500).json({
type: 'error',
message: 'Internal server error'
});
}
}
- logoutAndRedirectToClient: Logs the user out of the Partner Panel and redirects to the Client Panel
async logoutAndRedirectToClient({ auth, response, session }) {
try {
const user = await auth.getUser();
const userId = user.id;
// Log logout activity
const loginActivity = await LoginActivity.query()
.where('user_id', userId)
.where('panel', 2) // Partner panel
.where('logout_time', null)
.orderBy('id', 'desc')
.first();
if (loginActivity) {
const userActivityService = new LoginActivityService();
await userActivityService.logLogoutActivity(
userId,
2, // Partner panel
loginActivity.session_id
);
}
// Perform logout
await auth.logout();
session.clear();
// Get client panel URL
const clientPortalInfo = await Database
.table('general_informations')
.select('client_portal_url')
.first();
// Redirect to client panel
if (clientPortalInfo && clientPortalInfo.client_portal_url) {
return response.status(200).json({
type: 'success',
message: 'Logged out successfully',
redirectUrl: `${clientPortalInfo.client_portal_url.replace(/\/$/, '')}/login`
});
} else {
return response.status(200).json({
type: 'success',
message: 'Logged out successfully'
});
}
} catch (error) {
console.error('Logout error:', error);
return response.status(500).json({
type: 'error',
message: 'Internal server error'
});
}
}
Setting Up Routes
Add these routes to your Client Panel's routes file:
// Client Panel routes
Route.get('/switch-to-partner', 'AuthController.switchToPartnerPanel').middleware(['auth'])
Route.get('/sso-login', 'AuthController.handleSSOLogin')
Route.get('/logout-to-partner', 'AuthController.logoutAndRedirectToPartner').middleware(['auth'])
And these routes to your Partner Panel's routes file:
// Partner Panel routes
Route.get('/sso-login', 'AuthController.handleSSOLogin')
Route.get('/switch-to-client', 'AuthController.switchToClientPanel').middleware(['auth'])
Route.get('/logout', 'AuthController.logoutAndRedirectToClient').middleware(['auth'])
Frontend Implementation
Now let's implement the UI components to trigger the panel switching.
Client Panel Vue Component
Create a component for the Client Panel that allows switching to the Partner Panel:
<template>
<div>
<v-btn
text
:loading="isLoading"
:disabled="isLoading"
@click="switchToPartnerPanel"
class="mx-2"
>
<v-icon left>mdi-swap-horizontal</v-icon>
Switch to Partner Panel
</v-btn>
<v-snackbar
v-model="showError"
color="error"
timeout="5000"
bottom
>
{{ error }}
<template v-slot:action="{ attrs }">
<v-btn
text
v-bind="attrs"
@click="showError = false"
>
Close
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
export default {
name: 'PanelSwitcher',
computed: {
...mapState('panelSwitcher', ['isLoading', 'error']),
showError: {
get() {
return this.error !== null;
},
set(value) {
if (!value) {
this.$store.commit('panelSwitcher/SET_ERROR', null);
}
}
}
},
methods: {
...mapActions('panelSwitcher', ['switchToPartnerPanel'])
}
}
</script>
Partner Panel Vue Component
Create a similar component for the Partner Panel to switch back to the Client Panel:
<template>
<div>
<v-btn
text
:loading="isLoading"
:disabled="isLoading"
@click="switchToClientPanel"
class="mx-2"
>
<v-icon left>mdi-swap-horizontal</v-icon>
{{ $t('clientPanel') || 'Switch to Client Panel' }}
</v-btn>
<v-snackbar
v-model="showError"
color="error"
timeout="5000"
bottom
>
{{ error }}
<template v-slot:action="{ attrs }">
<v-btn
text
v-bind="attrs"
@click="showError = false"
>
Close
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
export default {
name: 'PanelSwitcher',
computed: {
...mapState('panelSwitcher', ['isLoading', 'error']),
showError: {
get() {
return this.error !== null;
},
set(value) {
if (!value) {
this.$store.commit('panelSwitcher/SET_ERROR', null);
}
}
}
},
methods: {
...mapActions('panelSwitcher', ['switchToClientPanel'])
}
}
</script>
Vuex Store Module
Create a Vuex store module for each panel to handle the panel switching:
// Client Panel: store/modules/panelSwitcher.js
import toast from "@/services/Notification";
import HTTP from "@/_helpers/http";
// State
const state = {
ssoUrl: null,
isLoading: false,
error: null
};
// Getters
const getters = {};
// Mutations
const mutations = {
SET_LOADING(state, isLoading) {
state.isLoading = isLoading;
},
SET_ERROR(state, error) {
state.error = error;
},
SET_SSO_URL(state, url) {
state.ssoUrl = url;
}
};
// Actions
const actions = {
async switchToPartnerPanel({ commit }) {
commit('SET_LOADING', true);
commit('SET_ERROR', null);
try {
const response = await HTTP({
trackProgress: true,
loadingText: 'Preparing panel switch...'
}).get('/switch-to-partner');
if (response.status === 200 && response.data.type === 'success') {
commit('SET_SSO_URL', response.data.ssoUrl);
// Redirect to partner panel
window.location.href = response.data.ssoUrl;
} else {
commit('SET_ERROR', response.data.message || 'Failed to switch panel');
toast.error(response.data.message || 'Failed to switch panel');
}
} catch (error) {
console.error(error);
commit('SET_ERROR', 'Connection error, please try again');
toast.error('Connection error, please try again');
} finally {
commit('SET_LOADING', false);
}
}
};
export default {
namespaced: true,
state,
getters,
mutations,
actions
};
Create a similar module for the Partner Panel, but with **switchToClientPanel **instead.
Security Considerations
This implementation includes several security measures:
- Secure Key Management: The encryption key is stored in the database, not hardcoded in the application
- Strong Encryption: We use AES-256-CBC with a proper 32-byte key derived from SHA-256
- Minimal Data Transfer: Only the user ID is encrypted and passed between panels
- Permission Verification: Users must have the proper permissions to access each panel
- Activity Logging: All login and logout activities are recorded
- Error Handling: Comprehensive error handling prevents security issues from improper use
Top comments (0)