diff --git a/Infrastructure/CLAUDE.md b/Infrastructure/CLAUDE.md index d224806..1668e4e 100644 --- a/Infrastructure/CLAUDE.md +++ b/Infrastructure/CLAUDE.md @@ -64,6 +64,21 @@ param adoProjectName string = '' @description('Azure DevOps repository name for diagram storage') param adoRepositoryName string = '' + +@description('Enable EntraID authentication via Easy Auth') +param enableEntraIdAuth bool = false + +@description('Azure AD App Registration Client ID') +param entraIdClientId string = '' + +@description('Azure AD Tenant ID (defaults to subscription tenant)') +param entraIdTenantId string = subscription().tenantId + +@description('Comma-separated list of Azure AD Group Object IDs allowed to access (empty = all tenant users)') +param entraIdAllowedGroups string = '' + +@description('Disable password authentication (EntraID only)') +param disablePasswordAuth bool = false ``` ### Resource Naming Convention @@ -86,6 +101,9 @@ The template configures these environment variables for the Website: | `ADO_ORGANIZATION_URL` | Parameter | Azure DevOps org URL | | `ADO_PROJECT_NAME` | Parameter | ADO project name | | `ADO_REPOSITORY_NAME` | Parameter | Diagram storage repo | +| `ENABLE_ENTRAID_AUTH` | Parameter | Enable EntraID auth | +| `ENTRAID_ALLOWED_GROUPS` | Parameter | Group-based access control | +| `DISABLE_PASSWORD_AUTH` | Parameter | Disable password login | ## Deployment @@ -141,6 +159,249 @@ The Azure Pipeline (`azure-pipelines-deploy-jobs.yml`) deploys using: -adoRepositoryName $(AdoRepositoryName) ``` +## EntraID Authentication Setup + +The application supports **optional** Microsoft EntraID (Azure AD) authentication using **OpenID Connect** (via NextAuth.js). This provides enterprise single sign-on (SSO) with your organization's Microsoft accounts. + +**Important**: This implementation uses standard OpenID Connect flow, NOT Azure App Service Easy Auth. Users can always access the login page - authentication only occurs when they click "Sign in with Microsoft". + +### Authentication Modes + +Three authentication modes are supported: + +1. **Password Only** (default): Traditional password-based login +2. **EntraID Only**: Microsoft SSO authentication via OpenID Connect, password login disabled +3. **Dual Mode**: Users can choose between password or Microsoft SSO + +### EntraID Prerequisites + +Before enabling EntraID authentication: + +1. **Azure AD App Registration** +2. **User access to Azure AD tenant** +3. **Optional**: Azure AD security groups for access control + +### Step 1: Create Azure AD App Registration + +1. Navigate to [Azure Portal](https://portal.azure.com) → **Azure Active Directory** → **App registrations** +2. Click **New registration** +3. Configure: + - **Name**: `Data Model Viewer - {environment}` (e.g., `Data Model Viewer - Production`) + - **Supported account types**: `Accounts in this organizational directory only (Single tenant)` + - **Redirect URI**: + - Platform: `Web` + - URI: `https://wa-{solutionId}.azurewebsites.net/api/auth/callback/microsoft-entra-id` + - Replace `{solutionId}` with your actual solution ID +4. Click **Register** +5. Note the **Application (client) ID** and **Directory (tenant) ID** from the Overview page + +### Step 2: Create Client Secret + +**CRITICAL**: Azure App Service Easy Auth requires a client secret to avoid using deprecated implicit grant flow. + +1. In your App Registration, go to **Certificates & secrets** +2. Click **Client secrets** → **New client secret** +3. Enter: + - **Description**: `App Service SSO` + - **Expires**: Choose appropriate duration (e.g., 24 months) +4. Click **Add** +5. **IMPORTANT**: Copy the **Value** (not the Secret ID) immediately - it won't be shown again +6. Save this value securely - you'll use it in the deployment + +**Alternative (Preview)**: Azure supports using a managed identity with federated credentials instead of a client secret. This approach is currently in preview and requires additional setup. For production deployments, the client secret approach is recommended. See Microsoft's documentation on [using a managed identity instead of a secret](https://learn.microsoft.com/en-us/azure/app-service/configure-authentication-provider-aad?tabs=workforce-configuration%2Cworkforce-tenant#use-a-managed-identity-instead-of-a-secret-preview) if interested. + +### Step 3: Configure App Registration API Permissions + +1. In your App Registration, go to **API permissions** +2. Click **Add a permission** → **Microsoft Graph** → **Delegated permissions** +3. Add these permissions: + - `User.Read` (required - basic user profile) + - `Group.Read.All` (optional - required for group-based access control) +4. Click **Add permissions** +5. **Grant admin consent** if required by your organization + +### Step 4: Configure Token Claims (Optional - for Group-Based Access) + +To enable group-based access control: + +1. Go to **Token configuration** in your App Registration +2. Click **Add groups claim** +3. Select **Security groups** +4. Check both **ID** and **Access tokens** +5. Click **Add** + +### Step 5: Get Security Group Object IDs (Optional) + +If restricting access to specific groups: + +1. Navigate to **Azure Active Directory** → **Groups** +2. Find the group(s) that should have access +3. Click on each group and copy the **Object ID** +4. Prepare comma-separated list: `abc123-...,def456-...,ghi789-...` + +### Step 6: Deploy with EntraID Enabled + +#### Option A: Enable on New Deployment + +```bash +az deployment group create \ + --resource-group rg-datamodelviewer \ + --template-file main.bicep \ + --parameters solutionId=myorg-dmv \ + websitePassword='SecurePassword123!' \ + sessionSecret='<32-byte-random-string>' \ + enableEntraIdAuth=true \ + entraIdClientId='' \ + entraIdClientSecret='' \ + entraIdTenantId='' \ + entraIdAllowedGroups=',' \ + disablePasswordAuth=false \ + adoOrganizationUrl='https://dev.azure.com/myorg' \ + adoProjectName='MyProject' \ + adoRepositoryName='DataModelViewer' +``` + +#### Option B: Update Existing Deployment + +```bash +az deployment group create \ + --resource-group rg-datamodelviewer \ + --template-file main.bicep \ + --parameters @previous-parameters.json \ + enableEntraIdAuth=true \ + entraIdClientId='' \ + entraIdClientSecret='' \ + entraIdTenantId='' +``` + +### EntraID Parameter Reference + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `enableEntraIdAuth` | No | `false` | Enables EntraID authentication via Easy Auth | +| `entraIdClientId` | Yes if enabled | `''` | Application (client) ID from App Registration | +| `entraIdClientSecret` | Yes if enabled | `''` | Client secret value from App Registration | +| `entraIdTenantId` | No | Subscription tenant | Directory (tenant) ID for your Azure AD | +| `entraIdAllowedGroups` | No | `''` | Comma-separated group Object IDs. Empty = all tenant users | +| `disablePasswordAuth` | No | `false` | Set to `true` for EntraID-only mode | + +### Authentication Mode Examples + +#### Example 1: Dual Mode (Password + EntraID) +```bash +--parameters enableEntraIdAuth=true \ + entraIdClientId='abc123...' \ + entraIdClientSecret='secret123...' \ + disablePasswordAuth=false +``` +- Users see both "Sign in with Microsoft" and password form +- Existing password auth continues to work +- Ideal for gradual migration + +#### Example 2: EntraID Only +```bash +--parameters enableEntraIdAuth=true \ + entraIdClientId='abc123...' \ + entraIdClientSecret='secret123...' \ + disablePasswordAuth=true +``` +- Only "Sign in with Microsoft" button shown +- Password login disabled +- Full enterprise SSO + +#### Example 3: EntraID with Group Restrictions +```bash +--parameters enableEntraIdAuth=true \ + entraIdClientId='abc123...' \ + entraIdClientSecret='secret123...' \ + entraIdAllowedGroups='group-id-1,group-id-2' +``` +- Only users in specified security groups can access +- Returns 403 Forbidden for unauthorized users + +### How EntraID Authentication Works + +1. **User Access**: User navigates to `https://wa-{solutionId}.azurewebsites.net/` +2. **Easy Auth Intercepts**: App Service Easy Auth detects unauthenticated request +3. **Redirect to Microsoft**: User redirected to `login.microsoftonline.com` +4. **User Signs In**: User authenticates with Microsoft account +5. **Token Exchange**: Microsoft returns ID token to Easy Auth +6. **Header Injection**: Easy Auth validates token and injects `X-MS-CLIENT-PRINCIPAL` header +7. **Application Access**: Middleware parses header, creates session, grants access + +### Troubleshooting EntraID Authentication + +#### Users Get "AADSTS700054: response_type 'id_token' is not enabled" Error + +**Problem**: No client secret configured - Easy Auth is falling back to deprecated implicit grant flow + +**Solution**: +1. Create a client secret in your App Registration: + - Go to Azure Portal → Azure Active Directory → App registrations + - Select your Data Model Viewer app registration + - Go to **Certificates & secrets** → **Client secrets** → **New client secret** + - Copy the secret **Value** (not Secret ID) +2. Redeploy with the client secret parameter: + ```bash + az deployment group create \ + --resource-group rg-datamodelviewer \ + --template-file main.bicep \ + --parameters @previous-parameters.json \ + entraIdClientSecret='' + ``` +3. Wait 2-3 minutes for App Service to restart +4. Try logging in again + +**Note**: Easy Auth requires a client secret to avoid using the deprecated OAuth 2.0 implicit grant flow + +#### Users Get "Redirect URI Mismatch" Error + +**Problem**: App Registration redirect URI doesn't match deployed URL + +**Solution**: +1. Check App Registration → Authentication → Redirect URIs +2. Ensure it matches: `https://wa-{solutionId}.azurewebsites.net/.auth/login/aad/callback` +3. Verify HTTPS (not HTTP) +4. No trailing slash + +#### Users Get "AADSTS50020: User account does not exist" Error + +**Problem**: User's account is not in the specified tenant + +**Solution**: +1. Verify user belongs to correct Azure AD tenant +2. Check App Registration is "Single tenant" type +3. Ensure user account is not external/guest (or add multi-tenant support) + +#### Users Get 403 Forbidden After Login + +**Problem**: User not in allowed security groups + +**Solution**: +1. Check `entraIdAllowedGroups` parameter includes user's group +2. Verify group claim is configured in token configuration +3. Check API permission `Group.Read.All` is granted +4. Wait 5-10 minutes for group membership cache to refresh + +#### EntraID Login Doesn't Work Locally + +**Expected Behavior**: Easy Auth only works on Azure App Service + +**Solution**: +- Use password authentication for local development +- Set `ENABLE_ENTRAID_AUTH=false` in `.env.local` +- Test EntraID in deployed dev environment + +#### Can't Find App Service Managed Identity in Azure AD + +**Problem**: Looking for wrong object + +**Solution**: +- Managed Identity is for **backend services** (Dataverse, ADO) +- **EntraID/Easy Auth** is for **user authentication** +- These are separate authentication mechanisms +- Don't add users to Managed Identity + ## Post-Deployment Configuration ### 1. Configure Startup Command diff --git a/Infrastructure/main.bicep b/Infrastructure/main.bicep index 7e32d13..0b09a1d 100644 --- a/Infrastructure/main.bicep +++ b/Infrastructure/main.bicep @@ -8,6 +8,25 @@ param adoOrganizationUrl string = '' param adoProjectName string = '' param adoRepositoryName string = '' +@description('Enable EntraID authentication') +param enableEntraIdAuth bool = false + +@description('Azure AD App Registration Client ID') +param entraIdClientId string = '' + +@description('Azure AD App Registration Client Secret') +@secure() +param entraIdClientSecret string = '' + +@description('Azure AD Tenant ID (defaults to subscription tenant)') +param entraIdTenantId string = subscription().tenantId + +@description('Comma-separated list of Azure AD Group Object IDs allowed to access (empty = all tenant users)') +param entraIdAllowedGroups string = '' + +@description('Disable password authentication') +param disablePasswordAuth bool = false + var location = resourceGroup().location @description('Create an App Service Plan') @@ -45,6 +64,14 @@ resource webApp 'Microsoft.Web/sites@2021-02-01' = { name: 'WebsiteSessionSecret' value: sessionSecret } + { + name: 'AUTH_SECRET' + value: sessionSecret + } + { + name: 'NEXTAUTH_URL' + value: 'https://wa-${solutionId}.azurewebsites.net' + } { name: 'WEBSITE_NODE_DEFAULT_VERSION' value: '~20' @@ -61,6 +88,30 @@ resource webApp 'Microsoft.Web/sites@2021-02-01' = { name: 'ADO_REPOSITORY_NAME' value: adoRepositoryName } + { + name: 'ENABLE_ENTRAID_AUTH' + value: string(enableEntraIdAuth) + } + { + name: 'AZURE_AD_CLIENT_ID' + value: entraIdClientId + } + { + name: 'AZURE_AD_CLIENT_SECRET' + value: entraIdClientSecret + } + { + name: 'AZURE_AD_TENANT_ID' + value: entraIdTenantId + } + { + name: 'ENTRAID_ALLOWED_GROUPS' + value: entraIdAllowedGroups + } + { + name: 'DISABLE_PASSWORD_AUTH' + value: string(disablePasswordAuth) + } ] } } diff --git a/README.md b/README.md index 2630e15..a5d1c63 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@

