1483 lines
40 KiB
Markdown
1483 lines
40 KiB
Markdown
|
|
# 🔐 Keycloak Custom Login Implementation Guide
|
||
|
|
|
||
|
|
## 📋 **Table of Contents**
|
||
|
|
|
||
|
|
1. [Overview & Architecture](#overview--architecture)
|
||
|
|
2. [Keycloak Configuration](#keycloak-configuration)
|
||
|
|
3. [Environment Setup](#environment-setup)
|
||
|
|
4. [Server-Side Implementation](#server-side-implementation)
|
||
|
|
5. [Client-Side Implementation](#client-side-implementation)
|
||
|
|
6. [Authentication Flow](#authentication-flow)
|
||
|
|
7. [Session Management](#session-management)
|
||
|
|
8. [Security Implementation](#security-implementation)
|
||
|
|
9. [Middleware System](#middleware-system)
|
||
|
|
10. [Error Handling](#error-handling)
|
||
|
|
11. [Testing & Debugging](#testing--debugging)
|
||
|
|
12. [Production Considerations](#production-considerations)
|
||
|
|
13. [Troubleshooting](#troubleshooting)
|
||
|
|
|
||
|
|
## 🏗️ **Overview & Architecture**
|
||
|
|
|
||
|
|
### **Why Custom Login Instead of Keycloak Hosted Pages**
|
||
|
|
|
||
|
|
**Benefits:**
|
||
|
|
- ✅ **Complete UI Control**: Custom branding, responsive design, mobile optimization
|
||
|
|
- ✅ **Better UX**: No redirects to external domains, seamless user experience
|
||
|
|
- ✅ **Integration**: Easy integration with existing Nuxt/Vue components
|
||
|
|
- ✅ **Mobile Compatibility**: Full control over mobile browser behavior
|
||
|
|
- ✅ **Performance**: No external redirects, faster login experience
|
||
|
|
|
||
|
|
**Trade-offs:**
|
||
|
|
- ⚠️ **More Complex**: Must handle security, session management, token validation
|
||
|
|
- ⚠️ **Security Responsibility**: Must implement proper rate limiting, CSRF protection
|
||
|
|
- ⚠️ **Maintenance**: More code to maintain vs. Keycloak's hosted pages
|
||
|
|
|
||
|
|
### **Architecture Overview**
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
graph TD
|
||
|
|
A[Client Browser] --> B[Nuxt App]
|
||
|
|
B --> C[Custom Login Page]
|
||
|
|
C --> D[Server API Route]
|
||
|
|
D --> E[Keycloak Token Endpoint]
|
||
|
|
E --> F[Return Access Token]
|
||
|
|
F --> D
|
||
|
|
D --> G[Create Encrypted Session]
|
||
|
|
G --> H[Set HTTP-Only Cookie]
|
||
|
|
H --> B
|
||
|
|
B --> I[Authenticated App]
|
||
|
|
```
|
||
|
|
|
||
|
|
### **Tech Stack**
|
||
|
|
|
||
|
|
- **Frontend**: Nuxt 3 + Vue 3 + Vuetify 3
|
||
|
|
- **Backend**: Nuxt 3 Server Routes
|
||
|
|
- **Authentication**: Keycloak (Resource Owner Password Credentials flow)
|
||
|
|
- **Session**: Encrypted server-side session storage
|
||
|
|
- **Security**: Rate limiting, input validation, CSRF protection
|
||
|
|
|
||
|
|
## 🔧 **Keycloak Configuration**
|
||
|
|
|
||
|
|
### **1. Client Configuration**
|
||
|
|
|
||
|
|
In Keycloak Admin Console:
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// Client Settings
|
||
|
|
{
|
||
|
|
clientId: "monacousa-portal",
|
||
|
|
clientType: "confidential", // Important: Must be confidential
|
||
|
|
standardFlowEnabled: false, // Disable standard flow
|
||
|
|
directAccessGrantsEnabled: true, // Enable direct access grants
|
||
|
|
serviceAccountsEnabled: false,
|
||
|
|
publicClient: false,
|
||
|
|
|
||
|
|
// Valid Redirect URIs (for OAuth fallback if needed)
|
||
|
|
redirectUris: [
|
||
|
|
"https://yourdomain.com/auth/callback",
|
||
|
|
"http://localhost:3000/auth/callback" // Dev only
|
||
|
|
],
|
||
|
|
|
||
|
|
// Root URL
|
||
|
|
rootUrl: "https://yourdomain.com",
|
||
|
|
adminUrl: "https://yourdomain.com",
|
||
|
|
baseUrl: "https://yourdomain.com",
|
||
|
|
|
||
|
|
// Advanced Settings
|
||
|
|
accessTokenLifespan: "15 minutes",
|
||
|
|
ssoSessionIdleTimeout: "30 minutes",
|
||
|
|
ssoSessionMaxLifespan: "12 hours"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. Required Scopes**
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// Default Client Scopes (include these)
|
||
|
|
[
|
||
|
|
"openid", // OpenID Connect
|
||
|
|
"profile", // User profile information
|
||
|
|
"email", // Email address
|
||
|
|
"roles", // User roles/groups
|
||
|
|
"groups" // User groups (if using)
|
||
|
|
]
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. User Groups/Roles Setup**
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// Example Group Structure
|
||
|
|
{
|
||
|
|
groups: [
|
||
|
|
{
|
||
|
|
name: "admin",
|
||
|
|
description: "System administrators"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "board",
|
||
|
|
description: "Board members"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "user",
|
||
|
|
description: "Regular users"
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### **4. Keycloak Client Secret**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# In Keycloak Admin Console:
|
||
|
|
# Clients → [Your Client] → Credentials → Client Secret
|
||
|
|
# Copy this secret for environment variables
|
||
|
|
```
|
||
|
|
|
||
|
|
## 🌐 **Environment Setup**
|
||
|
|
|
||
|
|
### **Environment Variables**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# .env
|
||
|
|
# Keycloak Configuration
|
||
|
|
NUXT_KEYCLOAK_ISSUER=https://auth.yourdomain.com/realms/your-realm
|
||
|
|
NUXT_KEYCLOAK_CLIENT_ID=your-client-id
|
||
|
|
NUXT_KEYCLOAK_CLIENT_SECRET=your-client-secret
|
||
|
|
NUXT_KEYCLOAK_CALLBACK_URL=https://yourdomain.com/auth/callback
|
||
|
|
|
||
|
|
# Session Security
|
||
|
|
NUXT_SESSION_SECRET=your-48-character-session-secret-key-here-123456
|
||
|
|
NUXT_ENCRYPTION_KEY=your-32-character-encryption-key-here12
|
||
|
|
|
||
|
|
# Public Configuration
|
||
|
|
NUXT_PUBLIC_DOMAIN=yourdomain.com
|
||
|
|
```
|
||
|
|
|
||
|
|
### **Nuxt Configuration**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// nuxt.config.ts
|
||
|
|
export default defineNuxtConfig({
|
||
|
|
runtimeConfig: {
|
||
|
|
// Private (server-side only)
|
||
|
|
keycloak: {
|
||
|
|
issuer: process.env.NUXT_KEYCLOAK_ISSUER,
|
||
|
|
clientId: process.env.NUXT_KEYCLOAK_CLIENT_ID,
|
||
|
|
clientSecret: process.env.NUXT_KEYCLOAK_CLIENT_SECRET,
|
||
|
|
callbackUrl: process.env.NUXT_KEYCLOAK_CALLBACK_URL
|
||
|
|
},
|
||
|
|
sessionSecret: process.env.NUXT_SESSION_SECRET,
|
||
|
|
encryptionKey: process.env.NUXT_ENCRYPTION_KEY,
|
||
|
|
|
||
|
|
// Public (client-side accessible)
|
||
|
|
public: {
|
||
|
|
domain: process.env.NUXT_PUBLIC_DOMAIN
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
// SSR Configuration for auth
|
||
|
|
ssr: false, // or configure properly for SSR
|
||
|
|
|
||
|
|
// Security headers
|
||
|
|
nitro: {
|
||
|
|
experimental: {
|
||
|
|
wasm: true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
## 🖥️ **Server-Side Implementation**
|
||
|
|
|
||
|
|
### **1. Direct Login API Route**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// server/api/auth/direct-login.post.ts
|
||
|
|
export default defineEventHandler(async (event) => {
|
||
|
|
try {
|
||
|
|
const { username, password, rememberMe } = await readBody(event);
|
||
|
|
const config = useRuntimeConfig();
|
||
|
|
|
||
|
|
// Input validation
|
||
|
|
if (!username || !password) {
|
||
|
|
throw createError({
|
||
|
|
statusCode: 400,
|
||
|
|
statusMessage: 'Username and password are required'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Rate limiting
|
||
|
|
const clientIP = getClientIP(event);
|
||
|
|
if (!checkRateLimit(clientIP)) {
|
||
|
|
throw createError({
|
||
|
|
statusCode: 429,
|
||
|
|
statusMessage: 'Too many login attempts'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Direct authentication with Keycloak
|
||
|
|
const tokenResponse = await fetch(`${config.keycloak.issuer}/protocol/openid-connect/token`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||
|
|
'User-Agent': 'YourApp/1.0'
|
||
|
|
},
|
||
|
|
body: new URLSearchParams({
|
||
|
|
grant_type: 'password',
|
||
|
|
client_id: config.keycloak.clientId,
|
||
|
|
client_secret: config.keycloak.clientSecret,
|
||
|
|
username,
|
||
|
|
password,
|
||
|
|
scope: 'openid email profile roles'
|
||
|
|
})
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!tokenResponse.ok) {
|
||
|
|
throw createError({
|
||
|
|
statusCode: 401,
|
||
|
|
statusMessage: 'Invalid credentials'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const tokens = await tokenResponse.json();
|
||
|
|
|
||
|
|
// Get user info from Keycloak
|
||
|
|
const userResponse = await fetch(`${config.keycloak.issuer}/protocol/openid-connect/userinfo`, {
|
||
|
|
headers: {
|
||
|
|
'Authorization': `Bearer ${tokens.access_token}`
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
const userInfo = await userResponse.json();
|
||
|
|
|
||
|
|
// Extract user groups and determine tier
|
||
|
|
const groups = extractUserGroups(tokens, userInfo);
|
||
|
|
const userTier = determineTier(groups);
|
||
|
|
|
||
|
|
// Create session
|
||
|
|
const sessionData = {
|
||
|
|
user: {
|
||
|
|
id: userInfo.sub,
|
||
|
|
email: userInfo.email,
|
||
|
|
name: userInfo.name,
|
||
|
|
firstName: userInfo.given_name,
|
||
|
|
lastName: userInfo.family_name,
|
||
|
|
tier: userTier,
|
||
|
|
groups: groups
|
||
|
|
},
|
||
|
|
tokens: {
|
||
|
|
accessToken: tokens.access_token,
|
||
|
|
refreshToken: tokens.refresh_token,
|
||
|
|
expiresAt: Date.now() + (tokens.expires_in * 1000)
|
||
|
|
},
|
||
|
|
rememberMe: !!rememberMe,
|
||
|
|
createdAt: Date.now(),
|
||
|
|
lastActivity: Date.now()
|
||
|
|
};
|
||
|
|
|
||
|
|
// Create encrypted session cookie
|
||
|
|
const sessionManager = createSessionManager();
|
||
|
|
const cookieString = sessionManager.createSession(sessionData, !!rememberMe);
|
||
|
|
|
||
|
|
// Set secure cookie
|
||
|
|
const maxAge = !!rememberMe ? 60 * 60 * 24 * 30 : 60 * 60 * 24 * 7;
|
||
|
|
const isProduction = process.env.NODE_ENV === 'production';
|
||
|
|
|
||
|
|
setCookie(event, 'your-session-name', cookieString.split('=')[1], {
|
||
|
|
httpOnly: true,
|
||
|
|
secure: isProduction,
|
||
|
|
sameSite: 'lax',
|
||
|
|
maxAge,
|
||
|
|
path: '/'
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
redirectTo: '/dashboard',
|
||
|
|
user: {
|
||
|
|
email: userInfo.email,
|
||
|
|
name: userInfo.name,
|
||
|
|
tier: userTier
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
} catch (error) {
|
||
|
|
// Error handling
|
||
|
|
throw createError({
|
||
|
|
statusCode: error.statusCode || 500,
|
||
|
|
statusMessage: error.statusMessage || 'Authentication failed'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. Session Validation API**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// server/api/auth/session.get.ts
|
||
|
|
export default defineEventHandler(async (event) => {
|
||
|
|
try {
|
||
|
|
const sessionManager = createSessionManager();
|
||
|
|
const sessionData = sessionManager.getSession(event);
|
||
|
|
|
||
|
|
if (!sessionData || !sessionData.user) {
|
||
|
|
return {
|
||
|
|
authenticated: false,
|
||
|
|
user: null
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if token needs refresh
|
||
|
|
if (sessionData.tokens.expiresAt < Date.now() + 60000) { // 1 minute buffer
|
||
|
|
try {
|
||
|
|
const refreshedTokens = await refreshKeycloakToken(sessionData.tokens.refreshToken);
|
||
|
|
|
||
|
|
// Update session with new tokens
|
||
|
|
sessionData.tokens = {
|
||
|
|
accessToken: refreshedTokens.access_token,
|
||
|
|
refreshToken: refreshedTokens.refresh_token,
|
||
|
|
expiresAt: Date.now() + (refreshedTokens.expires_in * 1000)
|
||
|
|
};
|
||
|
|
|
||
|
|
sessionManager.updateSession(event, sessionData);
|
||
|
|
} catch (refreshError) {
|
||
|
|
console.warn('Token refresh failed:', refreshError);
|
||
|
|
// Session is invalid, force logout
|
||
|
|
return {
|
||
|
|
authenticated: false,
|
||
|
|
user: null
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update last activity
|
||
|
|
sessionData.lastActivity = Date.now();
|
||
|
|
sessionManager.updateSession(event, sessionData);
|
||
|
|
|
||
|
|
return {
|
||
|
|
authenticated: true,
|
||
|
|
user: sessionData.user
|
||
|
|
};
|
||
|
|
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Session validation error:', error);
|
||
|
|
return {
|
||
|
|
authenticated: false,
|
||
|
|
user: null
|
||
|
|
};
|
||
|
|
}
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. Session Management Utilities**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// server/utils/session.ts
|
||
|
|
import crypto from 'crypto';
|
||
|
|
|
||
|
|
interface SessionData {
|
||
|
|
user: User;
|
||
|
|
tokens: {
|
||
|
|
accessToken: string;
|
||
|
|
refreshToken: string;
|
||
|
|
expiresAt: number;
|
||
|
|
};
|
||
|
|
rememberMe: boolean;
|
||
|
|
createdAt: number;
|
||
|
|
lastActivity: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const createSessionManager = () => {
|
||
|
|
const config = useRuntimeConfig();
|
||
|
|
|
||
|
|
const encryptData = (data: any): string => {
|
||
|
|
const cipher = crypto.createCipher('aes-256-cbc', config.encryptionKey);
|
||
|
|
let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex');
|
||
|
|
encrypted += cipher.final('hex');
|
||
|
|
return encrypted;
|
||
|
|
};
|
||
|
|
|
||
|
|
const decryptData = (encryptedData: string): any => {
|
||
|
|
try {
|
||
|
|
const decipher = crypto.createDecipher('aes-256-cbc', config.encryptionKey);
|
||
|
|
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
|
||
|
|
decrypted += decipher.final('utf8');
|
||
|
|
return JSON.parse(decrypted);
|
||
|
|
} catch (error) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const createSession = (sessionData: SessionData, rememberMe: boolean): string => {
|
||
|
|
const sessionId = crypto.randomUUID();
|
||
|
|
const encryptedData = encryptData(sessionData);
|
||
|
|
|
||
|
|
// Store in server-side storage (memory, database, Redis, etc.)
|
||
|
|
serverSessionStorage.set(sessionId, encryptedData);
|
||
|
|
|
||
|
|
return `session-id=${sessionId}`;
|
||
|
|
};
|
||
|
|
|
||
|
|
const getSession = (event: any): SessionData | null => {
|
||
|
|
const sessionId = getCookie(event, 'your-session-name');
|
||
|
|
if (!sessionId) return null;
|
||
|
|
|
||
|
|
const encryptedData = serverSessionStorage.get(sessionId);
|
||
|
|
if (!encryptedData) return null;
|
||
|
|
|
||
|
|
return decryptData(encryptedData);
|
||
|
|
};
|
||
|
|
|
||
|
|
return {
|
||
|
|
createSession,
|
||
|
|
getSession,
|
||
|
|
updateSession: (event: any, sessionData: SessionData) => {
|
||
|
|
const sessionId = getCookie(event, 'your-session-name');
|
||
|
|
if (sessionId) {
|
||
|
|
const encryptedData = encryptData(sessionData);
|
||
|
|
serverSessionStorage.set(sessionId, encryptedData);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
destroySession: (event: any) => {
|
||
|
|
const sessionId = getCookie(event, 'your-session-name');
|
||
|
|
if (sessionId) {
|
||
|
|
serverSessionStorage.delete(sessionId);
|
||
|
|
deleteCookie(event, 'your-session-name');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
## 🎨 **Client-Side Implementation**
|
||
|
|
|
||
|
|
### **1. Authentication Composable**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// composables/useAuth.ts
|
||
|
|
export const useAuth = () => {
|
||
|
|
// Use useState for SSR compatibility
|
||
|
|
const user = useState<User | null>('auth.user', () => null);
|
||
|
|
const isAuthenticated = computed(() => !!user.value);
|
||
|
|
const loading = ref(false);
|
||
|
|
const error = ref<string | null>(null);
|
||
|
|
|
||
|
|
// Tier-based computed properties
|
||
|
|
const userTier = computed(() => user.value?.tier || 'user');
|
||
|
|
const isUser = computed(() => user.value?.tier === 'user');
|
||
|
|
const isBoard = computed(() => user.value?.tier === 'board');
|
||
|
|
const isAdmin = computed(() => user.value?.tier === 'admin');
|
||
|
|
|
||
|
|
// Login method
|
||
|
|
const login = async (credentials: LoginCredentials) => {
|
||
|
|
loading.value = true;
|
||
|
|
error.value = null;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await $fetch<LoginResponse>('/api/auth/direct-login', {
|
||
|
|
method: 'POST',
|
||
|
|
body: credentials,
|
||
|
|
timeout: 30000
|
||
|
|
});
|
||
|
|
|
||
|
|
if (response.success) {
|
||
|
|
// Wait for cookie to be set
|
||
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||
|
|
|
||
|
|
// Verify session was created properly
|
||
|
|
let sessionSuccess = false;
|
||
|
|
let attempts = 0;
|
||
|
|
const maxAttempts = 3;
|
||
|
|
|
||
|
|
while (!sessionSuccess && attempts < maxAttempts) {
|
||
|
|
attempts++;
|
||
|
|
sessionSuccess = await checkAuth();
|
||
|
|
|
||
|
|
if (!sessionSuccess && attempts < maxAttempts) {
|
||
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (sessionSuccess) {
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
redirectTo: response.redirectTo || '/dashboard'
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { success: false, error: 'Login failed' };
|
||
|
|
} catch (err: any) {
|
||
|
|
const errorMessage = getErrorMessage(err);
|
||
|
|
error.value = errorMessage;
|
||
|
|
return { success: false, error: errorMessage };
|
||
|
|
} finally {
|
||
|
|
loading.value = false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Check authentication status
|
||
|
|
const checkAuth = async (): Promise<boolean> => {
|
||
|
|
try {
|
||
|
|
const response = await $fetch<SessionResponse>('/api/auth/session');
|
||
|
|
|
||
|
|
if (response.authenticated && response.user) {
|
||
|
|
user.value = response.user;
|
||
|
|
return true;
|
||
|
|
} else {
|
||
|
|
user.value = null;
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
user.value = null;
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Logout method
|
||
|
|
const logout = async () => {
|
||
|
|
try {
|
||
|
|
await $fetch('/api/auth/logout', { method: 'POST' });
|
||
|
|
user.value = null;
|
||
|
|
await navigateTo('/login');
|
||
|
|
} catch (err) {
|
||
|
|
user.value = null;
|
||
|
|
await navigateTo('/login');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return {
|
||
|
|
user: readonly(user),
|
||
|
|
isAuthenticated,
|
||
|
|
loading: readonly(loading),
|
||
|
|
error: readonly(error),
|
||
|
|
userTier,
|
||
|
|
isUser,
|
||
|
|
isBoard,
|
||
|
|
isAdmin,
|
||
|
|
login,
|
||
|
|
logout,
|
||
|
|
checkAuth
|
||
|
|
};
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. Custom Login Page**
|
||
|
|
|
||
|
|
```vue
|
||
|
|
<!-- pages/login.vue -->
|
||
|
|
<template>
|
||
|
|
<div class="login-container">
|
||
|
|
<v-container fluid class="fill-height">
|
||
|
|
<v-row class="fill-height" justify="center" align="center">
|
||
|
|
<v-col cols="12" sm="8" md="6" lg="4" xl="3">
|
||
|
|
<v-card class="login-card" elevation="24">
|
||
|
|
<v-card-text class="pa-8">
|
||
|
|
<!-- Logo and Branding -->
|
||
|
|
<div class="text-center mb-8">
|
||
|
|
<v-img
|
||
|
|
src="/logo.png"
|
||
|
|
width="120"
|
||
|
|
height="120"
|
||
|
|
class="mx-auto mb-4"
|
||
|
|
alt="Company Logo"
|
||
|
|
/>
|
||
|
|
<h1 class="text-h4 font-weight-bold mb-2">
|
||
|
|
Welcome Back
|
||
|
|
</h1>
|
||
|
|
<p class="text-body-1 text-medium-emphasis">
|
||
|
|
Sign in to your portal
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Login Form -->
|
||
|
|
<v-form @submit.prevent="handleLogin" ref="loginForm">
|
||
|
|
<v-text-field
|
||
|
|
v-model="credentials.username"
|
||
|
|
label="Username or Email"
|
||
|
|
prepend-inner-icon="mdi-account"
|
||
|
|
variant="outlined"
|
||
|
|
:error-messages="errors.username"
|
||
|
|
:disabled="loading"
|
||
|
|
class="mb-3"
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
|
||
|
|
<v-text-field
|
||
|
|
v-model="credentials.password"
|
||
|
|
:type="showPassword ? 'text' : 'password'"
|
||
|
|
label="Password"
|
||
|
|
prepend-inner-icon="mdi-lock"
|
||
|
|
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
||
|
|
@click:append-inner="showPassword = !showPassword"
|
||
|
|
variant="outlined"
|
||
|
|
:error-messages="errors.password"
|
||
|
|
:disabled="loading"
|
||
|
|
class="mb-3"
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
|
||
|
|
<div class="d-flex justify-space-between align-center mb-6">
|
||
|
|
<v-checkbox
|
||
|
|
v-model="credentials.rememberMe"
|
||
|
|
label="Remember me"
|
||
|
|
density="compact"
|
||
|
|
:disabled="loading"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Error Alert -->
|
||
|
|
<v-alert
|
||
|
|
v-if="loginError"
|
||
|
|
type="error"
|
||
|
|
variant="tonal"
|
||
|
|
class="mb-4"
|
||
|
|
closable
|
||
|
|
@click:close="loginError = ''"
|
||
|
|
>
|
||
|
|
{{ loginError }}
|
||
|
|
</v-alert>
|
||
|
|
|
||
|
|
<!-- Login Button -->
|
||
|
|
<v-btn
|
||
|
|
type="submit"
|
||
|
|
color="primary"
|
||
|
|
size="large"
|
||
|
|
block
|
||
|
|
:loading="loading"
|
||
|
|
:disabled="!isFormValid"
|
||
|
|
class="mb-4"
|
||
|
|
>
|
||
|
|
<v-icon left>mdi-login</v-icon>
|
||
|
|
Sign In
|
||
|
|
</v-btn>
|
||
|
|
</v-form>
|
||
|
|
</v-card-text>
|
||
|
|
</v-card>
|
||
|
|
</v-col>
|
||
|
|
</v-row>
|
||
|
|
</v-container>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup lang="ts">
|
||
|
|
definePageMeta({
|
||
|
|
layout: false,
|
||
|
|
middleware: 'guest' // Prevents authenticated users from accessing login
|
||
|
|
});
|
||
|
|
|
||
|
|
const { login, loading: authLoading, error: authError } = useAuth();
|
||
|
|
|
||
|
|
// Form state
|
||
|
|
const credentials = ref({
|
||
|
|
username: '',
|
||
|
|
password: '',
|
||
|
|
rememberMe: false
|
||
|
|
});
|
||
|
|
|
||
|
|
const showPassword = ref(false);
|
||
|
|
const loginError = ref('');
|
||
|
|
const errors = ref({
|
||
|
|
username: '',
|
||
|
|
password: ''
|
||
|
|
});
|
||
|
|
|
||
|
|
const loading = computed(() => authLoading.value);
|
||
|
|
const isFormValid = computed(() => {
|
||
|
|
return credentials.value.username.length > 0 &&
|
||
|
|
credentials.value.password.length > 0 &&
|
||
|
|
!loading.value;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Form validation
|
||
|
|
const validateForm = () => {
|
||
|
|
errors.value = { username: '', password: '' };
|
||
|
|
let isValid = true;
|
||
|
|
|
||
|
|
if (!credentials.value.username || credentials.value.username.length < 2) {
|
||
|
|
errors.value.username = 'Username must be at least 2 characters';
|
||
|
|
isValid = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!credentials.value.password || credentials.value.password.length < 6) {
|
||
|
|
errors.value.password = 'Password must be at least 6 characters';
|
||
|
|
isValid = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return isValid;
|
||
|
|
};
|
||
|
|
|
||
|
|
// Handle login submission
|
||
|
|
const handleLogin = async () => {
|
||
|
|
if (!validateForm()) return;
|
||
|
|
|
||
|
|
loginError.value = '';
|
||
|
|
|
||
|
|
try {
|
||
|
|
const result = await login({
|
||
|
|
username: credentials.value.username,
|
||
|
|
password: credentials.value.password,
|
||
|
|
rememberMe: credentials.value.rememberMe
|
||
|
|
});
|
||
|
|
|
||
|
|
if (result.success) {
|
||
|
|
await navigateTo(result.redirectTo || '/dashboard');
|
||
|
|
} else {
|
||
|
|
loginError.value = result.error || 'Login failed. Please check your credentials.';
|
||
|
|
}
|
||
|
|
} catch (error: any) {
|
||
|
|
loginError.value = authError.value || 'Login failed. Please try again.';
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Auto-focus username field
|
||
|
|
onMounted(() => {
|
||
|
|
nextTick(() => {
|
||
|
|
const usernameField = document.querySelector('input[type="text"]') as HTMLInputElement;
|
||
|
|
if (usernameField) {
|
||
|
|
usernameField.focus();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. Auth Plugin (Initialize Auth State)**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// plugins/01.auth-check.client.ts
|
||
|
|
export default defineNuxtPlugin(async () => {
|
||
|
|
const { checkAuth } = useAuth();
|
||
|
|
|
||
|
|
// Check authentication status on app startup
|
||
|
|
await checkAuth();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## 🛡️ **Security Implementation**
|
||
|
|
|
||
|
|
### **1. Rate Limiting**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// server/utils/security.ts
|
||
|
|
const loginAttempts = new Map<string, { count: number; lastAttempt: number }>();
|
||
|
|
const blockedIPs = new Map<string, number>();
|
||
|
|
|
||
|
|
export const checkRateLimit = (ip: string): { allowed: boolean; attemptsLeft: number } => {
|
||
|
|
const now = Date.now();
|
||
|
|
const maxAttempts = 5;
|
||
|
|
const windowMs = 15 * 60 * 1000; // 15 minutes
|
||
|
|
const blockDurationMs = 60 * 60 * 1000; // 1 hour
|
||
|
|
|
||
|
|
// Check if IP is blocked
|
||
|
|
const blockedUntil = blockedIPs.get(ip);
|
||
|
|
if (blockedUntil && now < blockedUntil) {
|
||
|
|
return { allowed: false, attemptsLeft: 0 };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clear expired blocks
|
||
|
|
if (blockedUntil && now >= blockedUntil) {
|
||
|
|
blockedIPs.delete(ip);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check current attempts
|
||
|
|
const attempts = loginAttempts.get(ip);
|
||
|
|
if (attempts && (now - attempts.lastAttempt) > windowMs) {
|
||
|
|
loginAttempts.delete(ip);
|
||
|
|
return { allowed: true, attemptsLeft: maxAttempts };
|
||
|
|
}
|
||
|
|
|
||
|
|
const currentCount = attempts?.count || 0;
|
||
|
|
if (currentCount >= maxAttempts) {
|
||
|
|
blockedIPs.set(ip, now + blockDurationMs);
|
||
|
|
return { allowed: false, attemptsLeft: 0 };
|
||
|
|
}
|
||
|
|
|
||
|
|
return { allowed: true, attemptsLeft: maxAttempts - currentCount };
|
||
|
|
};
|
||
|
|
|
||
|
|
export const recordFailedAttempt = (ip: string): void => {
|
||
|
|
const now = Date.now();
|
||
|
|
const attempts = loginAttempts.get(ip);
|
||
|
|
|
||
|
|
if (attempts) {
|
||
|
|
attempts.count += 1;
|
||
|
|
attempts.lastAttempt = now;
|
||
|
|
} else {
|
||
|
|
loginAttempts.set(ip, { count: 1, lastAttempt: now });
|
||
|
|
}
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. Input Validation**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// server/utils/validation.ts
|
||
|
|
export const validateLoginInput = (username: string, password: string): string[] => {
|
||
|
|
const errors: string[] = [];
|
||
|
|
|
||
|
|
// Username validation
|
||
|
|
if (!username || typeof username !== 'string') {
|
||
|
|
errors.push('Username is required');
|
||
|
|
} else if (username.length < 2) {
|
||
|
|
errors.push('Username must be at least 2 characters');
|
||
|
|
} else if (username.length > 100) {
|
||
|
|
errors.push('Username is too long');
|
||
|
|
} else if (!/^[a-zA-Z0-9@._-]+$/.test(username)) {
|
||
|
|
errors.push('Username contains invalid characters');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Password validation
|
||
|
|
if (!password || typeof password !== 'string') {
|
||
|
|
errors.push('Password is required');
|
||
|
|
} else if (password.length < 6) {
|
||
|
|
errors.push('Password must be at least 6 characters');
|
||
|
|
} else if (password.length > 200) {
|
||
|
|
errors.push('Password is too long');
|
||
|
|
}
|
||
|
|
|
||
|
|
return errors;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const getClientIP = (event: any): string => {
|
||
|
|
const headers = getHeaders(event);
|
||
|
|
return (
|
||
|
|
headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||
|
|
headers['x-real-ip'] ||
|
||
|
|
headers['x-client-ip'] ||
|
||
|
|
headers['cf-connecting-ip'] ||
|
||
|
|
'unknown'
|
||
|
|
);
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. Session Security**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Secure cookie configuration
|
||
|
|
setCookie(event, 'session-name', sessionId, {
|
||
|
|
httpOnly: true, // Prevent XSS attacks
|
||
|
|
secure: isProduction, // HTTPS only in production
|
||
|
|
sameSite: 'lax', // CSRF protection
|
||
|
|
maxAge: rememberMe ? 2592000 : 604800, // 30 days vs 7 days
|
||
|
|
path: '/', // Available site-wide
|
||
|
|
domain: process.env.NUXT_PUBLIC_DOMAIN ? `.${process.env.NUXT_PUBLIC_DOMAIN}` : undefined
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## 🔄 **Authentication Flow**
|
||
|
|
|
||
|
|
### **Complete Authentication Flow Diagram**
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
sequenceDiagram
|
||
|
|
participant U as User
|
||
|
|
participant C as Client App
|
||
|
|
participant S as Server API
|
||
|
|
participant K as Keycloak
|
||
|
|
participant D as Database
|
||
|
|
|
||
|
|
U->>C: Navigate to /login
|
||
|
|
C->>C: Guest middleware (allow if not authenticated)
|
||
|
|
C->>U: Show login form
|
||
|
|
|
||
|
|
U->>C: Submit credentials
|
||
|
|
C->>S: POST /api/auth/direct-login
|
||
|
|
S->>S: Validate input & rate limiting
|
||
|
|
S->>K: POST /token (ROPC flow)
|
||
|
|
K->>S: Return access token + refresh token
|
||
|
|
S->>K: GET /userinfo (with access token)
|
||
|
|
K->>S: Return user profile
|
||
|
|
S->>S: Extract groups/roles, determine tier
|
||
|
|
S->>D: Create encrypted session
|
||
|
|
S->>C: Set secure cookie + return success
|
||
|
|
C->>C: Navigate to /dashboard
|
||
|
|
C->>S: GET /api/auth/session (via cookie)
|
||
|
|
S->>S: Decrypt session, validate tokens
|
||
|
|
S->>C: Return user data
|
||
|
|
C->>C: Auth middleware passes
|
||
|
|
C->>U: Show authenticated dashboard
|
||
|
|
```
|
||
|
|
|
||
|
|
### **Session Lifecycle**
|
||
|
|
|
||
|
|
1. **Login**: User provides credentials → Server validates with Keycloak → Creates encrypted session
|
||
|
|
2. **Session Check**: Client calls `/api/auth/session` → Server validates session → Returns user data
|
||
|
|
3. **Token Refresh**: Server automatically refreshes tokens before expiry
|
||
|
|
4. **Logout**: Client calls `/api/auth/logout` → Server destroys session → Redirects to login
|
||
|
|
|
||
|
|
## 🛠️ **Middleware System**
|
||
|
|
|
||
|
|
### **1. Auth Middleware (Protect Routes)**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// middleware/auth.ts
|
||
|
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||
|
|
if (to.meta.auth === false) {
|
||
|
|
return; // Skip auth for public pages
|
||
|
|
}
|
||
|
|
|
||
|
|
const { isAuthenticated, checkAuth, user } = useAuth();
|
||
|
|
|
||
|
|
// Ensure auth is checked if user isn't loaded
|
||
|
|
if (!user.value) {
|
||
|
|
await checkAuth();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!isAuthenticated.value) {
|
||
|
|
return navigateTo('/login');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. Guest Middleware (Redirect Authenticated Users)**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// middleware/guest.ts
|
||
|
|
export default defineNuxtRouteMiddleware((to, from) => {
|
||
|
|
const { user } = useAuth();
|
||
|
|
|
||
|
|
// If user is already authenticated, redirect to dashboard
|
||
|
|
if (user.value) {
|
||
|
|
return navigateTo('/dashboard');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. Role-Based Middleware**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// middleware/auth-admin.ts
|
||
|
|
export default defineNuxtRouteMiddleware((to, from) => {
|
||
|
|
const { isAuthenticated, isAdmin } = useAuth();
|
||
|
|
|
||
|
|
if (!isAuthenticated.value) {
|
||
|
|
return navigateTo('/login');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!isAdmin.value) {
|
||
|
|
throw createError({
|
||
|
|
statusCode: 403,
|
||
|
|
statusMessage: 'Access denied. Administrator privileges required.'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## 🧪 **Testing & Debugging**
|
||
|
|
|
||
|
|
### **1. Testing Checklist**
|
||
|
|
|
||
|
|
**Backend Testing:**
|
||
|
|
```bash
|
||
|
|
# Test direct login endpoint
|
||
|
|
curl -X POST http://localhost:3000/api/auth/direct-login \
|
||
|
|
-H "Content-Type: application/json" \
|
||
|
|
-d '{"username":"test@example.com","password":"password123"}'
|
||
|
|
|
||
|
|
# Test session endpoint with cookie
|
||
|
|
curl -X GET http://localhost:3000/api/auth/session \
|
||
|
|
-H "Cookie: your-session-name=session-id-here"
|
||
|
|
|
||
|
|
# Test logout endpoint
|
||
|
|
curl -X POST http://localhost:3000/api/auth/logout \
|
||
|
|
-H "Cookie: your-session-name=session-id-here"
|
||
|
|
```
|
||
|
|
|
||
|
|
**Frontend Testing:**
|
||
|
|
- [ ] **Login Form Validation**: Test empty fields, short passwords, invalid characters
|
||
|
|
- [ ] **Login Flow**: Valid credentials → successful login → redirect to dashboard
|
||
|
|
- [ ] **Error Handling**: Invalid credentials → proper error messages
|
||
|
|
- [ ] **Session Management**: Page refresh → user stays logged in
|
||
|
|
- [ ] **Mobile Compatibility**: Test on iOS Safari, Android Chrome
|
||
|
|
- [ ] **Remember Me**: Long-term sessions work correctly
|
||
|
|
|
||
|
|
**Security Testing:**
|
||
|
|
- [ ] **Rate Limiting**: 5+ failed attempts → IP blocked for 1 hour
|
||
|
|
- [ ] **CSRF Protection**: Direct API calls without cookies → rejected
|
||
|
|
- [ ] **XSS Protection**: Malicious input → properly sanitized
|
||
|
|
- [ ] **Session Security**: HttpOnly cookies → not accessible via JavaScript
|
||
|
|
|
||
|
|
### **2. Debug Tools**
|
||
|
|
|
||
|
|
**Server-Side Debugging:**
|
||
|
|
```typescript
|
||
|
|
// Add debug logging in server routes
|
||
|
|
console.log('🔄 Login attempt:', {
|
||
|
|
username: credentials.username,
|
||
|
|
ip: getClientIP(event),
|
||
|
|
timestamp: new Date().toISOString()
|
||
|
|
});
|
||
|
|
|
||
|
|
console.log('✅ Keycloak response:', {
|
||
|
|
status: tokenResponse.status,
|
||
|
|
hasTokens: !!tokens.access_token,
|
||
|
|
userEmail: userInfo.email,
|
||
|
|
groups: extractedGroups
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**Client-Side Debugging:**
|
||
|
|
```typescript
|
||
|
|
// Add debug logging in composables
|
||
|
|
const checkAuth = async (): Promise<boolean> => {
|
||
|
|
console.log('🔄 Checking authentication...');
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await $fetch<SessionResponse>('/api/auth/session');
|
||
|
|
|
||
|
|
console.log('🔍 Session response:', {
|
||
|
|
authenticated: response.authenticated,
|
||
|
|
user: response.user?.email,
|
||
|
|
tier: response.user?.tier
|
||
|
|
});
|
||
|
|
|
||
|
|
// ... rest of method
|
||
|
|
} catch (err) {
|
||
|
|
console.error('❌ Auth check failed:', err);
|
||
|
|
// ... error handling
|
||
|
|
}
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
**Browser DevTools Inspection:**
|
||
|
|
- **Network Tab**: Monitor API calls to `/api/auth/*`
|
||
|
|
- **Application Tab**: Check cookies are set correctly
|
||
|
|
- **Console Tab**: Review authentication logs
|
||
|
|
- **Sources Tab**: Set breakpoints in auth flow
|
||
|
|
|
||
|
|
## 🚨 **Error Handling**
|
||
|
|
|
||
|
|
### **1. Server-Side Error Handling**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// server/api/auth/direct-login.post.ts
|
||
|
|
export default defineEventHandler(async (event) => {
|
||
|
|
try {
|
||
|
|
// ... authentication logic
|
||
|
|
|
||
|
|
} catch (error: any) {
|
||
|
|
console.error('❌ Login error:', {
|
||
|
|
message: error.message,
|
||
|
|
status: error.status,
|
||
|
|
ip: getClientIP(event),
|
||
|
|
timestamp: new Date().toISOString()
|
||
|
|
});
|
||
|
|
|
||
|
|
// Map specific errors to user-friendly messages
|
||
|
|
if (error.status === 401) {
|
||
|
|
recordFailedAttempt(getClientIP(event));
|
||
|
|
|
||
|
|
throw createError({
|
||
|
|
statusCode: 401,
|
||
|
|
statusMessage: 'Invalid username or password'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (error.status === 429) {
|
||
|
|
throw createError({
|
||
|
|
statusCode: 429,
|
||
|
|
statusMessage: 'Too many login attempts. Please try again later.'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (error.code === 'ECONNREFUSED') {
|
||
|
|
throw createError({
|
||
|
|
statusCode: 503,
|
||
|
|
statusMessage: 'Authentication service temporarily unavailable'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generic error for unexpected issues
|
||
|
|
throw createError({
|
||
|
|
statusCode: 500,
|
||
|
|
statusMessage: 'Login failed. Please try again.'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. Client-Side Error Handling**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// composables/useAuth.ts
|
||
|
|
const getErrorMessage = (err: any): string => {
|
||
|
|
// Handle network errors
|
||
|
|
if (!err.response) {
|
||
|
|
return 'Network error. Please check your connection.';
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle HTTP errors
|
||
|
|
switch (err.status) {
|
||
|
|
case 400:
|
||
|
|
return err.data?.message || 'Invalid request. Please check your input.';
|
||
|
|
case 401:
|
||
|
|
return 'Invalid username or password.';
|
||
|
|
case 403:
|
||
|
|
return 'Access denied. Please contact your administrator.';
|
||
|
|
case 429:
|
||
|
|
return 'Too many login attempts. Please try again later.';
|
||
|
|
case 502:
|
||
|
|
case 503:
|
||
|
|
return 'Service temporarily unavailable. Please try again.';
|
||
|
|
case 504:
|
||
|
|
return 'Request timeout. Please try again.';
|
||
|
|
default:
|
||
|
|
return err.data?.message || 'Login failed. Please try again.';
|
||
|
|
}
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. User-Friendly Error Display**
|
||
|
|
|
||
|
|
```vue
|
||
|
|
<!-- Login page error handling -->
|
||
|
|
<v-alert
|
||
|
|
v-if="loginError"
|
||
|
|
type="error"
|
||
|
|
variant="tonal"
|
||
|
|
class="mb-4"
|
||
|
|
closable
|
||
|
|
@click:close="loginError = ''"
|
||
|
|
>
|
||
|
|
<template v-slot:prepend>
|
||
|
|
<v-icon>mdi-alert-circle</v-icon>
|
||
|
|
</template>
|
||
|
|
<div>
|
||
|
|
<strong>Login Failed</strong>
|
||
|
|
<br>
|
||
|
|
{{ loginError }}
|
||
|
|
</div>
|
||
|
|
<template v-slot:append v-if="showRetryButton">
|
||
|
|
<v-btn
|
||
|
|
variant="text"
|
||
|
|
size="small"
|
||
|
|
@click="retryLogin"
|
||
|
|
>
|
||
|
|
Retry
|
||
|
|
</v-btn>
|
||
|
|
</template>
|
||
|
|
</v-alert>
|
||
|
|
```
|
||
|
|
|
||
|
|
## 🚀 **Production Considerations**
|
||
|
|
|
||
|
|
### **1. Environment Configuration**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Production .env
|
||
|
|
NODE_ENV=production
|
||
|
|
|
||
|
|
# Use strong secrets in production
|
||
|
|
NUXT_SESSION_SECRET=generate-a-very-strong-48-character-secret-key-here
|
||
|
|
NUXT_ENCRYPTION_KEY=generate-strong-32-char-key-here
|
||
|
|
|
||
|
|
# Keycloak production URLs
|
||
|
|
NUXT_KEYCLOAK_ISSUER=https://auth.yourdomain.com/realms/your-realm
|
||
|
|
NUXT_KEYCLOAK_CLIENT_ID=your-production-client
|
||
|
|
NUXT_KEYCLOAK_CLIENT_SECRET=your-production-secret
|
||
|
|
|
||
|
|
# Security settings
|
||
|
|
NUXT_PUBLIC_DOMAIN=yourdomain.com
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. Security Headers**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// nuxt.config.ts
|
||
|
|
export default defineNuxtConfig({
|
||
|
|
nitro: {
|
||
|
|
routeRules: {
|
||
|
|
'/**': {
|
||
|
|
headers: {
|
||
|
|
// Security headers
|
||
|
|
'X-Frame-Options': 'DENY',
|
||
|
|
'X-Content-Type-Options': 'nosniff',
|
||
|
|
'X-XSS-Protection': '1; mode=block',
|
||
|
|
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||
|
|
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
|
||
|
|
|
||
|
|
// HTTPS enforcement
|
||
|
|
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
||
|
|
|
||
|
|
// Content Security Policy
|
||
|
|
'Content-Security-Policy': [
|
||
|
|
"default-src 'self'",
|
||
|
|
"script-src 'self' 'unsafe-inline'",
|
||
|
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||
|
|
"font-src 'self' https://fonts.gstatic.com",
|
||
|
|
"img-src 'self' data: https:",
|
||
|
|
"connect-src 'self'"
|
||
|
|
].join('; ')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. Session Storage Options**
|
||
|
|
|
||
|
|
**In-Memory (Development Only):**
|
||
|
|
```typescript
|
||
|
|
// Simple Map - loses data on restart
|
||
|
|
const sessions = new Map<string, string>();
|
||
|
|
```
|
||
|
|
|
||
|
|
**Redis (Recommended for Production):**
|
||
|
|
```typescript
|
||
|
|
// server/utils/session-storage.ts
|
||
|
|
import Redis from 'ioredis';
|
||
|
|
|
||
|
|
const redis = new Redis({
|
||
|
|
host: process.env.REDIS_HOST || 'localhost',
|
||
|
|
port: parseInt(process.env.REDIS_PORT || '6379'),
|
||
|
|
password: process.env.REDIS_PASSWORD,
|
||
|
|
db: 0,
|
||
|
|
retryDelayOnFailover: 100,
|
||
|
|
maxRetriesPerRequest: 3
|
||
|
|
});
|
||
|
|
|
||
|
|
export const sessionStorage = {
|
||
|
|
async set(key: string, value: string, ttl: number = 3600): Promise<void> {
|
||
|
|
await redis.setex(`session:${key}`, ttl, value);
|
||
|
|
},
|
||
|
|
|
||
|
|
async get(key: string): Promise<string | null> {
|
||
|
|
return await redis.get(`session:${key}`);
|
||
|
|
},
|
||
|
|
|
||
|
|
async delete(key: string): Promise<void> {
|
||
|
|
await redis.del(`session:${key}`);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
**Database (Alternative):**
|
||
|
|
```sql
|
||
|
|
-- Session table schema
|
||
|
|
CREATE TABLE user_sessions (
|
||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
session_id VARCHAR(255) UNIQUE NOT NULL,
|
||
|
|
user_id VARCHAR(255) NOT NULL,
|
||
|
|
session_data TEXT NOT NULL,
|
||
|
|
expires_at TIMESTAMP NOT NULL,
|
||
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
last_activity TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_sessions_session_id ON user_sessions(session_id);
|
||
|
|
CREATE INDEX idx_sessions_expires_at ON user_sessions(expires_at);
|
||
|
|
```
|
||
|
|
|
||
|
|
### **4. Monitoring & Logging**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// server/utils/monitoring.ts
|
||
|
|
export const logAuthEvent = (event: string, data: any) => {
|
||
|
|
const logData = {
|
||
|
|
timestamp: new Date().toISOString(),
|
||
|
|
event,
|
||
|
|
ip: data.ip || 'unknown',
|
||
|
|
userAgent: data.userAgent || 'unknown',
|
||
|
|
user: data.user || 'anonymous',
|
||
|
|
success: data.success || false,
|
||
|
|
error: data.error || null
|
||
|
|
};
|
||
|
|
|
||
|
|
// Send to monitoring service (e.g., DataDog, New Relic, etc.)
|
||
|
|
console.log(JSON.stringify(logData));
|
||
|
|
|
||
|
|
// In production, send to your logging service:
|
||
|
|
// await sendToLoggingService(logData);
|
||
|
|
};
|
||
|
|
|
||
|
|
// Usage in auth routes
|
||
|
|
logAuthEvent('login_attempt', {
|
||
|
|
ip: getClientIP(event),
|
||
|
|
user: username,
|
||
|
|
success: true
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### **5. Performance Optimization**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Cache Keycloak public key for token validation
|
||
|
|
let keycloakPublicKey: string | null = null;
|
||
|
|
let keyLastFetched = 0;
|
||
|
|
const KEY_CACHE_DURATION = 3600000; // 1 hour
|
||
|
|
|
||
|
|
const getKeycloakPublicKey = async (): Promise<string> => {
|
||
|
|
const now = Date.now();
|
||
|
|
|
||
|
|
if (!keycloakPublicKey || (now - keyLastFetched) > KEY_CACHE_DURATION) {
|
||
|
|
const config = useRuntimeConfig();
|
||
|
|
const response = await fetch(`${config.keycloak.issuer}/.well-known/openid_configuration`);
|
||
|
|
const oidcConfig = await response.json();
|
||
|
|
|
||
|
|
const keysResponse = await fetch(oidcConfig.jwks_uri);
|
||
|
|
const keys = await keysResponse.json();
|
||
|
|
|
||
|
|
// Extract public key from JWKS
|
||
|
|
keycloakPublicKey = keys.keys[0].x5c[0];
|
||
|
|
keyLastFetched = now;
|
||
|
|
}
|
||
|
|
|
||
|
|
return keycloakPublicKey!;
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
## 🔧 **Troubleshooting**
|
||
|
|
|
||
|
|
### **1. Common Issues**
|
||
|
|
|
||
|
|
**Issue: "Invalid client credentials"**
|
||
|
|
```bash
|
||
|
|
# Solution: Check Keycloak client configuration
|
||
|
|
- Verify client ID matches environment variable
|
||
|
|
- Ensure client secret is correct
|
||
|
|
- Confirm client type is "confidential"
|
||
|
|
- Check "Direct Access Grants" is enabled
|
||
|
|
```
|
||
|
|
|
||
|
|
**Issue: "CORS errors during login"**
|
||
|
|
```bash
|
||
|
|
# Solution: Configure Keycloak CORS settings
|
||
|
|
- In Keycloak Admin Console
|
||
|
|
- Go to Client → Settings → Advanced Settings
|
||
|
|
- Add your domain to "Web Origins"
|
||
|
|
- Set "Valid Redirect URIs" properly
|
||
|
|
```
|
||
|
|
|
||
|
|
**Issue: "Session not persisting after login"**
|
||
|
|
```typescript
|
||
|
|
// Solution: Check cookie configuration
|
||
|
|
setCookie(event, 'session-name', sessionId, {
|
||
|
|
httpOnly: true,
|
||
|
|
secure: process.env.NODE_ENV === 'production', // Important!
|
||
|
|
sameSite: 'lax',
|
||
|
|
domain: process.env.NODE_ENV === 'production' ? '.yourdomain.com' : undefined
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**Issue: "Redirect loop after login"**
|
||
|
|
```typescript
|
||
|
|
// Solution: Check middleware configuration
|
||
|
|
// Ensure pages have correct middleware:
|
||
|
|
- Login page: middleware: 'guest'
|
||
|
|
- Dashboard pages: middleware: 'auth'
|
||
|
|
- Index page: client-side navigation only
|
||
|
|
```
|
||
|
|
|
||
|
|
**Issue: "User groups/roles not being extracted"**
|
||
|
|
```typescript
|
||
|
|
// Solution: Configure group mappers in Keycloak
|
||
|
|
// 1. Go to Client Scopes → roles → Mappers
|
||
|
|
// 2. Create mapper: "groups"
|
||
|
|
// 3. Mapper Type: "Group Membership"
|
||
|
|
// 4. Token Claim Name: "groups"
|
||
|
|
// 5. Add to access token and ID token
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. Debug Commands**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Check Keycloak connectivity
|
||
|
|
curl -X GET "https://auth.yourdomain.com/realms/your-realm/.well-known/openid_configuration"
|
||
|
|
|
||
|
|
# Test token endpoint directly
|
||
|
|
curl -X POST "https://auth.yourdomain.com/realms/your-realm/protocol/openid-connect/token" \
|
||
|
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||
|
|
-d "grant_type=password&client_id=your-client&client_secret=your-secret&username=test&password=test123"
|
||
|
|
|
||
|
|
# Verify user info endpoint
|
||
|
|
curl -X GET "https://auth.yourdomain.com/realms/your-realm/protocol/openid-connect/userinfo" \
|
||
|
|
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. Common Configuration Mistakes**
|
||
|
|
|
||
|
|
❌ **Wrong Grant Type:**
|
||
|
|
```javascript
|
||
|
|
// WRONG - Don't use authorization code flow for custom login
|
||
|
|
standardFlowEnabled: true
|
||
|
|
directAccessGrantsEnabled: false
|
||
|
|
```
|
||
|
|
|
||
|
|
✅ **Correct Grant Type:**
|
||
|
|
```javascript
|
||
|
|
// CORRECT - Use Resource Owner Password Credentials
|
||
|
|
standardFlowEnabled: false
|
||
|
|
directAccessGrantsEnabled: true
|
||
|
|
```
|
||
|
|
|
||
|
|
❌ **Wrong Cookie Settings:**
|
||
|
|
```typescript
|
||
|
|
// WRONG - Insecure cookie settings
|
||
|
|
setCookie(event, 'session', sessionId, {
|
||
|
|
httpOnly: false, // ❌ XSS vulnerable
|
||
|
|
secure: false, // ❌ Not secure over HTTP
|
||
|
|
sameSite: 'none' // ❌ CSRF vulnerable
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
✅ **Correct Cookie Settings:**
|
||
|
|
```typescript
|
||
|
|
// CORRECT - Secure cookie settings
|
||
|
|
setCookie(event, 'session', sessionId, {
|
||
|
|
httpOnly: true, // ✅ XSS protection
|
||
|
|
secure: isProduction, // ✅ HTTPS in production
|
||
|
|
sameSite: 'lax' // ✅ CSRF protection
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### **4. Performance Troubleshooting**
|
||
|
|
|
||
|
|
**Slow Login Response:**
|
||
|
|
- Check network latency to Keycloak server
|
||
|
|
- Verify Keycloak server performance
|
||
|
|
- Consider caching user info for short periods
|
||
|
|
- Optimize session creation process
|
||
|
|
|
||
|
|
**High Memory Usage:**
|
||
|
|
- Implement session cleanup for expired sessions
|
||
|
|
- Use external session storage (Redis) instead of memory
|
||
|
|
- Set appropriate session TTL values
|
||
|
|
|
||
|
|
**Database Connection Issues:**
|
||
|
|
- Configure proper connection pooling
|
||
|
|
- Set appropriate timeout values
|
||
|
|
- Implement retry logic for transient failures
|
||
|
|
|
||
|
|
## 📚 **Additional Resources**
|
||
|
|
|
||
|
|
### **Keycloak Documentation**
|
||
|
|
- [Resource Owner Password Credentials Flow](https://www.keycloak.org/docs/latest/securing_apps/#_resource_owner_password_credentials_flow)
|
||
|
|
- [Client Configuration](https://www.keycloak.org/docs/latest/server_admin/#_clients)
|
||
|
|
- [User Groups and Roles](https://www.keycloak.org/docs/latest/server_admin/#con-groups_server_administration_guide)
|
||
|
|
|
||
|
|
### **Security Best Practices**
|
||
|
|
- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|
||
|
|
- [OWASP Session Management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
|
||
|
|
- [Cookie Security Guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#security)
|
||
|
|
|
||
|
|
### **Nuxt 3 Specific**
|
||
|
|
- [Nuxt 3 Authentication Patterns](https://nuxt.com/docs/guide/directory-structure/middleware#authentication)
|
||
|
|
- [Server Routes](https://nuxt.com/docs/guide/directory-structure/server)
|
||
|
|
- [Runtime Config](https://nuxt.com/docs/api/nuxt-config#runtimeconfig)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🎉 **Conclusion**
|
||
|
|
|
||
|
|
This comprehensive guide covers implementing a custom login page with Keycloak in a Nuxt 3 application. The solution provides:
|
||
|
|
|
||
|
|
✅ **Complete UI Control**: Custom branded login experience
|
||
|
|
✅ **Mobile Compatibility**: Works perfectly on all devices including iOS Safari
|
||
|
|
✅ **Enterprise Security**: Rate limiting, input validation, secure sessions
|
||
|
|
✅ **Production Ready**: Proper error handling, monitoring, and scalability
|
||
|
|
✅ **Maintainable**: Clean separation of concerns and well-documented code
|
||
|
|
|
||
|
|
The implementation balances security, performance, and user experience while providing a solid foundation for enterprise authentication needs.
|