From cd29123e23d19f58883849ca55749fb9082f5551 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 Aug 2025 12:28:41 +0200 Subject: [PATCH] Refactor authentication system with tier-based access control - Replace group-based auth with user/board/admin tier system - Add direct login functionality alongside OAuth - Implement role-based middleware for route protection - Create dashboard pages and admin API endpoints - Add error handling page and improved user management - Maintain backward compatibility with legacy role methods --- composables/useAuth.ts | 172 ++++++--- error.vue | 214 +++++++++++ layouts/dashboard.vue | 260 ++++++++++++++ middleware/auth-admin.ts | 14 + middleware/auth-board.ts | 14 + middleware/auth-user.ts | 7 + pages/dashboard/admin.vue | 518 +++++++++++++++++++++++++++ pages/dashboard/board.vue | 345 ++++++++++++++++++ pages/dashboard/index.vue | 55 +++ pages/dashboard/user.vue | 211 +++++++++++ pages/index.vue | 4 +- server/api/admin/stats.get.ts | 88 +++++ server/api/auth/callback.get.ts | 16 +- server/api/auth/direct-login.post.ts | 15 +- utils/types.ts | 17 +- 15 files changed, 1893 insertions(+), 57 deletions(-) create mode 100644 error.vue create mode 100644 layouts/dashboard.vue create mode 100644 middleware/auth-admin.ts create mode 100644 middleware/auth-board.ts create mode 100644 middleware/auth-user.ts create mode 100644 pages/dashboard/admin.vue create mode 100644 pages/dashboard/board.vue create mode 100644 pages/dashboard/index.vue create mode 100644 pages/dashboard/user.vue create mode 100644 server/api/admin/stats.get.ts diff --git a/composables/useAuth.ts b/composables/useAuth.ts index c2fe84c..796a3f1 100644 --- a/composables/useAuth.ts +++ b/composables/useAuth.ts @@ -1,64 +1,156 @@ -import type { AuthState } from '~/utils/types'; +import type { User } from '~/utils/types'; export const useAuth = () => { - const authState = useState('auth.state', () => ({ - authenticated: false, - user: null, - groups: [], - })); + const user = ref(null); + const isAuthenticated = computed(() => !!user.value); + const loading = ref(false); + const error = ref(null); - const login = () => { - return navigateTo('/api/auth/login'); + // 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'); + const firstName = computed(() => { + if (user.value?.firstName) return user.value.firstName; + if (user.value?.name) return user.value.name.split(' ')[0]; + return 'User'; + }); + + // Helper methods + const hasTier = (requiredTier: 'user' | 'board' | 'admin') => { + return user.value?.tier === requiredTier; }; - const logout = async () => { + const hasGroup = (groupName: string) => { + return user.value?.groups?.includes(groupName) || false; + }; + + // Legacy compatibility + const hasRole = (role: string) => { + return hasGroup(role); + }; + + // Direct login method + const login = async (credentials: { username: string; password: string; rememberMe?: boolean }) => { + loading.value = true; + error.value = null; + try { - await $fetch('/api/auth/logout', { method: 'POST' }); - authState.value = { - authenticated: false, - user: null, - groups: [], - }; - await navigateTo('/login'); - } catch (error) { - console.error('Logout error:', error); - await navigateTo('/login'); + const response = await $fetch<{ + success: boolean; + redirectTo?: string; + user?: User; + }>('/api/auth/direct-login', { + method: 'POST', + body: credentials + }); + + if (response.success && response.user) { + user.value = response.user; + + // Redirect to dashboard or intended page + await navigateTo(response.redirectTo || '/dashboard'); + + return { success: true }; + } + + return { success: false, error: 'Login failed' }; + } catch (err: any) { + error.value = err.data?.message || 'Login failed'; + return { success: false, error: error.value }; + } finally { + loading.value = false; } }; + // OAuth login method (fallback) + const loginOAuth = () => { + return navigateTo('/api/auth/login'); + }; + + // Password reset method + const requestPasswordReset = async (email: string) => { + loading.value = true; + error.value = null; + + try { + const response = await $fetch<{ + success: boolean; + message: string; + }>('/api/auth/forgot-password', { + method: 'POST', + body: { email } + }); + + return { success: true, message: response.message }; + } catch (err: any) { + error.value = err.data?.message || 'Password reset failed'; + return { success: false, error: error.value }; + } finally { + loading.value = false; + } + }; + + // Check authentication status const checkAuth = async () => { try { - const response = await $fetch('/api/auth/session'); - authState.value = response; - return response.authenticated; - } catch (error) { - console.error('Auth check error:', error); - authState.value = { - authenticated: false, - user: null, - groups: [], - }; + const response = await $fetch<{ + authenticated: boolean; + user: User | null; + }>('/api/auth/session'); + + if (response.authenticated && response.user) { + user.value = response.user; + return true; + } else { + user.value = null; + return false; + } + } catch (err) { + console.error('Auth check error:', err); + user.value = null; return false; } }; - const isAdmin = computed(() => { - return authState.value.groups?.includes('admin') || false; - }); - - const hasRole = (role: string) => { - return authState.value.groups?.includes(role) || false; + // Logout method + const logout = async () => { + try { + await $fetch('/api/auth/logout', { method: 'POST' }); + user.value = null; + await navigateTo('/login'); + } catch (err) { + console.error('Logout error:', err); + user.value = null; + await navigateTo('/login'); + } }; return { - authState: readonly(authState), - user: computed(() => authState.value.user), - authenticated: computed(() => authState.value.authenticated), - groups: computed(() => authState.value.groups), + // State + user: readonly(user), + isAuthenticated, + loading: readonly(loading), + error: readonly(error), + + // Tier-based properties + userTier, + isUser, + isBoard, isAdmin, - hasRole, + firstName, + + // Helper methods + hasTier, + hasGroup, + hasRole, // Legacy compatibility + + // Actions login, + loginOAuth, logout, + requestPasswordReset, checkAuth, }; }; diff --git a/error.vue b/error.vue new file mode 100644 index 0000000..f4b562c --- /dev/null +++ b/error.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/layouts/dashboard.vue b/layouts/dashboard.vue new file mode 100644 index 0000000..2173050 --- /dev/null +++ b/layouts/dashboard.vue @@ -0,0 +1,260 @@ + + + + + diff --git a/middleware/auth-admin.ts b/middleware/auth-admin.ts new file mode 100644 index 0000000..8c631af --- /dev/null +++ b/middleware/auth-admin.ts @@ -0,0 +1,14 @@ +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.' + }); + } +}); diff --git a/middleware/auth-board.ts b/middleware/auth-board.ts new file mode 100644 index 0000000..8556818 --- /dev/null +++ b/middleware/auth-board.ts @@ -0,0 +1,14 @@ +export default defineNuxtRouteMiddleware((to, from) => { + const { isAuthenticated, isBoard, isAdmin } = useAuth(); + + if (!isAuthenticated.value) { + return navigateTo('/login'); + } + + if (!isBoard.value && !isAdmin.value) { + throw createError({ + statusCode: 403, + statusMessage: 'Access denied. Board membership required.' + }); + } +}); diff --git a/middleware/auth-user.ts b/middleware/auth-user.ts new file mode 100644 index 0000000..2f7bf51 --- /dev/null +++ b/middleware/auth-user.ts @@ -0,0 +1,7 @@ +export default defineNuxtRouteMiddleware((to, from) => { + const { isAuthenticated } = useAuth(); + + if (!isAuthenticated.value) { + return navigateTo('/login'); + } +}); diff --git a/pages/dashboard/admin.vue b/pages/dashboard/admin.vue new file mode 100644 index 0000000..48da3c3 --- /dev/null +++ b/pages/dashboard/admin.vue @@ -0,0 +1,518 @@ + + + + + diff --git a/pages/dashboard/board.vue b/pages/dashboard/board.vue new file mode 100644 index 0000000..1f5da4c --- /dev/null +++ b/pages/dashboard/board.vue @@ -0,0 +1,345 @@ + + + + + diff --git a/pages/dashboard/index.vue b/pages/dashboard/index.vue new file mode 100644 index 0000000..5d0bd49 --- /dev/null +++ b/pages/dashboard/index.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/pages/dashboard/user.vue b/pages/dashboard/user.vue new file mode 100644 index 0000000..2a37772 --- /dev/null +++ b/pages/dashboard/user.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/pages/index.vue b/pages/index.vue index a938e6b..175d4e1 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -5,8 +5,8 @@ diff --git a/server/api/admin/stats.get.ts b/server/api/admin/stats.get.ts new file mode 100644 index 0000000..8241fe9 --- /dev/null +++ b/server/api/admin/stats.get.ts @@ -0,0 +1,88 @@ +export default defineEventHandler(async (event) => { + console.log('📊 Admin stats requested at:', new Date().toISOString()); + + try { + // Check if user is admin (middleware should handle this, but double-check) + const sessionManager = createSessionManager(); + const cookieHeader = getHeader(event, 'cookie'); + const session = sessionManager.getSession(cookieHeader); + + if (!session || session.user.tier !== 'admin') { + console.warn('🚨 Unauthorized admin stats access attempt'); + throw createError({ + statusCode: 403, + statusMessage: 'Admin access required' + }); + } + + console.log('✅ Admin access verified for user:', session.user.email); + + // For now, return mock data - later integrate with actual data sources + const stats = { + totalUsers: 156, + activeUsers: 45, + totalSessions: 67, + systemHealth: 'healthy', + lastBackup: new Date().toISOString(), + diskUsage: '45%', + memoryUsage: '62%', + recentActivity: [ + { + action: 'User login', + user: 'john@example.com', + timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + type: 'info' + }, + { + action: 'Password reset', + user: 'jane@example.com', + timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(), + type: 'warning' + }, + { + action: 'User created', + user: 'admin@monacousa.org', + timestamp: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(), + type: 'success' + } + ], + systemMetrics: { + cpu: 45, + memory: 62, + disk: 38, + uptime: '5d 12h 30m' + }, + securityAlerts: [ + { + id: 1, + title: 'Failed Login Attempts', + description: '3 failed login attempts detected', + severity: 'medium', + timestamp: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString() + }, + { + id: 2, + title: 'System Update Available', + description: 'Security update available for Keycloak', + severity: 'low', + timestamp: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString() + } + ] + }; + + console.log('✅ Admin stats retrieved successfully'); + return stats; + + } catch (error: any) { + console.error('❌ Admin stats error:', error); + + if (error.statusCode) { + throw error; + } + + throw createError({ + statusCode: 500, + statusMessage: 'Failed to retrieve system statistics' + }); + } +}); diff --git a/server/api/auth/callback.get.ts b/server/api/auth/callback.get.ts index c6dd902..8ae82ee 100644 --- a/server/api/auth/callback.get.ts +++ b/server/api/auth/callback.get.ts @@ -36,14 +36,24 @@ export default defineEventHandler(async (event) => { // Get user info const userInfo = await keycloak.getUserInfo(tokens.access_token); + // Tier determination logic - admin > board > user priority + const determineTier = (groups: string[]): 'user' | 'board' | 'admin' => { + if (groups.includes('admin')) return 'admin'; + if (groups.includes('board')) return 'board'; + return 'user'; // Default tier + }; + // Create session const sessionData = { user: { id: userInfo.sub, email: userInfo.email, - name: userInfo.name || `${userInfo.given_name} ${userInfo.family_name}`.trim(), - groups: userInfo.groups || [], - tier: userInfo.tier, + name: userInfo.name || `${userInfo.given_name || ''} ${userInfo.family_name || ''}`.trim(), + firstName: userInfo.given_name, + lastName: userInfo.family_name, + username: userInfo.preferred_username, + tier: determineTier(userInfo.groups || []), + groups: userInfo.groups || ['user'], }, tokens: { accessToken: tokens.access_token, diff --git a/server/api/auth/direct-login.post.ts b/server/api/auth/direct-login.post.ts index 3239b9b..51d76a6 100644 --- a/server/api/auth/direct-login.post.ts +++ b/server/api/auth/direct-login.post.ts @@ -203,15 +203,24 @@ export default defineEventHandler(async (event) => { name: userInfo.name }); + // Tier determination logic - admin > board > user priority + const determineTier = (groups: string[]): 'user' | 'board' | 'admin' => { + if (groups.includes('admin')) return 'admin'; + if (groups.includes('board')) return 'board'; + return 'user'; // Default tier + }; + // Create session data with extended expiry if remember me const sessionData = { user: { id: userInfo.sub, email: userInfo.email, name: userInfo.name || `${userInfo.given_name || ''} ${userInfo.family_name || ''}`.trim(), - groups: userInfo.groups || [], - tier: userInfo.tier, - username: userInfo.preferred_username || username + firstName: userInfo.given_name, + lastName: userInfo.family_name, + username: userInfo.preferred_username || username, + tier: determineTier(userInfo.groups || []), + groups: userInfo.groups || ['user'] }, tokens: { accessToken: tokens.access_token, diff --git a/utils/types.ts b/utils/types.ts index 8b40d6a..960754b 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -3,8 +3,11 @@ export interface User { id: string; email: string; name: string; - groups?: string[]; - tier?: string; + firstName?: string; + lastName?: string; + username?: string; + tier: 'user' | 'board' | 'admin'; + groups: string[]; } export interface AuthState { @@ -60,23 +63,19 @@ export interface UserInfo { given_name?: string; family_name?: string; name?: string; + preferred_username?: string; groups?: string[]; tier?: string; } export interface SessionData { - user: { - id: string; - email: string; - name: string; - groups?: string[]; - tier?: string; - }; + user: User; tokens: { accessToken: string; refreshToken: string; expiresAt: number; }; + rememberMe?: boolean; createdAt: number; lastActivity: number; }