+> [!NOTE] +> The README is slowly being moved to [Git Wiki](https://github.com/delegateas/DataModelViewer/wiki). Newer features are documented in the wiki whilst older ones are documented in this Readme. + # 👋 Introduction

diff --git a/Setup/azure-pipelines-deploy-jobs.yml b/Setup/azure-pipelines-deploy-jobs.yml index 58cce5e..9b2bbec 100644 --- a/Setup/azure-pipelines-deploy-jobs.yml +++ b/Setup/azure-pipelines-deploy-jobs.yml @@ -32,6 +32,24 @@ parameters: - name: adoRepositoryName type: string default: '' + - name: azureTenantId + type: string + default: '' + - name: azureClientId + type: string + default: '' + - name: azureClientSecret + type: string + default: '' + - name: enableEntraIdAuth + type: string + default: 'false' + - name: disablePasswordAuth + type: string + default: 'false' + - name: entraIdAllowedGroups + type: string + default: '' steps: - task: AzureCLI@2 @@ -54,6 +72,12 @@ steps: --parameters adoOrganizationUrl="${{ parameters.adoOrganizationUrl }}" ` --parameters adoProjectName="${{ parameters.adoProjectName }}" ` --parameters adoRepositoryName="${{ parameters.adoRepositoryName }}" ` + --parameters entraIdTenantId="${{ parameters.azureTenantId }}" ` + --parameters entraIdClientId="${{ parameters.azureClientId }}" ` + --parameters entraIdClientSecret="${{ parameters.azureClientSecret }}" ` + --parameters enableEntraIdAuth="${{ parameters.enableEntraIdAuth }}" ` + --parameters disablePasswordAuth="${{ parameters.disablePasswordAuth }}" ` + --parameters entraIdAllowedGroups="${{ parameters.entraIdAllowedGroups }}" ` | ConvertFrom-Json # Extract outputs diff --git a/Setup/azure-pipelines-external.yml b/Setup/azure-pipelines-external.yml index 3ecd784..cc9c024 100644 --- a/Setup/azure-pipelines-external.yml +++ b/Setup/azure-pipelines-external.yml @@ -16,13 +16,18 @@ # - WebsitePassword # - WebsiteSessionSecret # - WebsiteName -# - AzureTenantId -# - AzureClientId -# - AzureClientSecret +# - AzureTenantId (for Dataverse access during build) +# - AzureClientId (for Dataverse access during build) +# - AzureClientSecret (for Dataverse access during build) # - DataverseUrl # - AdoWikiName # - AdoWikiPagePath # - AdoRepositoryName +# +# Optional variables (for Entra ID authentication): +# - EnableEntraIdAuth (string: 'true' or 'false', default: 'false') +# - DisablePasswordAuth (string: 'true' or 'false', default: 'false') +# - EntraIdAllowedGroups (comma-separated list of Entra ID group object IDs) trigger: none pr: none @@ -94,4 +99,10 @@ stages: websiteName: $(WebsiteName) adoOrganizationUrl: $(System.CollectionUri) adoProjectName: $(System.TeamProject) - adoRepositoryName: $(AdoRepositoryName) \ No newline at end of file + adoRepositoryName: ${{ coalesce(variables.AdoRepositoryName, '') }} + enableEntraIdAuth: ${{ coalesce(variables.EnableEntraIdAuth, false) }} + azureTenantId: ${{ coalesce(variables.AzureTenantId, '') }} + azureClientId: ${{ coalesce(variables.AzureClientId, '') }} + azureClientSecret: ${{ coalesce(variables.AzureClientSecret, '') }} + entraIdAllowedGroups: ${{ coalesce(variables.EntraIdAllowedGroups, '') }} + disablePasswordAuth: ${{ coalesce(variables.DisablePasswordAuth, false) }} \ No newline at end of file diff --git a/Website/CLAUDE.md b/Website/CLAUDE.md index ce11501..f3b7168 100644 --- a/Website/CLAUDE.md +++ b/Website/CLAUDE.md @@ -194,6 +194,71 @@ Authentication uses either: File operations always specify branch (default: 'main') and commit messages. +## Authentication System + +The application supports two authentication methods that can be used independently or together: + +### Password Authentication (Default) +- Traditional password-based login +- JWT session stored in HTTP-only cookie +- Configured via `WebsitePassword` environment variable +- Session managed by [lib/session.ts](lib/session.ts) + +### EntraID Authentication (Optional) +- Microsoft single sign-on (SSO) using Azure Active Directory +- Enabled via App Service Easy Auth +- Optional group-based access control +- User information extracted from `X-MS-CLIENT-PRINCIPAL` header + +### Authentication Architecture + +**Unified Session Model**: Both auth types use the same session cookie format with `authType` field: + +```typescript +type Session = { + authType: 'password' | 'entraid'; + expiresAt: Date; + password?: string; // Password auth + userPrincipalName?: string; // EntraID auth + userId?: string; // EntraID auth + name?: string; // EntraID auth + groups?: string[]; // EntraID auth (for access control) +} +``` + +**Authentication Flow**: +1. [middleware.ts](middleware.ts) checks authentication on every request +2. If EntraID enabled: Parse `X-MS-CLIENT-PRINCIPAL` header → validate groups → create session +3. If password enabled: Validate existing JWT session cookie +4. [AuthContext.tsx](contexts/AuthContext.tsx) provides auth state to React components +5. Login page conditionally shows password form and/or "Sign in with Microsoft" button + +### EntraID Configuration + +**Environment Variables** ([.env.local](.env.local) for local, App Service settings for production): + +```bash +# Enable EntraID authentication +ENABLE_ENTRAID_AUTH=false # Set to true to enable + +# Group-based access control (optional) +ENTRAID_ALLOWED_GROUPS= # Comma-separated Azure AD group Object IDs + # Empty = all tenant users allowed + +# Disable password authentication (optional) +DISABLE_PASSWORD_AUTH=false # Set to true for EntraID-only mode +``` + +**Key Files**: +- [lib/auth/entraid.ts](lib/auth/entraid.ts) - EntraID utilities (parse headers, validate groups) +- [middleware.ts](middleware.ts) - Authentication middleware (checks both auth types) +- [lib/session.ts](lib/session.ts) - Session management (unified for both auth types) +- [components/loginview/LoginView.tsx](components/loginview/LoginView.tsx) - Login UI + +**Setup Instructions**: See [Infrastructure/CLAUDE.md](../Infrastructure/CLAUDE.md) for complete Azure AD App Registration setup guide. + +**Local Development**: EntraID requires deployment to Azure App Service (Easy Auth doesn't work locally). Use password auth for local development. + ## Styling Guidelines **CRITICAL**: This project uses a specific MUI + Tailwind integration pattern. diff --git a/Website/app/api/auth/[...nextauth]/route.ts b/Website/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..0a98352 --- /dev/null +++ b/Website/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '@/auth'; + +export const { GET, POST } = handlers; diff --git a/Website/app/api/auth/login/route.ts b/Website/app/api/auth/login/route.ts index 7e10007..8198594 100644 --- a/Website/app/api/auth/login/route.ts +++ b/Website/app/api/auth/login/route.ts @@ -1,14 +1,56 @@ 'use server'; import { NextResponse } from "next/server"; +import { + checkRateLimit, + recordFailedAttempt, + recordSuccessfulAttempt, + getClientIp +} from "@/lib/auth/rateLimit"; export async function POST(req: Request) { + const clientIp = getClientIp(req); + + // Check if the IP is currently rate limited + const rateLimitCheck = checkRateLimit(clientIp); + if (rateLimitCheck.isLocked) { + return NextResponse.json( + { + error: rateLimitCheck.message, + remainingTime: rateLimitCheck.remainingTime + }, + { status: 429 } // 429 Too Many Requests + ); + } + const body: { password: string } = await req.json(); const validPassword = process.env.WebsitePassword; + // Validate password if (body.password !== validPassword) { - return NextResponse.json({}, { status: 401 }); + const failureResult = recordFailedAttempt(clientIp); + + if (failureResult.isLocked) { + return NextResponse.json( + { + error: failureResult.message, + remainingTime: failureResult.remainingTime + }, + { status: 429 } + ); + } + + return NextResponse.json( + { + error: failureResult.message || "Invalid password", + attemptsRemaining: failureResult.attemptsRemaining + }, + { status: 401 } + ); } + // Successful login - reset attempt counter + recordSuccessfulAttempt(clientIp); + return NextResponse.json({}, { status: 200 }); } \ No newline at end of file diff --git a/Website/app/api/auth/logout/route.ts b/Website/app/api/auth/logout/route.ts index cd99ace..65b213a 100644 --- a/Website/app/api/auth/logout/route.ts +++ b/Website/app/api/auth/logout/route.ts @@ -1,17 +1,30 @@ 'use server'; import { NextResponse } from "next/server"; -import { deleteSession } from "@/lib/session"; +import { deleteSession, getSession } from "@/lib/session"; +import { signOut } from "@/auth"; export async function POST() { try { + const session = await getSession(); + const wasEntraId = session?.authType === 'entraid'; + + // Delete custom session cookie await deleteSession(); - // Return success response instead of redirect since we handle navigation client-side - return NextResponse.json({ success: true }); + + // If EntraID, also sign out from NextAuth + if (wasEntraId) { + await signOut({ redirect: false }); + } + + return NextResponse.json({ + success: true, + redirectToEntraIdLogout: wasEntraId + }); } catch (error) { console.error('Logout error:', error); return NextResponse.json( - { error: 'Failed to logout' }, + { success: false, error: 'Logout failed' }, { status: 500 } ); } diff --git a/Website/app/api/auth/session/route.ts b/Website/app/api/auth/session/route.ts index 360f73b..d7037fc 100644 --- a/Website/app/api/auth/session/route.ts +++ b/Website/app/api/auth/session/route.ts @@ -4,16 +4,45 @@ import { getSession } from "@/lib/session"; export async function GET() { try { const session = await getSession(); - const isAuthenticated = session !== null; - - return NextResponse.json({ - isAuthenticated, - user: isAuthenticated ? { authenticated: true } : null - }); + + if (!session) { + return NextResponse.json({ + isAuthenticated: false, + authType: null, + user: null + }); + } + + const response: { + isAuthenticated: boolean; + authType: 'password' | 'entraid'; + user: { + userPrincipalName?: string; + name?: string; + authenticated?: boolean; + }; + } = { + isAuthenticated: true, + authType: session.authType, + user: {} + }; + + if (session.authType === 'entraid') { + response.user = { + userPrincipalName: session.userPrincipalName, + name: session.name + }; + } else { + response.user = { + authenticated: true + }; + } + + return NextResponse.json(response); } catch (error) { console.error('Session check error:', error); return NextResponse.json( - { isAuthenticated: false, user: null }, + { isAuthenticated: false, authType: null, user: null }, { status: 500 } ); } diff --git a/Website/app/api/version/route.ts b/Website/app/api/version/route.ts index d4c7665..75561ce 100644 --- a/Website/app/api/version/route.ts +++ b/Website/app/api/version/route.ts @@ -1,6 +1,11 @@ import { NextResponse } from 'next/server' import { version } from '../../../package.json' +import { isEntraIdEnabled, isPasswordAuthDisabled } from '@/lib/auth/entraid' export async function GET() { - return NextResponse.json({ version }) + return NextResponse.json({ + version, + entraIdEnabled: isEntraIdEnabled(), + passwordAuthDisabled: isPasswordAuthDisabled() + }) } diff --git a/Website/auth.config.ts b/Website/auth.config.ts new file mode 100644 index 0000000..30bbe00 --- /dev/null +++ b/Website/auth.config.ts @@ -0,0 +1,85 @@ +import type { NextAuthConfig } from 'next-auth'; +import MicrosoftEntraID from 'next-auth/providers/microsoft-entra-id'; + +export const authConfig = { + providers: [ + MicrosoftEntraID({ + clientId: process.env.AZURE_AD_CLIENT_ID, + clientSecret: process.env.AZURE_AD_CLIENT_SECRET, + issuer: process.env.AZURE_TENANT_ID + ? `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/v2.0` + : undefined, + authorization: { + params: { + scope: 'openid profile email User.Read', + }, + }, + async profile(profile, tokens) { + // Only fetch groups if group-based access control is configured + let groups: string[] = []; + const allowedGroups = process.env.ENTRAID_ALLOWED_GROUPS || ''; + const hasGroupRestrictions = allowedGroups.trim().length > 0; + + if (hasGroupRestrictions && tokens.access_token) { + try { + const response = await fetch('https://graph.microsoft.com/v1.0/me/memberOf', { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + // Extract group Object IDs + groups = data.value + .filter((item: any) => item['@odata.type'] === '#microsoft.graph.group') + .map((group: any) => group.id); + } else { + console.error('Failed to fetch groups:', response.status, response.statusText); + } + } catch (error) { + console.error('Error fetching groups:', error); + } + } + + return { + id: profile.sub || profile.oid, + name: profile.name, + email: profile.email || profile.preferred_username, + groups, + oid: profile.oid, + tid: profile.tid, + preferred_username: profile.preferred_username, + }; + }, + }), + ], + pages: { + signIn: '/login', + error: '/login', + }, + callbacks: { + authorized({ auth, request: { nextUrl } }) { + const isLoggedIn = !!auth?.user; + const isOnLoginPage = nextUrl.pathname === '/login'; + + // Allow everyone to access the login page + if (isOnLoginPage) { + return true; + } + + // Public API endpoints + const publicApiEndpoints = ['/api/auth', '/api/version']; + if (publicApiEndpoints.some(path => nextUrl.pathname.startsWith(path))) { + return true; + } + + // All other pages require authentication + if (!isLoggedIn) { + return false; // Will redirect to /login + } + + return true; + }, + }, +} satisfies NextAuthConfig; diff --git a/Website/auth.ts b/Website/auth.ts new file mode 100644 index 0000000..1f61b8b --- /dev/null +++ b/Website/auth.ts @@ -0,0 +1,65 @@ +import NextAuth from 'next-auth'; +import { authConfig } from './auth.config'; +import { getEntraIdAllowedGroups } from './lib/auth/entraid'; +import { createEntraIdSession } from './lib/session'; + +export const { handlers, auth, signIn, signOut } = NextAuth({ + ...authConfig, + callbacks: { + ...authConfig.callbacks, + async signIn({ user, account, profile }) { + // Validate EntraID users before creating session + if (account?.provider === 'microsoft-entra-id' && profile) { + // Groups are now fetched via the profile callback and available in user object + const userGroups = (user as any).groups || (profile as any).groups || []; + const allowedGroups = getEntraIdAllowedGroups(); + + // If groups are configured, validate access + if (allowedGroups.length > 0) { + const hasAccess = userGroups.some((group: string) => allowedGroups.includes(group)); + if (!hasAccess) return false; + } + + // Create custom session cookie only after group validation passes + await createEntraIdSession({ + userPrincipalName: (user as any).preferred_username || user.email || '', + userId: (user as any).oid || user.id || '', + name: user.name || '', + groups: userGroups + }); + } + + return true; + }, + async jwt({ token, account, profile, user }) { + // On initial sign in, add custom claims + if (account && profile) { + token.tenantId = (profile as any).tid; + token.groups = (user as any)?.groups || (profile as any).groups || []; + token.userId = (profile as any).oid; + } + return token; + }, + async session({ session, token }) { + // Add custom claims to session + if (session.user) { + (session.user as any).tenantId = token.tenantId; + (session.user as any).groups = token.groups; + (session.user as any).userId = token.userId; + } + + // Validate group access + const allowedGroups = getEntraIdAllowedGroups(); + if (allowedGroups.length > 0) { + const userGroups = (token.groups as string[]) || []; + const hasAccess = userGroups.some(group => allowedGroups.includes(group)); + + if (!hasAccess) { + throw new Error('User not in allowed groups'); + } + } + + return session; + }, + }, +}); diff --git a/Website/components/homeview/HomeView.tsx b/Website/components/homeview/HomeView.tsx index 2d0c3a6..d844f40 100644 --- a/Website/components/homeview/HomeView.tsx +++ b/Website/components/homeview/HomeView.tsx @@ -23,6 +23,13 @@ export const HomeView = ({ }: IHomeViewProps) => { // Carousel data const carouselItems: CarouselItem[] = [ + { + image: '/MSAuthentication.jpg', + title: 'Microsoft Entra ID Authentication!', + text: 'Enhanced security with Microsoft Entra ID (Azure AD) authentication. Support for group-based access control, allowing administrators to restrict access to specific security groups. Seamlessly integrates with your organization\'s identity provider for secure single sign-on (SSO).', + type: '(v2.3.0) Security Feature', + actionlabel: 'Learn More' + }, { image: '/processes.jpg', title: 'Implicit data!', @@ -121,7 +128,6 @@ export const HomeView = ({ }: IHomeViewProps) => { } - backgroundImage={carouselItems[currentCarouselIndex]?.image} className='h-96' > { const router = useRouter(); + const searchParams = useSearchParams(); const { setAuthenticated } = useAuth(); - const { - isAuthenticating, - isRedirecting, - startAuthentication, - startRedirection, - stopAuthentication, - resetAuthState + const { + isAuthenticating, + isRedirecting, + startAuthentication, + startRedirection, + stopAuthentication, + resetAuthState } = useLoading(); const [showPassword, setShowPassword] = useState(false); const [version, setVersion] = useState(null); const [showIncorrectPassword, setShowIncorrectPassword] = useState(false); const [animateError, setAnimateError] = useState(false); + const [entraIdEnabled, setEntraIdEnabled] = useState(false); + const [passwordAuthDisabled, setPasswordAuthDisabled] = useState(false); + const [isLoadingConfig, setIsLoadingConfig] = useState(true); + const [isEntraIdAuthenticating, setIsEntraIdAuthenticating] = useState(false); + const [authError, setAuthError] = useState(null); + const [errorMessage, setErrorMessage] = useState('The password is incorrect.'); + const [isLockedOut, setIsLockedOut] = useState(false); + + useEffect(() => { + // Check for authentication errors in URL + const error = searchParams.get('error'); + if (error) { + setAuthError('Access denied. You are not a member of an authorized security group or not in a correct tenant.'); + setIsEntraIdAuthenticating(false); + } + }, [searchParams]); useEffect(() => { fetch('/api/version') .then((res) => res.json()) - .then((data) => setVersion(data.version)) + .then((data) => { + setVersion(data.version); + setEntraIdEnabled(data.entraIdEnabled || false); + setPasswordAuthDisabled(data.passwordAuthDisabled || false); + }) .catch(() => setVersion('Unknown')) + .finally(() => setIsLoadingConfig(false)); }, []); const handleClickShowPassword = () => setShowPassword((show) => !show); + const handleEntraIdLogin = async () => { + setIsEntraIdAuthenticating(true); + try { + // Use NextAuth signIn + const { signIn } = await import('next-auth/react'); + await signIn('microsoft-entra-id', { callbackUrl: '/' }); + } catch (error) { + console.error('EntraID login error:', error); + setIsEntraIdAuthenticating(false); + } + }; + async function handleSubmit(event: FormEvent) { event.preventDefault(); - + startAuthentication(); setShowIncorrectPassword(false); setAnimateError(false); + setIsLockedOut(false); const formData = new FormData(event.currentTarget); const password = formData.get("password") @@ -64,6 +99,18 @@ const LoginView = ({ }: LoginViewProps) => { startRedirection(); router.push("/"); } else { + const data = await response.json(); + + if (response.status === 429) { + // Rate limited / locked out + setIsLockedOut(true); + setErrorMessage(data.error || 'Too many failed attempts. Please try again later.'); + } else if (response.status === 401) { + // Invalid password + setIsLockedOut(false); + setErrorMessage(data.error || 'The password is incorrect.'); + } + setShowIncorrectPassword(true); setTimeout(() => setAnimateError(true), 10); stopAuthentication(); @@ -71,6 +118,8 @@ const LoginView = ({ }: LoginViewProps) => { } catch (error) { console.error('Login error:', error); setShowIncorrectPassword(true); + setIsLockedOut(false); + setErrorMessage('An error occurred. Please try again.'); setTimeout(() => setAnimateError(true), 10); resetAuthState(); } @@ -78,9 +127,9 @@ const LoginView = ({ }: LoginViewProps) => { return ( - @@ -96,7 +145,7 @@ const LoginView = ({ }: LoginViewProps) => { Sign in to your organization } severity="info" className='w-full rounded-lg'> - Last synchronization: {LastSynched ? LastSynched.toLocaleString('en-DK', { + Last synchronization: {LastSynched ? LastSynched.toLocaleString('en-DK', { timeZone: 'Europe/Copenhagen', timeZoneName: 'short', year: 'numeric', @@ -107,64 +156,118 @@ const LoginView = ({ }: LoginViewProps) => { }) : '...'} {showIncorrectPassword && ( - } - severity="warning" - className={`w-full rounded-lg mt-4 transition-all duration-300 ease-out ${ - animateError - ? 'translate-x-0 opacity-100' - : 'translate-x-4 opacity-0' - }`} + } + severity={isLockedOut ? "error" : "warning"} + className={`w-full rounded-lg mt-4 transition-all duration-300 ease-out ${animateError + ? 'translate-x-0 opacity-100' + : 'translate-x-4 opacity-0' + }`} > - The password is incorrect. + {errorMessage} )} -

- - Password - - - {showPassword ? : } - - - } - label="Password" - /> - - - + {authError} + + )} + + {isLoadingConfig ? ( + <> + + + OR + + + ) : ( + entraIdEnabled && ( + <> + + {!passwordAuthDisabled && ( + + OR + + )} + + ) + )} + + {isLoadingConfig ? ( + + + + + ) : ( + !passwordAuthDisabled && ( +
+ + Password + + + {showPassword ? : } + + + } + label="Password" + /> + + +
+ ) + )} Version: {version ?? '...'} diff --git a/Website/components/shared/elements/Carousel.tsx b/Website/components/shared/elements/Carousel.tsx index ff5f3bd..dba1fbc 100644 --- a/Website/components/shared/elements/Carousel.tsx +++ b/Website/components/shared/elements/Carousel.tsx @@ -28,13 +28,13 @@ const Carousel = ({ items, currentIndex = 0, slideDirection = null, className }: if (currentIndex !== prevIndex && slideDirection) { setAnimationState('sliding'); setShowPrevious(true); - + const timer = setTimeout(() => { setPrevIndex(currentIndex); setAnimationState('idle'); setShowPrevious(false); }, 500); - + return () => clearTimeout(timer); } else if (currentIndex !== prevIndex) { setPrevIndex(currentIndex); @@ -46,6 +46,36 @@ const Carousel = ({ items, currentIndex = 0, slideDirection = null, className }: return ( + {/* Previous background image - animating out */} + {showPrevious && animationState === 'sliding' && previousItem.image && ( + + )} + + {/* Current background image - animating in or static */} + {currentItem.image && ( + + )} + {/* Gradient overlay for text readability */} diff --git a/Website/contexts/AuthContext.tsx b/Website/contexts/AuthContext.tsx index 681fcd8..4642e79 100644 --- a/Website/contexts/AuthContext.tsx +++ b/Website/contexts/AuthContext.tsx @@ -5,17 +5,24 @@ import { createContext, ReactNode, useContext, useState, useEffect, useCallback export interface AuthState { isAuthenticated: boolean | null; // null = loading isLoading: boolean; + authType?: 'password' | 'entraid'; + user?: { + userPrincipalName?: string; + name?: string; + }; } const initialState: AuthState = { isAuthenticated: null, - isLoading: true + isLoading: true, + authType: undefined, + user: undefined } interface AuthContextType extends AuthState { checkAuth: () => Promise; setAuthenticated: (authenticated: boolean) => void; - logout: () => void; + logout: () => Promise; } const AuthContext = createContext(undefined); @@ -34,13 +41,17 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { const data = await response.json(); setState({ isAuthenticated: data.isAuthenticated, - isLoading: false + isLoading: false, + authType: data.authType, + user: data.user }); } catch (error) { console.error('Auth check failed:', error); setState({ isAuthenticated: false, - isLoading: false + isLoading: false, + authType: undefined, + user: undefined }); } }, []); @@ -53,11 +64,31 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { })); }, []); - const logout = useCallback(() => { - setState({ - isAuthenticated: false, - isLoading: false - }); + const logout = useCallback(async () => { + try { + // Call logout API to clear both custom session and NextAuth session + await fetch('/api/auth/logout', { method: 'POST' }); + + setState({ + isAuthenticated: false, + isLoading: false, + authType: undefined, + user: undefined + }); + + // Redirect to login page + window.location.href = '/login'; + } catch (error) { + console.error('Logout failed:', error); + // Still redirect to login even if API call fails + setState({ + isAuthenticated: false, + isLoading: false, + authType: undefined, + user: undefined + }); + window.location.href = '/login'; + } }, []); useEffect(() => { diff --git a/Website/lib/auth/entraid.ts b/Website/lib/auth/entraid.ts new file mode 100644 index 0000000..5f0ab18 --- /dev/null +++ b/Website/lib/auth/entraid.ts @@ -0,0 +1,19 @@ +export interface ParsedEntraIdUser { + userPrincipalName: string; + userId: string; + name: string; + groups: string[]; +} + +export function isEntraIdEnabled(): boolean { + return process.env.ENABLE_ENTRAID_AUTH === 'true'; +} + +export function isPasswordAuthDisabled(): boolean { + return process.env.DISABLE_PASSWORD_AUTH === 'true'; +} + +export function getEntraIdAllowedGroups(): string[] { + const groups = process.env.ENTRAID_ALLOWED_GROUPS || ''; + return groups.split(',').filter(g => g.trim().length > 0); +} \ No newline at end of file diff --git a/Website/lib/auth/rateLimit.ts b/Website/lib/auth/rateLimit.ts new file mode 100644 index 0000000..61e666a --- /dev/null +++ b/Website/lib/auth/rateLimit.ts @@ -0,0 +1,225 @@ +/** + * Rate limiting and brute-force protection for login attempts + * Tracks failed login attempts by IP address with progressive lockout durations + */ + +type AttemptRecord = { + attempts: number; + lockoutUntil: Date | null; + lastAttempt: Date; + lockoutCount: number; // Track how many times they've been locked out +}; + +// In-memory store for tracking login attempts by IP address (could be replaced with a persistent store someday maybe?) +const attemptStore = new Map(); + +// Configuration constants +const MAX_ATTEMPTS = 5; +const CLEANUP_INTERVAL = 60 * 60 * 1000; + +const LOCKOUT_DURATIONS = [ + 15 * 60 * 1000, // 1st lockout: 15 minutes + 30 * 60 * 1000, // 2nd lockout: 30 minutes + 60 * 60 * 1000, // 3rd lockout: 1 hour + 2 * 60 * 60 * 1000, // 4th lockout: 2 hours + 4 * 60 * 60 * 1000, // 5th lockout: 4 hours + 8 * 60 * 60 * 1000, // 6th lockout: 8 hours + 24 * 60 * 60 * 1000, // 7th+ lockout: 24 hours +]; +function getLockoutDuration(lockoutCount: number): number { + const index = Math.min(lockoutCount, LOCKOUT_DURATIONS.length - 1); + return LOCKOUT_DURATIONS[index]; +} + +/** + * Clean up expired entries from the attempt store + */ +function cleanupExpiredEntries(): void { + const now = new Date(); + const expirationThreshold = 24 * 60 * 60 * 1000; // Remove entries older than 24 hours with no lockout + + for (const [ip, record] of attemptStore.entries()) { + // Remove if: no lockout and last attempt was more than 24 hours ago + if (!record.lockoutUntil && now.getTime() - record.lastAttempt.getTime() > expirationThreshold) { + attemptStore.delete(ip); + } + // Remove if: lockout expired more than 24 hours ago + else if (record.lockoutUntil && now.getTime() - record.lockoutUntil.getTime() > expirationThreshold) { + attemptStore.delete(ip); + } + } +} + +// Start cleanup interval +if (typeof setInterval !== 'undefined') { + setInterval(cleanupExpiredEntries, CLEANUP_INTERVAL); +} + +/** + * Get or create an attempt record for an IP address + */ +function getAttemptRecord(ip: string): AttemptRecord { + let record = attemptStore.get(ip); + + if (!record) { + record = { + attempts: 0, + lockoutUntil: null, + lastAttempt: new Date(), + lockoutCount: 0, + }; + attemptStore.set(ip, record); + } + + return record; +} + +/** + * Check if an IP address is currently locked out + * Returns the lockout info if locked, null otherwise + */ +export function checkRateLimit(ip: string): { + isLocked: boolean; + remainingTime?: number; + attemptsRemaining?: number; + message?: string; +} { + const record = getAttemptRecord(ip); + const now = new Date(); + + // Check if currently locked out + if (record.lockoutUntil && now < record.lockoutUntil) { + const remainingMs = record.lockoutUntil.getTime() - now.getTime(); + const remainingMinutes = Math.ceil(remainingMs / (60 * 1000)); + + return { + isLocked: true, + remainingTime: remainingMs, + message: `Too many failed attempts. Please try again in ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''}.`, + }; + } + + // Lockout expired, reset attempts + if (record.lockoutUntil && now >= record.lockoutUntil) { + record.attempts = 0; + record.lockoutUntil = null; + } + + // Return remaining attempts + const attemptsRemaining = Math.max(0, MAX_ATTEMPTS - record.attempts); + + return { + isLocked: false, + attemptsRemaining, + }; +} + +/** + * Record a failed login attempt for an IP address + * Returns lockout info if the user is now locked out + */ +export function recordFailedAttempt(ip: string): { + isLocked: boolean; + remainingTime?: number; + attemptsRemaining?: number; + message?: string; +} { + const record = getAttemptRecord(ip); + const now = new Date(); + + // Increment attempt counter + record.attempts += 1; + record.lastAttempt = now; + + // Check if we've hit the max attempts + if (record.attempts >= MAX_ATTEMPTS) { + record.lockoutCount += 1; + const lockoutDuration = getLockoutDuration(record.lockoutCount - 1); + record.lockoutUntil = new Date(now.getTime() + lockoutDuration); + + const remainingMinutes = Math.ceil(lockoutDuration / (60 * 1000)); + + return { + isLocked: true, + remainingTime: lockoutDuration, + message: `Too many failed attempts. Account locked for ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''}.`, + }; + } + + // Return remaining attempts + const attemptsRemaining = MAX_ATTEMPTS - record.attempts; + + return { + isLocked: false, + attemptsRemaining, + message: attemptsRemaining > 0 + ? `Invalid password. ${attemptsRemaining} attempt${attemptsRemaining !== 1 ? 's' : ''} remaining.` + : undefined, + }; +} + +export function recordSuccessfulAttempt(ip: string): void { + const record = getAttemptRecord(ip); + // reset on successful login + record.attempts = 0; + record.lockoutUntil = null; + record.lastAttempt = new Date(); +} + +/** + * Get client IP address from request headers + * Handles various proxy configurations + */ +export function getClientIp(request: Request): string { + const headers = request.headers; + + // Try common headers for proxied requests + const forwarded = headers.get('x-forwarded-for'); + if (forwarded) { + // x-forwarded-for can contain multiple IPs, take the first one + return forwarded.split(',')[0].trim(); + } + + const realIp = headers.get('x-real-ip'); + if (realIp) { + return realIp; + } + + const cfConnectingIp = headers.get('cf-connecting-ip'); // Cloudflare + if (cfConnectingIp) { + return cfConnectingIp; + } + + return 'unknown'; +} + +export function formatRemainingTime(milliseconds: number): string { + const minutes = Math.floor(milliseconds / (60 * 1000)); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainingHours = hours % 24; + return `${days} day${days !== 1 ? 's' : ''}${remainingHours > 0 ? ` and ${remainingHours} hour${remainingHours !== 1 ? 's' : ''}` : ''}`; + } + + if (hours > 0) { + const remainingMinutes = minutes % 60; + return `${hours} hour${hours !== 1 ? 's' : ''}${remainingMinutes > 0 ? ` and ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''}` : ''}`; + } + + return `${Math.max(1, minutes)} minute${minutes !== 1 ? 's' : ''}`; +} + +export function resetAttempts(ip: string): void { + attemptStore.delete(ip); +} + +export function getRateLimitStats() { + return { + totalTrackedIps: attemptStore.size, + lockedIps: Array.from(attemptStore.entries()) + .filter(([, record]) => record.lockoutUntil && new Date() < record.lockoutUntil) + .length, + }; +} diff --git a/Website/lib/session.ts b/Website/lib/session.ts index 2be88aa..0fe3cbb 100644 --- a/Website/lib/session.ts +++ b/Website/lib/session.ts @@ -7,8 +7,17 @@ const secretKey = process.env.WebsiteSessionSecret; const encodedKey = new TextEncoder().encode(secretKey); export type Session = { - password: string; + authType: 'password' | 'entraid'; expiresAt: Date; + + // Password auth + password?: string; + + // EntraID auth + userPrincipalName?: string; + userId?: string; + name?: string; + groups?: string[]; } export async function encrypt(payload: Session) { @@ -34,7 +43,35 @@ export async function decrypt(session: string | undefined = '') { export async function createSession(password: string) { const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); - const session = await encrypt({ password, expiresAt }); + const session = await encrypt({ + authType: 'password', + password, + expiresAt + }); + (await cookies()).set( + "session", + session, + { + httpOnly: true, + secure: true, + expires: expiresAt, + sameSite: "lax", + path: "/", + }); +} + +export async function createEntraIdSession(userInfo: { + userPrincipalName: string; + userId: string; + name?: string; + groups?: string[]; +}) { + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + const session = await encrypt({ + authType: 'entraid', + expiresAt, + ...userInfo + }); (await cookies()).set( "session", session, diff --git a/Website/middleware.ts b/Website/middleware.ts index 0bc6b8a..49f725c 100644 --- a/Website/middleware.ts +++ b/Website/middleware.ts @@ -1,43 +1,66 @@ -import { NextResponse, NextRequest } from 'next/server' -import { getSession } from './lib/session' +import { auth } from './auth'; +import { NextResponse } from 'next/server'; +import { isPasswordAuthDisabled } from './lib/auth/entraid'; -export async function middleware(request: NextRequest) { - const session = await getSession(); - const isAuthenticated = session !== null; +export default auth((req) => { + const { auth: session } = req; + const isLoggedIn = !!session?.user; + const { pathname } = req.nextUrl; - // If the user is authenticated, continue as normal - if (isAuthenticated) { - return NextResponse.next() - } + // Allow access to login page + if (pathname === '/login') { + return NextResponse.next(); + } + + // Allow access to auth API routes + if (pathname.startsWith('/api/auth')) { + return NextResponse.next(); + } - // Allow access to public API endpoints without authentication - const publicApiEndpoints = ['/api/auth/login', '/api/version']; - if (publicApiEndpoints.includes(request.nextUrl.pathname)) { - return NextResponse.next() + // Allow access to version API + if (pathname === '/api/version') { + return NextResponse.next(); + } + + // Check password auth session if EntraID is not used or if dual mode + if (!isLoggedIn && !isPasswordAuthDisabled()) { + // Check for password session + const passwordSession = req.cookies.get('session'); + if (passwordSession) { + // User has password session, allow access + return NextResponse.next(); } + } - // For API routes, return 401 Unauthorized - if (request.nextUrl.pathname.startsWith('/api')) { - return NextResponse.json( - { error: 'Unauthorized' }, - { status: 401 } - ) + // For API routes, return 401 + if (pathname.startsWith('/api')) { + if (!isLoggedIn) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); } + } + + // For page routes, redirect to login if not authenticated + if (!isLoggedIn) { + const loginUrl = new URL('/login', req.url); + return NextResponse.redirect(loginUrl); + } - // For page routes, redirect to login page - return NextResponse.redirect(new URL('/login', request.url)) -} + // User is authenticated, allow access + return NextResponse.next(); +}); export const config = { - matcher: [ - /* - * Match all request paths except for the ones starting with: - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - * - login (login page) - * - public assets (images, SVGs, etc.) - */ - '/((?!_next/static|_next/image|favicon.ico|login|.*\\.).*)', - ] -} \ No newline at end of file + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - public assets (images, SVGs, etc.) + */ + '/((?!_next/static|_next/image|favicon.ico|.*\\.).*)', + ], +}; diff --git a/Website/next.config.ts b/Website/next.config.ts index 12c8b4e..4cda934 100644 --- a/Website/next.config.ts +++ b/Website/next.config.ts @@ -7,6 +7,47 @@ const nextConfig = { turbopack: { root: path.join(__dirname, '..'), }, + async headers() { + return [ + { + source: '/:path*', + headers: [ + { + key: 'X-DNS-Prefetch-Control', + value: 'on' + }, + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload' + }, + { + key: 'X-Frame-Options', + value: 'SAMEORIGIN' + }, + { + key: 'X-Content-Type-Options', + value: 'nosniff' + }, + { + key: 'X-XSS-Protection', + value: '1; mode=block' + }, + { + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin' + }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()' + }, + { + key: 'Content-Security-Policy', + value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" + } + ], + }, + ] + }, } module.exports = nextConfig \ No newline at end of file diff --git a/Website/package-lock.json b/Website/package-lock.json index c4a68de..015eb7d 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -24,7 +24,8 @@ "html2canvas": "^1.4.1", "jose": "^5.9.6", "libavoid-js": "^0.4.5", - "next": "^15.5.3", + "next": "^15.5.9", + "next-auth": "^5.0.0-beta.30", "postcss": "^8.5.6", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -58,6 +59,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@auth/core": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", + "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/core/node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@azure/abort-controller": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", @@ -1495,9 +1534,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.3.tgz", - "integrity": "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1510,9 +1549,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.3.tgz", - "integrity": "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", "cpu": [ "arm64" ], @@ -1526,9 +1565,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.3.tgz", - "integrity": "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", "cpu": [ "x64" ], @@ -1542,9 +1581,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.3.tgz", - "integrity": "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", "cpu": [ "arm64" ], @@ -1558,9 +1597,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.3.tgz", - "integrity": "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", "cpu": [ "arm64" ], @@ -1574,9 +1613,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.3.tgz", - "integrity": "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", "cpu": [ "x64" ], @@ -1590,9 +1629,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.3.tgz", - "integrity": "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "cpu": [ "x64" ], @@ -1606,9 +1645,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.3.tgz", - "integrity": "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", "cpu": [ "arm64" ], @@ -1622,9 +1661,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.3.tgz", - "integrity": "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "cpu": [ "x64" ], @@ -1953,6 +1992,15 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -2680,10 +2728,11 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -3115,10 +3164,11 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5621,10 +5671,11 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -5732,12 +5783,12 @@ } }, "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -6220,9 +6271,9 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -6843,12 +6894,12 @@ "dev": true }, "node_modules/next": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.3.tgz", - "integrity": "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", "dependencies": { - "@next/env": "15.5.3", + "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -6861,14 +6912,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.3", - "@next/swc-darwin-x64": "15.5.3", - "@next/swc-linux-arm64-gnu": "15.5.3", - "@next/swc-linux-arm64-musl": "15.5.3", - "@next/swc-linux-x64-gnu": "15.5.3", - "@next/swc-linux-x64-musl": "15.5.3", - "@next/swc-win32-arm64-msvc": "15.5.3", - "@next/swc-win32-x64-msvc": "15.5.3", + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { @@ -6894,6 +6945,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.30", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz", + "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.0" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -6921,6 +6999,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/oauth4webapi": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz", + "integrity": "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7238,6 +7325,25 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/Website/package.json b/Website/package.json index c000f0a..e6ee248 100644 --- a/Website/package.json +++ b/Website/package.json @@ -18,8 +18,8 @@ "@mui/material-nextjs": "^7.3.2", "@nivo/bar": "^0.99.0", "@nivo/core": "^0.99.0", - "@nivo/pie": "^0.99.0", "@nivo/heatmap": "^0.99.0", + "@nivo/pie": "^0.99.0", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", "class-variance-authority": "^0.7.1", @@ -27,7 +27,8 @@ "html2canvas": "^1.4.1", "jose": "^5.9.6", "libavoid-js": "^0.4.5", - "next": "^15.5.3", + "next": "^15.5.9", + "next-auth": "^5.0.0-beta.30", "postcss": "^8.5.6", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/Website/public/MSAuthentication.jpg b/Website/public/MSAuthentication.jpg new file mode 100644 index 0000000..7a74501 Binary files /dev/null and b/Website/public/MSAuthentication.jpg differ diff --git a/Website/public/MS_LOGO.svg b/Website/public/MS_LOGO.svg new file mode 100644 index 0000000..5334aa7 --- /dev/null +++ b/Website/public/MS_LOGO.svg @@ -0,0 +1 @@ + \ No newline at end of file