Implement Keycloak authentication integration and unify user management
This commit is contained in:
parent
72ea543485
commit
5f8720bb63
12
.env.example
12
.env.example
|
|
@ -20,3 +20,15 @@ NUXT_DOCUMENSO_BASE_URL=https://signatures.portnimara.dev
|
||||||
|
|
||||||
# Webhook Configuration for Embedded Signing
|
# Webhook Configuration for Embedded Signing
|
||||||
WEBHOOK_SECRET_SIGNING=96BQQRiKkTIN2w0rHbqo7yHggV/sT8702HtHih3uNSY=
|
WEBHOOK_SECRET_SIGNING=96BQQRiKkTIN2w0rHbqo7yHggV/sT8702HtHih3uNSY=
|
||||||
|
|
||||||
|
# Keycloak Configuration
|
||||||
|
KEYCLOAK_ISSUER=https://auth.portnimara.dev/realms/client-portal
|
||||||
|
KEYCLOAK_CLIENT_ID=client-portal
|
||||||
|
KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
|
||||||
|
KEYCLOAK_CALLBACK_URL=https://client.portnimara.dev/auth/callback
|
||||||
|
# For local development, use: http://localhost:3000/auth/callback
|
||||||
|
|
||||||
|
# OIDC Session Configuration
|
||||||
|
OIDC_SESSION_SECRET=your-32-character-session-secret
|
||||||
|
OIDC_ENCRYPT_KEY=your-32-character-encryption-key
|
||||||
|
OIDC_ENCRYPT_IV=16-char-enc-iv!
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
export interface UnifiedUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
tier?: string;
|
||||||
|
authSource: 'keycloak' | 'directus';
|
||||||
|
raw: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUnifiedAuth = () => {
|
||||||
|
// Get both auth systems
|
||||||
|
const directusAuth = useDirectusAuth();
|
||||||
|
const directusUser = useDirectusUser();
|
||||||
|
const oidc = useOidc();
|
||||||
|
|
||||||
|
// Create unified user object
|
||||||
|
const user = computed<UnifiedUser | null>(() => {
|
||||||
|
// Check Keycloak user first
|
||||||
|
if (oidc.user?.value) {
|
||||||
|
const keycloakUser = oidc.user.value;
|
||||||
|
// Construct name from firstName and lastName if available
|
||||||
|
let name = keycloakUser.name;
|
||||||
|
if (!name && (keycloakUser.given_name || keycloakUser.family_name)) {
|
||||||
|
name = `${keycloakUser.given_name || ''} ${keycloakUser.family_name || ''}`.trim();
|
||||||
|
} else if (!name && (keycloakUser.firstName || keycloakUser.lastName)) {
|
||||||
|
name = `${keycloakUser.firstName || ''} ${keycloakUser.lastName || ''}`.trim();
|
||||||
|
}
|
||||||
|
if (!name) {
|
||||||
|
name = keycloakUser.preferred_username || keycloakUser.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: keycloakUser.sub || keycloakUser.id,
|
||||||
|
email: keycloakUser.email,
|
||||||
|
name: name,
|
||||||
|
tier: keycloakUser.tier || 'basic', // From custom Keycloak attribute
|
||||||
|
authSource: 'keycloak',
|
||||||
|
raw: keycloakUser
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Directus user
|
||||||
|
if (directusUser.value && directusUser.value.email) {
|
||||||
|
return {
|
||||||
|
id: directusUser.value.id,
|
||||||
|
email: directusUser.value.email,
|
||||||
|
name: `${directusUser.value.first_name || ''} ${directusUser.value.last_name || ''}`.trim() || directusUser.value.email,
|
||||||
|
tier: directusUser.value.tier || 'basic', // If Directus has tier field
|
||||||
|
authSource: 'directus',
|
||||||
|
raw: directusUser.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unified logout function
|
||||||
|
const logout = async () => {
|
||||||
|
if (user.value?.authSource === 'keycloak') {
|
||||||
|
// Keycloak logout
|
||||||
|
await oidc.logout();
|
||||||
|
} else if (user.value?.authSource === 'directus') {
|
||||||
|
// Directus logout
|
||||||
|
await directusAuth.logout();
|
||||||
|
await navigateTo('/login');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if user is authenticated
|
||||||
|
const isAuthenticated = computed(() => !!user.value);
|
||||||
|
|
||||||
|
// Get auth source
|
||||||
|
const authSource = computed(() => user.value?.authSource);
|
||||||
|
|
||||||
|
// Check if user has specific tier
|
||||||
|
const hasTier = (tier: string) => {
|
||||||
|
return user.value?.tier === tier;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
const isAdmin = computed(() => hasTier('admin'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: readonly(user),
|
||||||
|
logout,
|
||||||
|
isAuthenticated: readonly(isAuthenticated),
|
||||||
|
authSource: readonly(authSource),
|
||||||
|
hasTier,
|
||||||
|
isAdmin: readonly(isAdmin),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
# Keycloak Integration Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the dual authentication system implementation that supports both Keycloak (new) and Directus (existing) authentication methods running in parallel.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[User visits Portal] --> B{Already Authenticated?}
|
||||||
|
B -->|No| C[Login Page]
|
||||||
|
B -->|Yes| D[Dashboard]
|
||||||
|
|
||||||
|
C --> E{Login Method}
|
||||||
|
E -->|SSO Button| F[Redirect to Keycloak]
|
||||||
|
E -->|Email/Password| G[Directus Authentication]
|
||||||
|
|
||||||
|
F --> H[Keycloak Login Page]
|
||||||
|
H --> I[User Authenticates]
|
||||||
|
I --> J[Redirect to /auth/callback]
|
||||||
|
J --> D
|
||||||
|
|
||||||
|
G --> K[Validate with Directus]
|
||||||
|
K --> D
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
1. **Unified Authentication Composable** (`composables/useUnifiedAuth.ts`)
|
||||||
|
- Provides a single interface for both auth systems
|
||||||
|
- Maps user data to a common format
|
||||||
|
- Handles logout for both systems
|
||||||
|
|
||||||
|
2. **Authentication Middleware** (`middleware/authentication.ts`)
|
||||||
|
- Checks Keycloak auth first, then Directus
|
||||||
|
- Supports public pages via `auth: false` page meta
|
||||||
|
|
||||||
|
3. **Login Page** (`pages/login.vue`)
|
||||||
|
- Dual login options (SSO and traditional)
|
||||||
|
- Public page (no auth required)
|
||||||
|
|
||||||
|
4. **OAuth Callback** (`pages/auth/callback.vue`)
|
||||||
|
- Handles Keycloak OAuth redirects
|
||||||
|
- Shows loading state during authentication
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Keycloak Configuration
|
||||||
|
KEYCLOAK_ISSUER=https://auth.portnimara.dev/realms/client-portal
|
||||||
|
KEYCLOAK_CLIENT_ID=client-portal
|
||||||
|
KEYCLOAK_CLIENT_SECRET=your-client-secret
|
||||||
|
KEYCLOAK_CALLBACK_URL=https://client.portnimara.dev/auth/callback
|
||||||
|
|
||||||
|
# OIDC Session Configuration
|
||||||
|
OIDC_SESSION_SECRET=your-32-character-session-secret
|
||||||
|
OIDC_ENCRYPT_KEY=your-32-character-encryption-key
|
||||||
|
OIDC_ENCRYPT_IV=16-char-enc-iv!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keycloak Client Configuration
|
||||||
|
|
||||||
|
1. **Client Type**: Confidential (uses client secret)
|
||||||
|
2. **Authentication Flow**: Standard flow (Authorization Code)
|
||||||
|
3. **Valid Redirect URIs**:
|
||||||
|
- `https://client.portnimara.dev/*`
|
||||||
|
- `https://client.portnimara.dev/auth/callback`
|
||||||
|
- `http://localhost:3000/*` (development)
|
||||||
|
- `http://localhost:3000/auth/callback` (development)
|
||||||
|
4. **Web Origins**: `+` (all redirect URI origins)
|
||||||
|
|
||||||
|
### User Attributes
|
||||||
|
|
||||||
|
Custom Keycloak attributes:
|
||||||
|
- `tier`: User tier level (basic, premium, admin)
|
||||||
|
|
||||||
|
Standard attributes used:
|
||||||
|
- `email`: User email address
|
||||||
|
- `given_name` / `firstName`: First name
|
||||||
|
- `family_name` / `lastName`: Last name
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### For Components
|
||||||
|
|
||||||
|
Replace Directus-specific code with unified auth:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const user = useDirectusUser();
|
||||||
|
const { logout } = useDirectusAuth();
|
||||||
|
|
||||||
|
// After
|
||||||
|
const { user, logout } = useUnifiedAuth();
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Object Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UnifiedUser {
|
||||||
|
id: string; // User ID
|
||||||
|
email: string; // Email address
|
||||||
|
name: string; // Full name
|
||||||
|
tier?: string; // User tier (basic, premium, admin)
|
||||||
|
authSource: 'keycloak' | 'directus'; // Auth system used
|
||||||
|
raw: any; // Original user object
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking Authentication
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { isAuthenticated, authSource, isAdmin } = useUnifiedAuth();
|
||||||
|
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
console.log(`User logged in via ${authSource.value}`);
|
||||||
|
|
||||||
|
if (isAdmin.value) {
|
||||||
|
// Show admin features
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test Page
|
||||||
|
|
||||||
|
Visit `/dashboard/auth-test` to:
|
||||||
|
- View current authentication status
|
||||||
|
- See user information and tier
|
||||||
|
- Test logout functionality
|
||||||
|
- View raw user data
|
||||||
|
|
||||||
|
### Testing Both Auth Methods
|
||||||
|
|
||||||
|
1. **Directus Login**:
|
||||||
|
- Use email/password form on login page
|
||||||
|
- Existing users continue to work
|
||||||
|
|
||||||
|
2. **Keycloak Login**:
|
||||||
|
- Click "Login with Single Sign-On"
|
||||||
|
- Redirected to Keycloak
|
||||||
|
- Returns to portal after authentication
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
### Phase 1: Parallel Systems (Current)
|
||||||
|
- Both authentication methods available
|
||||||
|
- Users can choose their preferred method
|
||||||
|
- No breaking changes
|
||||||
|
|
||||||
|
### Phase 2: Gradual Migration
|
||||||
|
- Encourage SSO usage
|
||||||
|
- Migrate users to Keycloak
|
||||||
|
- Monitor adoption rates
|
||||||
|
|
||||||
|
### Phase 3: Keycloak Only
|
||||||
|
- Remove Directus login option
|
||||||
|
- All users on Keycloak
|
||||||
|
- Simplified codebase
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **"Invalid redirect URI" error**
|
||||||
|
- Check Keycloak client redirect URIs
|
||||||
|
- Ensure callback URL matches environment
|
||||||
|
|
||||||
|
2. **User tier not showing**
|
||||||
|
- Verify tier attribute in Keycloak
|
||||||
|
- Check attribute mappers in client scope
|
||||||
|
|
||||||
|
3. **Name not displaying correctly**
|
||||||
|
- Ensure firstName/lastName set in Keycloak
|
||||||
|
- Check unified auth name construction logic
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
|
||||||
|
Enable debug logging in development:
|
||||||
|
- OIDC debug mode automatically enabled
|
||||||
|
- Check browser console for auth flow details
|
||||||
|
- Review network tab for OAuth redirects
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Client Secret**: Never expose in client-side code
|
||||||
|
2. **Session Security**: Uses encrypted cookies
|
||||||
|
3. **Token Validation**: Server-side APIs should validate JWTs
|
||||||
|
4. **HTTPS Required**: OAuth flow requires secure connections
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Social login providers (Google, Microsoft)
|
||||||
|
- [ ] Multi-factor authentication
|
||||||
|
- [ ] User self-service portal
|
||||||
|
- [ ] Advanced role-based access control
|
||||||
|
- [ ] Session management UI
|
||||||
|
|
@ -1,14 +1,34 @@
|
||||||
export default defineNuxtRouteMiddleware(async () => {
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
// Skip auth for SSR
|
||||||
|
if (import.meta.server) return;
|
||||||
|
|
||||||
|
// Check if auth is required (default true unless explicitly set to false)
|
||||||
|
const isAuthRequired = to.meta.auth !== false;
|
||||||
|
|
||||||
|
// Check Keycloak auth first
|
||||||
|
const oidc = useOidc();
|
||||||
|
if (oidc.isLoggedIn) {
|
||||||
|
// User authenticated with Keycloak
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Directus auth
|
||||||
const { fetchUser, setUser } = useDirectusAuth();
|
const { fetchUser, setUser } = useDirectusAuth();
|
||||||
|
const directusUser = useDirectusUser();
|
||||||
const user = useDirectusUser();
|
|
||||||
|
if (!directusUser.value) {
|
||||||
if (!user.value) {
|
|
||||||
const user = await fetchUser();
|
const user = await fetchUser();
|
||||||
setUser(user.value);
|
setUser(user.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.value) {
|
if (directusUser.value) {
|
||||||
return navigateTo("/login");
|
// User authenticated with Directus
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No authentication found
|
||||||
|
if (isAuthRequired) {
|
||||||
|
// Redirect to login page
|
||||||
|
return navigateTo('/login');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ export default defineNuxtConfig({
|
||||||
ssr: false,
|
ssr: false,
|
||||||
compatibilityDate: "2024-11-01",
|
compatibilityDate: "2024-11-01",
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
modules: ["nuxt-directus", "vuetify-nuxt-module", "@vite-pwa/nuxt"],
|
modules: ["nuxt-directus", "nuxt-openid-connect", "vuetify-nuxt-module", "@vite-pwa/nuxt"],
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
titleTemplate: "%s • Port Nimara Portal",
|
titleTemplate: "%s • Port Nimara Portal",
|
||||||
|
|
@ -124,6 +124,36 @@ export default defineNuxtConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
openidConnect: {
|
||||||
|
addPlugin: true,
|
||||||
|
op: {
|
||||||
|
issuer: process.env.KEYCLOAK_ISSUER || "https://auth.portnimara.dev/realms/client-portal",
|
||||||
|
clientId: process.env.KEYCLOAK_CLIENT_ID || "client-portal",
|
||||||
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || "",
|
||||||
|
callbackUrl: process.env.KEYCLOAK_CALLBACK_URL || "",
|
||||||
|
scope: ["openid", "email", "profile"],
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
debug: process.env.NODE_ENV === 'development',
|
||||||
|
response_type: "code",
|
||||||
|
secret: process.env.OIDC_SESSION_SECRET || "default-session-secret-change-in-production",
|
||||||
|
cookie: {
|
||||||
|
loginName: "keycloak-login",
|
||||||
|
},
|
||||||
|
cookiePrefix: "keycloak._",
|
||||||
|
cookieEncrypt: true,
|
||||||
|
cookieEncryptKey: process.env.OIDC_ENCRYPT_KEY || "default-encrypt-key-change-in-prod",
|
||||||
|
cookieEncryptIV: process.env.OIDC_ENCRYPT_IV || "default-iv-12345",
|
||||||
|
cookieEncryptALGO: "aes-256-cbc",
|
||||||
|
cookieMaxAge: 24 * 60 * 60, // 1 day
|
||||||
|
cookieFlags: {
|
||||||
|
access_token: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
vuetify: {
|
vuetify: {
|
||||||
vuetifyOptions: {
|
vuetifyOptions: {
|
||||||
theme: {
|
theme: {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"nuxt": "^3.15.4",
|
"nuxt": "^3.15.4",
|
||||||
"nuxt-directus": "^5.7.0",
|
"nuxt-directus": "^5.7.0",
|
||||||
|
"nuxt-openid-connect": "^0.8.1",
|
||||||
"v-phone-input": "^4.4.2",
|
"v-phone-input": "^4.4.2",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
"vue-router": "latest",
|
"vue-router": "latest",
|
||||||
|
|
@ -8570,6 +8571,15 @@
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-levenshtein": {
|
"node_modules/js-levenshtein": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
|
||||||
|
|
@ -9722,6 +9732,18 @@
|
||||||
"@nuxt/kit": "^3.0.0"
|
"@nuxt/kit": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nuxt-openid-connect": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nuxt-openid-connect/-/nuxt-openid-connect-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-b/pMCZ4ZnY6VHqQ5clnjRnrz7OQ1wsydjcZIQ0c5VQq7BCgb5VBtp/9HETRqRArTjDiIqxc/Xu3hhddbHAfakA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxt/kit": "^3.11.2",
|
||||||
|
"defu": "^6.0.0",
|
||||||
|
"openid-client": "^5.1.6",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nypm": {
|
"node_modules/nypm": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.2.tgz",
|
||||||
|
|
@ -9742,6 +9764,15 @@
|
||||||
"node": "^14.16.0 || >=16.10.0"
|
"node": "^14.16.0 || >=16.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-hash": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.3",
|
"version": "1.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz",
|
||||||
|
|
@ -9800,6 +9831,15 @@
|
||||||
"integrity": "sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==",
|
"integrity": "sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/oidc-token-hash": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || >=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
|
@ -9900,6 +9940,39 @@
|
||||||
"typescript": "^5.x"
|
"typescript": "^5.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^4.15.9",
|
||||||
|
"lru-cache": "^6.0.0",
|
||||||
|
"object-hash": "^2.2.0",
|
||||||
|
"oidc-token-hash": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/lru-cache": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/own-keys": {
|
"node_modules/own-keys": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||||
|
|
@ -13541,6 +13614,19 @@
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/v-phone-input": {
|
"node_modules/v-phone-input": {
|
||||||
"version": "4.4.2",
|
"version": "4.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/v-phone-input/-/v-phone-input-4.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/v-phone-input/-/v-phone-input-4.4.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"nuxt": "^3.15.4",
|
"nuxt": "^3.15.4",
|
||||||
"nuxt-directus": "^5.7.0",
|
"nuxt-directus": "^5.7.0",
|
||||||
|
"nuxt-openid-connect": "^0.8.1",
|
||||||
"v-phone-input": "^4.4.2",
|
"v-phone-input": "^4.4.2",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
"vue-router": "latest",
|
"vue-router": "latest",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<template>
|
||||||
|
<v-app>
|
||||||
|
<v-main>
|
||||||
|
<v-container class="fill-height">
|
||||||
|
<v-row align="center" justify="center">
|
||||||
|
<v-col cols="12" sm="8" md="4">
|
||||||
|
<v-card class="pa-6">
|
||||||
|
<v-card-text class="text-center">
|
||||||
|
<v-progress-circular
|
||||||
|
indeterminate
|
||||||
|
color="primary"
|
||||||
|
size="64"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<h2 class="text-h5 mb-2">Authenticating...</h2>
|
||||||
|
<p class="text-body-2 text-medium-emphasis">
|
||||||
|
Please wait while we complete your login.
|
||||||
|
</p>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// This page handles the OAuth callback from Keycloak
|
||||||
|
// The nuxt-openid-connect module will automatically process the callback
|
||||||
|
// and redirect to the originally requested page or default redirect
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
auth: false, // This page should be accessible without authentication
|
||||||
|
layout: false // Use minimal layout for callback page
|
||||||
|
});
|
||||||
|
|
||||||
|
// The OIDC module handles the callback automatically
|
||||||
|
// If you need custom logic after successful authentication, you can add it here
|
||||||
|
onMounted(() => {
|
||||||
|
// Optional: Add any custom post-authentication logic here
|
||||||
|
console.log('OAuth callback page mounted');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -20,10 +20,16 @@
|
||||||
<v-list lines="two">
|
<v-list lines="two">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-if="user"
|
v-if="user"
|
||||||
:title="`${user.first_name} ${user.last_name}`"
|
:title="user.name"
|
||||||
:subtitle="user.email"
|
:subtitle="user.email"
|
||||||
prepend-icon="mdi-account"
|
prepend-icon="mdi-account"
|
||||||
/>
|
>
|
||||||
|
<template #append>
|
||||||
|
<v-chip v-if="user.tier && user.tier !== 'basic'" size="small" color="primary">
|
||||||
|
{{ user.tier }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
@click="logOut"
|
@click="logOut"
|
||||||
title="Log out"
|
title="Log out"
|
||||||
|
|
@ -65,8 +71,7 @@ definePageMeta({
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mdAndDown } = useDisplay();
|
const { mdAndDown } = useDisplay();
|
||||||
const { logout } = useDirectusAuth();
|
const { user, logout, authSource } = useUnifiedAuth();
|
||||||
const user = useDirectusUser();
|
|
||||||
const tags = usePortalTags();
|
const tags = usePortalTags();
|
||||||
|
|
||||||
const drawer = ref(false);
|
const drawer = ref(false);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
<template>
|
||||||
|
<v-container>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<h1 class="text-h4 mb-4">Authentication Test</h1>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Current Authentication Status</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon :color="isAuthenticated ? 'success' : 'error'">
|
||||||
|
{{ isAuthenticated ? 'mdi-check-circle' : 'mdi-close-circle' }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Authentication Status</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ isAuthenticated ? 'Authenticated' : 'Not Authenticated' }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item v-if="authSource">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-shield-account</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Auth Source</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ authSource }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card v-if="user">
|
||||||
|
<v-card-title>User Information</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-identifier</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>User ID</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ user.id }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-email</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Email</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ user.email }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-account</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Name</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ user.name }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-medal</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Tier</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
<v-chip :color="user.tier === 'admin' ? 'error' : 'primary'" size="small">
|
||||||
|
{{ user.tier || 'basic' }}
|
||||||
|
</v-chip>
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item v-if="isAdmin">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon color="error">mdi-shield-crown</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Admin Status</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>User has admin privileges</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row v-if="user">
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Raw User Data</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-expansion-panels>
|
||||||
|
<v-expansion-panel>
|
||||||
|
<v-expansion-panel-title>Click to view raw data</v-expansion-panel-title>
|
||||||
|
<v-expansion-panel-text>
|
||||||
|
<pre class="text-caption">{{ JSON.stringify(user.raw, null, 2) }}</pre>
|
||||||
|
</v-expansion-panel-text>
|
||||||
|
</v-expansion-panel>
|
||||||
|
</v-expansion-panels>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row class="mt-4">
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Test Actions</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-btn color="error" @click="testLogout" prepend-icon="mdi-logout">
|
||||||
|
Test Logout
|
||||||
|
</v-btn>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { user, isAuthenticated, authSource, isAdmin, logout } = useUnifiedAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const testLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
// The middleware should redirect to login automatically
|
||||||
|
};
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Authentication Test'
|
||||||
|
});
|
||||||
|
</script>
|
||||||
165
pages/login.vue
165
pages/login.vue
|
|
@ -4,70 +4,96 @@
|
||||||
<v-container class="fill-height" fluid>
|
<v-container class="fill-height" fluid>
|
||||||
<v-row align="center" justify="center" class="fill-height">
|
<v-row align="center" justify="center" class="fill-height">
|
||||||
<v-col cols="12" class="d-flex flex-column align-center">
|
<v-col cols="12" class="d-flex flex-column align-center">
|
||||||
<v-card class="pa-6" rounded max-width="350" elevation="2">
|
<v-card class="pa-6" rounded max-width="450" elevation="2">
|
||||||
<v-form @submit.prevent="submit" v-model="valid">
|
<v-row no-gutters>
|
||||||
<v-row no-gutters>
|
<v-col cols="12">
|
||||||
<v-col cols="12">
|
<v-img src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" width="200" class="mb-3 mx-auto" />
|
||||||
<v-img src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" width="200" class="mb-3 mx-auto" />
|
</v-col>
|
||||||
</v-col>
|
|
||||||
<v-scroll-y-transition>
|
<!-- Keycloak SSO Login -->
|
||||||
<v-col v-if="errorThrown" cols="12" class="my-3">
|
<v-col cols="12" class="mb-4">
|
||||||
<v-alert
|
<v-btn
|
||||||
text="Invalid email address or password"
|
color="primary"
|
||||||
color="error"
|
size="large"
|
||||||
variant="tonal"
|
block
|
||||||
/>
|
@click="loginWithKeycloak"
|
||||||
</v-col>
|
prepend-icon="mdi-shield-account"
|
||||||
</v-scroll-y-transition>
|
>
|
||||||
<v-col cols="12">
|
Login with Single Sign-On
|
||||||
<v-row dense>
|
</v-btn>
|
||||||
<v-col cols="12" class="mt-4">
|
</v-col>
|
||||||
<v-text-field
|
|
||||||
v-model="emailAddress"
|
<!-- Divider -->
|
||||||
placeholder="Email address"
|
<v-col cols="12">
|
||||||
:disabled="loading"
|
<v-divider class="my-4">
|
||||||
:rules="[
|
<span class="text-caption">OR</span>
|
||||||
(value) => !!value || 'Must not be empty',
|
</v-divider>
|
||||||
(value) =>
|
</v-col>
|
||||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ||
|
|
||||||
'Invalid email address',
|
<!-- Existing Directus Login Form -->
|
||||||
]"
|
<v-col cols="12">
|
||||||
variant="outlined"
|
<v-form @submit.prevent="submit" v-model="valid">
|
||||||
type="email"
|
<v-row no-gutters>
|
||||||
autofocus
|
<v-scroll-y-transition>
|
||||||
/>
|
<v-col v-if="errorThrown" cols="12" class="my-3">
|
||||||
</v-col>
|
<v-alert
|
||||||
|
text="Invalid email address or password"
|
||||||
|
color="error"
|
||||||
|
variant="tonal"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-scroll-y-transition>
|
||||||
<v-col cols="12">
|
<v-col cols="12">
|
||||||
<v-text-field
|
<v-row dense>
|
||||||
@click:append-inner="passwordVisible = !passwordVisible"
|
<v-col cols="12" class="mt-4">
|
||||||
v-model="password"
|
<v-text-field
|
||||||
placeholder="Password"
|
v-model="emailAddress"
|
||||||
:disabled="loading"
|
placeholder="Email address"
|
||||||
:type="passwordVisible ? 'text' : 'password'"
|
:disabled="loading"
|
||||||
:append-inner-icon="
|
:rules="[
|
||||||
passwordVisible ? 'mdi-eye' : 'mdi-eye-off'
|
(value) => !!value || 'Must not be empty',
|
||||||
"
|
(value) =>
|
||||||
:rules="[(value) => !!value || 'Must not be empty']"
|
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ||
|
||||||
autocomplete="current-password"
|
'Invalid email address',
|
||||||
variant="outlined"
|
]"
|
||||||
/>
|
variant="outlined"
|
||||||
</v-col>
|
type="email"
|
||||||
<v-col cols="12">
|
autofocus
|
||||||
<v-btn
|
/>
|
||||||
text="Log in"
|
</v-col>
|
||||||
:disabled="!valid"
|
<v-col cols="12">
|
||||||
:loading="loading"
|
<v-text-field
|
||||||
type="submit"
|
@click:append-inner="passwordVisible = !passwordVisible"
|
||||||
variant="tonal"
|
v-model="password"
|
||||||
color="primary"
|
placeholder="Password"
|
||||||
size="large"
|
:disabled="loading"
|
||||||
block
|
:type="passwordVisible ? 'text' : 'password'"
|
||||||
/>
|
:append-inner-icon="
|
||||||
|
passwordVisible ? 'mdi-eye' : 'mdi-eye-off'
|
||||||
|
"
|
||||||
|
:rules="[(value) => !!value || 'Must not be empty']"
|
||||||
|
autocomplete="current-password"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-btn
|
||||||
|
text="Log in"
|
||||||
|
:disabled="!valid"
|
||||||
|
:loading="loading"
|
||||||
|
type="submit"
|
||||||
|
variant="tonal"
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-col>
|
</v-form>
|
||||||
</v-row>
|
</v-col>
|
||||||
</v-form>
|
</v-row>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<!-- PWA Install Banner -->
|
<!-- PWA Install Banner -->
|
||||||
|
|
@ -80,18 +106,33 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
// Define page meta for public access
|
||||||
|
definePageMeta({
|
||||||
|
auth: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Directus auth
|
||||||
const { login } = useDirectusAuth();
|
const { login } = useDirectusAuth();
|
||||||
|
|
||||||
|
// OIDC auth for Keycloak
|
||||||
|
const oidc = useOidc();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const errorThrown = ref(false);
|
const errorThrown = ref(false);
|
||||||
|
|
||||||
const emailAddress = ref();
|
const emailAddress = ref();
|
||||||
|
|
||||||
const password = ref();
|
const password = ref();
|
||||||
const passwordVisible = ref(false);
|
const passwordVisible = ref(false);
|
||||||
|
|
||||||
const valid = ref(false);
|
const valid = ref(false);
|
||||||
|
|
||||||
|
// Keycloak login function
|
||||||
|
const loginWithKeycloak = () => {
|
||||||
|
// Redirect to dashboard after login
|
||||||
|
oidc.login('/dashboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Directus login function
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue