diff --git a/.github/workflows/build-and-push-images.yml b/.github/workflows/build-and-push-images.yml index e6d5897..53bae26 100644 --- a/.github/workflows/build-and-push-images.yml +++ b/.github/workflows/build-and-push-images.yml @@ -1,22 +1,9 @@ name: Build and Push Docker Images on: - push: - branches: - - main - - master - tags: - - 'v*' - pull_request: - branches: - - main - - master + release: + types: [published] workflow_dispatch: - inputs: - version: - description: 'Version tag (e.g., 0.27.5)' - required: false - type: string env: REGISTRY: ghcr.io @@ -51,10 +38,10 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.ANALYTICS_SERVICE_IMAGE }} tags: | - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }} - type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} - type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} - type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=latest + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} - name: Extract metadata for analytics-ui id: meta-ui @@ -62,10 +49,10 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.ANALYTICS_UI_IMAGE }} tags: | - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }} - type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} - type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} - type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=latest + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} - name: Extract metadata for analytics-bootstrap id: meta-bootstrap @@ -73,17 +60,17 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.ANALYTICS_BOOTSTRAP_IMAGE }} tags: | - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }} - type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} - type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} - type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=latest + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} - name: Build and push analytics-service uses: docker/build-push-action@v5 with: context: ./analytics-ai-service file: ./analytics-ai-service/docker/Dockerfile - push: ${{ github.event_name != 'pull_request' }} + push: true tags: ${{ steps.meta-service.outputs.tags }} labels: ${{ steps.meta-service.outputs.labels }} cache-from: type=gha @@ -95,7 +82,7 @@ jobs: with: context: ./analytics-ui file: ./analytics-ui/Dockerfile - push: ${{ github.event_name != 'pull_request' }} + push: true tags: ${{ steps.meta-ui.outputs.tags }} labels: ${{ steps.meta-ui.outputs.labels }} cache-from: type=gha @@ -107,7 +94,7 @@ jobs: with: context: ./docker/bootstrap file: ./docker/bootstrap/Dockerfile - push: ${{ github.event_name != 'pull_request' }} + push: true tags: ${{ steps.meta-bootstrap.outputs.tags }} labels: ${{ steps.meta-bootstrap.outputs.labels }} cache-from: type=gha diff --git a/analytics-ui/migrations/20260327000000_cleanup_custom_auth_tables.js b/analytics-ui/migrations/20260327000000_cleanup_custom_auth_tables.js new file mode 100644 index 0000000..bcb1dbf --- /dev/null +++ b/analytics-ui/migrations/20260327000000_cleanup_custom_auth_tables.js @@ -0,0 +1,46 @@ +/** + * Migration: cleanup_custom_auth_tables + * + * Removes tables that were used by the old custom JWT auth system. + * These are no longer needed after migrating to NextAuth v4: + * - refresh_token: manual refresh token rotation (NextAuth handles session via JWT cookie) + * - oauth_state: custom OAuth PKCE/state storage (NextAuth handles this internally) + * - user_session: custom session tracking (replaced by NextAuth session-token cookie) + */ +exports.up = async (knex) => { + await knex.schema.dropTableIfExists('refresh_token'); + await knex.schema.dropTableIfExists('oauth_state'); + await knex.schema.dropTableIfExists('user_session'); +}; + +exports.down = async (knex) => { + // Recreate tables for rollback if needed + await knex.schema.createTableIfNotExists('refresh_token', (table) => { + table.increments('id').primary(); + table.integer('user_id').notNullable(); + table.string('token_hash', 64).notNullable().unique(); + table.string('family_id', 36).notNullable(); + table.boolean('is_revoked').defaultTo(false); + table.timestamp('expires_at').notNullable(); + table.string('ip_address', 45); + table.string('user_agent', 512); + table.timestamps(true, true); + }); + + await knex.schema.createTableIfNotExists('oauth_state', (table) => { + table.increments('id').primary(); + table.string('state', 64).notNullable().unique(); + table.string('provider', 64).notNullable(); + table.string('code_verifier', 128); + table.timestamp('expires_at').notNullable(); + table.timestamps(true, true); + }); + + await knex.schema.createTableIfNotExists('user_session', (table) => { + table.increments('id').primary(); + table.integer('user_id').notNullable(); + table.string('session_token', 64).notNullable().unique(); + table.timestamp('expires_at').notNullable(); + table.timestamps(true, true); + }); +}; diff --git a/analytics-ui/package.json b/analytics-ui/package.json index fb55ced..48650ba 100644 --- a/analytics-ui/package.json +++ b/analytics-ui/package.json @@ -31,6 +31,7 @@ "micro": "^9.4.1", "micro-cors": "^0.1.1", "next": "14.2.32", + "next-auth": "^4.24.13", "pg": "^8.8.0", "pg-cursor": "^2.7.4", "posthog-node": "^4.3.2", diff --git a/analytics-ui/src/apollo/client/index.ts b/analytics-ui/src/apollo/client/index.ts index aaff5b7..fde50bb 100644 --- a/analytics-ui/src/apollo/client/index.ts +++ b/analytics-ui/src/apollo/client/index.ts @@ -1,172 +1,31 @@ -import { ApolloClient, HttpLink, InMemoryCache, from, Observable } from '@apollo/client'; -import { setContext } from '@apollo/client/link/context'; +import { ApolloClient, HttpLink, InMemoryCache, from } from '@apollo/client'; import { onError } from '@apollo/client/link/error'; +import { signOut } from 'next-auth/react'; import errorHandler from '@/utils/errorHandler'; -const ACCESS_TOKEN_KEY = 'nqrust_access_token'; -const REFRESH_TOKEN_KEY = 'nqrust_refresh_token'; +const apolloErrorLink = onError(({ graphQLErrors, networkError, operation, forward }) => { + const hasAuthError = graphQLErrors?.some( + (err) => err.extensions?.code === 'UNAUTHENTICATED' + ); -// Track in-flight refresh to prevent concurrent refresh storms -let isRefreshing = false; - -// Queue holds both the retry callback and observer so we can error out on failure -interface PendingRequest { - resolve: () => void; - reject: (err: Error) => void; -} -let pendingRequests: PendingRequest[] = []; - -// Listeners notified when tokens are refreshed (so React state can sync) -type TokenRefreshListener = (accessToken: string) => void; -const tokenRefreshListeners = new Set(); -export const onTokenRefresh = (listener: TokenRefreshListener) => { - tokenRefreshListeners.add(listener); - return () => tokenRefreshListeners.delete(listener); -}; - -const resolvePendingRequests = () => { - pendingRequests.forEach((req) => req.resolve()); - pendingRequests = []; -}; - -const rejectPendingRequests = (err: Error) => { - pendingRequests.forEach((req) => req.reject(err)); - pendingRequests = []; -}; - -const forceLogout = () => { - localStorage.removeItem(ACCESS_TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); - window.location.href = '/login'; -}; - -const apolloErrorLink = onError( - ({ graphQLErrors, networkError, operation, forward }) => { - const hasAuthError = graphQLErrors?.some( - (err) => err.extensions?.code === 'UNAUTHENTICATED' - ); - - if (hasAuthError) { - // Don't intercept auth mutations themselves - const opName = operation.operationName; - if ( - opName === 'Login' || - opName === 'RefreshToken' || - opName === 'Register' - ) { - return; - } - - if (!isRefreshing) { - isRefreshing = true; - const refreshToken = - typeof window !== 'undefined' - ? localStorage.getItem(REFRESH_TOKEN_KEY) - : null; - - if (!refreshToken) { - forceLogout(); - return; - } - - // Use raw fetch to avoid Apollo error loop - fetch('/api/graphql', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: `mutation RefreshToken($refreshToken: String!) { - refreshToken(refreshToken: $refreshToken) { - accessToken - refreshToken - } - }`, - variables: { refreshToken }, - }), - }) - .then((res) => res.json()) - .then((result) => { - if (result.data?.refreshToken) { - const newAccessToken = result.data.refreshToken.accessToken; - localStorage.setItem(ACCESS_TOKEN_KEY, newAccessToken); - localStorage.setItem( - REFRESH_TOKEN_KEY, - result.data.refreshToken.refreshToken - ); - // Notify React state (useAuth) so it stays in sync - tokenRefreshListeners.forEach((fn) => fn(newAccessToken)); - resolvePendingRequests(); - } else { - rejectPendingRequests(new Error('Token refresh failed')); - forceLogout(); - } - }) - .catch((err) => { - // Only force logout for non-connectivity errors (e.g. invalid refresh token) - // For genuine network failures, keep the user logged in — they can retry - const isNetworkFailure = - err instanceof TypeError || - (err?.message?.toLowerCase()?.includes('failed to fetch')); - if (isNetworkFailure) { - // Network is down; error out pending operations so spinners stop - rejectPendingRequests( - new Error('Network unavailable. Please try again.'), - ); - } else { - rejectPendingRequests(err); - forceLogout(); - } - }) - .finally(() => { - isRefreshing = false; - }); - } - - // Queue the failed operation to retry after refresh completes - return new Observable((observer) => { - pendingRequests.push({ - resolve: () => { - const newToken = localStorage.getItem(ACCESS_TOKEN_KEY); - operation.setContext(({ headers = {} }: { headers: Record }) => ({ - headers: { - ...headers, - authorization: newToken ? `Bearer ${newToken}` : '', - }, - })); - forward(operation).subscribe(observer); - }, - reject: (err: Error) => { - observer.error(err); - }, - }); - }); - } - - // Non-auth errors: delegate to existing handler - errorHandler({ graphQLErrors, networkError, operation, forward }); + if (hasAuthError) { + // Sign out via NextAuth to properly clear the session cookie before redirecting. + // Using window.location.href would skip cookie cleanup and risk a redirect loop. + signOut({ callbackUrl: '/login' }); + return; } -); -const httpLink = new HttpLink({ - uri: '/api/graphql', + // Non-auth errors: delegate to existing handler + errorHandler({ graphQLErrors, networkError, operation, forward }); }); -// Auth link to add token to requests -const authLink = setContext((_, { headers }) => { - let token: string | null = null; - if (typeof window !== 'undefined') { - token = localStorage.getItem(ACCESS_TOKEN_KEY); - } - - return { - headers: { - ...headers, - authorization: token ? `Bearer ${token}` : '', - }, - }; +const httpLink = new HttpLink({ + uri: '/api/graphql', + credentials: 'include', // Send NextAuth session cookie with every GraphQL request }); const client = new ApolloClient({ - link: from([apolloErrorLink, authLink, httpLink]), + link: from([apolloErrorLink, httpLink]), cache: new InMemoryCache(), }); diff --git a/analytics-ui/src/apollo/server/config.ts b/analytics-ui/src/apollo/server/config.ts index bc6793c..96ab856 100644 --- a/analytics-ui/src/apollo/server/config.ts +++ b/analytics-ui/src/apollo/server/config.ts @@ -183,6 +183,17 @@ const config = { licensePublicKey: process.env.LICENSE_PUBLIC_KEY?.replace(/\\n/g, '\n'), }; +let _configWarned = false; export function getConfig(): IConfig { - return { ...defaultConfig, ...pickBy(config) }; + const result = { ...defaultConfig, ...pickBy(config) } as IConfig; + if (!_configWarned) { + _configWarned = true; + if (result.encryptionPassword === defaultConfig.encryptionPassword) { + console.warn('[security] ENCRYPTION_PASSWORD is using the default insecure value. Set the ENCRYPTION_PASSWORD environment variable before going to production.'); + } + if (result.encryptionSalt === defaultConfig.encryptionSalt) { + console.warn('[security] ENCRYPTION_SALT is using the default insecure value. Set the ENCRYPTION_SALT environment variable before going to production.'); + } + } + return result; } diff --git a/analytics-ui/src/apollo/server/middleware/authMiddleware.ts b/analytics-ui/src/apollo/server/middleware/authMiddleware.ts deleted file mode 100644 index 0261db9..0000000 --- a/analytics-ui/src/apollo/server/middleware/authMiddleware.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { NextApiRequest } from 'next'; -import { UserRepository, UserWithRoles } from '../repositories/userRepository'; -import { RoleRepository } from '../repositories/roleRepository'; -import { AuditLogRepository } from '../repositories/auditLogRepository'; -import { AuthService } from '../services/authService'; -import { Knex } from 'knex'; - -export interface AuthenticatedUser { - user: UserWithRoles | null; - ipAddress: string | undefined; -} - -/** - * Authentication middleware for GraphQL context - * Extracts JWT from Authorization header or cookie and validates it - */ -export class AuthMiddleware { - private authService: AuthService; - - constructor(knex: Knex) { - const userRepository = new UserRepository(knex); - const roleRepository = new RoleRepository(knex); - const auditLogRepository = new AuditLogRepository(knex); - this.authService = new AuthService(userRepository, roleRepository, auditLogRepository); - } - - /** - * Extract and verify authentication from request - * Returns user if authenticated, null otherwise - */ - async authenticate(req: NextApiRequest): Promise { - const token = this.extractToken(req); - const ipAddress = this.extractIpAddress(req); - - if (!token) { - return { user: null, ipAddress }; - } - - try { - const user = await this.authService.verifyToken(token); - return { user, ipAddress }; - } catch (_error) { - // Token verification failed - return null user - return { user: null, ipAddress }; - } - } - - /** - * Extract JWT token from request - * Tries Authorization header first, then falls back to cookie - */ - private extractToken(req: NextApiRequest): string | null { - // Try Authorization header (Bearer token) - const authHeader = req.headers.authorization; - if (authHeader && authHeader.startsWith('Bearer ')) { - return authHeader.slice(7); - } - - // Try cookie - const cookies = req.cookies; - if (cookies && cookies.auth_token) { - return cookies.auth_token; - } - - return null; - } - - /** - * Extract client IP address from request - */ - private extractIpAddress(req: NextApiRequest): string | undefined { - // Check X-Forwarded-For header (for proxied requests) - const forwarded = req.headers['x-forwarded-for']; - if (forwarded) { - const ips = typeof forwarded === 'string' ? forwarded : forwarded[0]; - return ips.split(',')[0].trim(); - } - - // Check X-Real-IP header - const realIp = req.headers['x-real-ip']; - if (realIp) { - return typeof realIp === 'string' ? realIp : realIp[0]; - } - - // Fallback to socket remote address - return req.socket?.remoteAddress; - } -} - -/** - * Create authentication context for GraphQL resolver - * This function should be called in the GraphQL server context builder - */ -export async function createAuthContext( - req: NextApiRequest, - knex: Knex -): Promise { - const middleware = new AuthMiddleware(knex); - return middleware.authenticate(req); -} diff --git a/analytics-ui/src/apollo/server/repositories/auditLogRepository.ts b/analytics-ui/src/apollo/server/repositories/auditLogRepository.ts index 142ec48..64e5624 100644 --- a/analytics-ui/src/apollo/server/repositories/auditLogRepository.ts +++ b/analytics-ui/src/apollo/server/repositories/auditLogRepository.ts @@ -157,4 +157,6 @@ export const AuditActions = { // Deploy actions DEPLOY: 'deploy.execute', + + } as const; diff --git a/analytics-ui/src/apollo/server/repositories/oauthAccountRepository.ts b/analytics-ui/src/apollo/server/repositories/oauthAccountRepository.ts index ad9e55b..8ad9d19 100644 --- a/analytics-ui/src/apollo/server/repositories/oauthAccountRepository.ts +++ b/analytics-ui/src/apollo/server/repositories/oauthAccountRepository.ts @@ -16,7 +16,7 @@ export interface OAuthAccount { updatedAt: Date; } -export type OAuthProvider = 'google' | 'github'; +export type OAuthProvider = 'google' | 'github' | (string & {}); export interface IOAuthAccountRepository extends IBasicRepository { findByProviderAndUserId(provider: string, providerUserId: string): Promise; diff --git a/analytics-ui/src/apollo/server/repositories/refreshTokenRepository.ts b/analytics-ui/src/apollo/server/repositories/refreshTokenRepository.ts deleted file mode 100644 index 15503b4..0000000 --- a/analytics-ui/src/apollo/server/repositories/refreshTokenRepository.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Knex } from 'knex'; -import { BaseRepository, IBasicRepository } from './baseRepository'; -import crypto from 'crypto'; - -export interface RefreshToken { - id: number; - userId: number; - tokenHash: string; - familyId: string; - expiresAt: Date; - revokedAt?: Date; - ipAddress?: string; - userAgent?: string; - createdAt: Date; - updatedAt: Date; -} - -export interface IRefreshTokenRepository extends IBasicRepository { - findByTokenHash(tokenHash: string): Promise; - findValidByTokenHash(tokenHash: string): Promise; - revokeToken(id: number): Promise; - revokeAllUserTokens(userId: number): Promise; - revokeTokenFamily(familyId: string): Promise; - cleanupExpiredTokens(): Promise; -} - -export class RefreshTokenRepository - extends BaseRepository - implements IRefreshTokenRepository { - constructor(knexPg: Knex) { - super({ knexPg, tableName: 'refresh_token' }); - } - - /** - * Hash a token for secure storage - */ - public static hashToken(token: string): string { - return crypto.createHash('sha256').update(token).digest('hex'); - } - - /** - * Generate a new refresh token string - */ - public static generateToken(): string { - return crypto.randomBytes(32).toString('hex'); - } - - /** - * Generate a new token family ID - */ - public static generateFamilyId(): string { - return crypto.randomBytes(16).toString('hex'); - } - - public async findByTokenHash(tokenHash: string): Promise { - const result = await this.knex(this.tableName) - .where({ token_hash: tokenHash }) - .first(); - return result ? this.transformFromDBData(result) : null; - } - - public async findValidByTokenHash(tokenHash: string): Promise { - const result = await this.knex(this.tableName) - .where({ token_hash: tokenHash }) - .whereNull('revoked_at') - .where('expires_at', '>', this.knex.fn.now()) - .first(); - return result ? this.transformFromDBData(result) : null; - } - - /** - * Atomically find a valid token and revoke it in a single transaction. - * Uses SELECT FOR UPDATE to prevent concurrent refresh race conditions. - * Returns the token if found and successfully revoked, null otherwise. - */ - public async findAndRevokeValidToken(tokenHash: string): Promise { - return this.knex.transaction(async (trx) => { - const result = await trx(this.tableName) - .where({ token_hash: tokenHash }) - .whereNull('revoked_at') - .where('expires_at', '>', trx.fn.now()) - .forUpdate() - .first(); - - if (!result) return null; - - await trx(this.tableName) - .where({ id: result.id }) - .update({ revoked_at: trx.fn.now() }); - - return this.transformFromDBData(result); - }); - } - - public async revokeToken(id: number): Promise { - await this.knex(this.tableName) - .where({ id }) - .update({ revoked_at: this.knex.fn.now() }); - } - - public async revokeAllUserTokens(userId: number): Promise { - await this.knex(this.tableName) - .where({ user_id: userId }) - .whereNull('revoked_at') - .update({ revoked_at: this.knex.fn.now() }); - } - - public async revokeTokenFamily(familyId: string): Promise { - await this.knex(this.tableName) - .where({ family_id: familyId }) - .whereNull('revoked_at') - .update({ revoked_at: this.knex.fn.now() }); - } - - public async cleanupExpiredTokens(): Promise { - // Delete tokens that expired more than 7 days ago - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - 7); - - return this.knex(this.tableName) - .where('expires_at', '<', cutoffDate) - .delete(); - } - - public async createRefreshToken( - userId: number, - familyId: string, - expiresInDays: number = 30, - ipAddress?: string, - userAgent?: string - ): Promise<{ token: string; refreshToken: RefreshToken }> { - const token = RefreshTokenRepository.generateToken(); - const tokenHash = RefreshTokenRepository.hashToken(token); - - const expiresAt = new Date(); - expiresAt.setDate(expiresAt.getDate() + expiresInDays); - - const refreshToken = await this.createOne({ - userId, - tokenHash, - familyId, - expiresAt, - ipAddress, - userAgent, - }); - - return { token, refreshToken }; - } -} diff --git a/analytics-ui/src/apollo/server/resolvers.ts b/analytics-ui/src/apollo/server/resolvers.ts index f1e378d..9e0c269 100644 --- a/analytics-ui/src/apollo/server/resolvers.ts +++ b/analytics-ui/src/apollo/server/resolvers.ts @@ -213,12 +213,8 @@ const resolvers = { // Auth register: authResolver.register, - login: authResolver.login, - logout: authResolver.logout, changePassword: authResolver.changePassword, requestPasswordReset: authResolver.requestPasswordReset, - refreshToken: authResolver.refreshToken, - revokeAllSessions: authResolver.revokeAllSessions, // User Management createUser: authResolver.createUser, diff --git a/analytics-ui/src/apollo/server/resolvers/authResolver.ts b/analytics-ui/src/apollo/server/resolvers/authResolver.ts index 80a5d30..642bdc4 100644 --- a/analytics-ui/src/apollo/server/resolvers/authResolver.ts +++ b/analytics-ui/src/apollo/server/resolvers/authResolver.ts @@ -3,56 +3,31 @@ import { UserRepository, UserWithRoles, User, Role } from '../repositories/userR import { RoleRepository, RoleWithPermissions } from '../repositories/roleRepository'; import { ProjectMemberRepository, ProjectMemberWithUser, ProjectMemberRole } from '../repositories/projectMemberRepository'; import { AuditLogRepository, AuditActions } from '../repositories/auditLogRepository'; -import { RefreshTokenRepository } from '../repositories/refreshTokenRepository'; -import { AuthService, AuthPayload, AuthServiceError } from '../services/authService'; -import { RateLimitService } from '../services/rateLimitService'; +import { AuthUtils, AuthServiceError } from '../utils/authUtils'; import { GraphQLError } from 'graphql'; import { Knex } from 'knex'; -// Extended context interface for auth - user will be added by auth middleware +// Extended context interface for auth - user added by getToken() in graphql.ts export interface AuthContext extends Omit { user: UserWithRoles | null; ipAddress?: string; - knex: Knex; // Add knex connection from the context } export class AuthResolver { - // Create repositories from knex connection in context private createRepositories(knex: Knex) { const userRepository = new UserRepository(knex); const roleRepository = new RoleRepository(knex); const projectMemberRepository = new ProjectMemberRepository(knex); const auditLogRepository = new AuditLogRepository(knex); - const refreshTokenRepository = new RefreshTokenRepository(knex); - const rateLimitService = new RateLimitService(knex); - const authService = new AuthService( - userRepository, - roleRepository, - auditLogRepository, - refreshTokenRepository - ); return { userRepository, roleRepository, projectMemberRepository, auditLogRepository, - refreshTokenRepository, - rateLimitService, - authService, }; } - private getKnex(ctx: IContext): Knex { - // Access knex through the project repository (it extends BaseRepository which has knex) - // This is a workaround since knex isn't directly on IContext - const projectRepo = ctx.projectRepository as any; - if (projectRepo?.knex) { - return projectRepo.knex; - } - throw new Error('Database connection not available'); - } - private requireAuth(ctx: AuthContext): UserWithRoles { if (!ctx.user) { throw new GraphQLError('Authentication required', { @@ -62,9 +37,9 @@ export class AuthResolver { return ctx.user; } - private async requireAdmin(ctx: AuthContext, authService: AuthService): Promise { + private requireAdmin(ctx: AuthContext): UserWithRoles { const user = this.requireAuth(ctx); - if (!(await authService.isAdmin(user.id))) { + if (!user.roles.some(r => r.name === 'admin')) { throw new GraphQLError('Admin access required', { extensions: { code: 'FORBIDDEN' }, }); @@ -81,17 +56,14 @@ export class AuthResolver { me = async (_root: unknown, _args: unknown, ctx: IContext) => { const authCtx = ctx as AuthContext; - if (!authCtx.user) return null; - const knex = this.getKnex(ctx); - const { userRepository } = this.createRepositories(knex); - return userRepository.findByIdWithRoles(authCtx.user.id); + return authCtx.user ?? null; }; users = async (_root: unknown, _args: unknown, ctx: IContext) => { const authCtx = ctx as AuthContext; - const knex = this.getKnex(ctx); + const knex = ctx.knex; const repos = this.createRepositories(knex); - await this.requireAdmin(authCtx, repos.authService); + this.requireAdmin(authCtx); const users = await repos.userRepository.findAll(); return Promise.all( @@ -105,16 +77,16 @@ export class AuthResolver { ctx: IContext ) => { const authCtx = ctx as AuthContext; - const knex = this.getKnex(ctx); + const knex = ctx.knex; const repos = this.createRepositories(knex); - await this.requireAdmin(authCtx, repos.authService); + this.requireAdmin(authCtx); return repos.userRepository.findByIdWithRoles(args.where.id); }; roles = async (_root: unknown, _args: unknown, ctx: IContext) => { const authCtx = ctx as AuthContext; this.requireAuth(authCtx); - const knex = this.getKnex(ctx); + const knex = ctx.knex; const { roleRepository } = this.createRepositories(knex); const roles = await roleRepository.findAll(); @@ -130,7 +102,7 @@ export class AuthResolver { ) => { const authCtx = ctx as AuthContext; this.requireAuth(authCtx); - const knex = this.getKnex(ctx); + const knex = ctx.knex; const { roleRepository } = this.createRepositories(knex); return roleRepository.findByIdWithPermissions(args.where.id); }; @@ -138,7 +110,7 @@ export class AuthResolver { permissions = async (_root: unknown, _args: unknown, ctx: IContext) => { const authCtx = ctx as AuthContext; this.requireAuth(authCtx); - const knex = this.getKnex(ctx); + const knex = ctx.knex; const { roleRepository } = this.createRepositories(knex); return roleRepository.getAllPermissions(); }; @@ -146,7 +118,7 @@ export class AuthResolver { projectMembers = async (_root: unknown, _args: unknown, ctx: IContext) => { const authCtx = ctx as AuthContext; this.requireAuth(authCtx); - const knex = this.getKnex(ctx); + const knex = ctx.knex; const { projectMemberRepository } = this.createRepositories(knex); const projectId = await this.getCurrentProjectId(ctx); @@ -159,77 +131,19 @@ export class AuthResolver { _root: unknown, args: { data: { email: string; password: string; displayName: string } }, ctx: IContext - ): Promise => { - const authCtx = ctx as AuthContext; - const knex = this.getKnex(ctx); - const { authService } = this.createRepositories(knex); - - try { - return await authService.register(args.data, authCtx.ipAddress); - } catch (error) { - if (error instanceof AuthServiceError) { - throw new Error(error.message); - } - throw error; - } - }; - - login = async ( - _root: unknown, - args: { data: { email: string; password: string } }, - ctx: IContext - ): Promise => { + ) => { const authCtx = ctx as AuthContext; - const knex = this.getKnex(ctx); - const { authService, rateLimitService } = this.createRepositories(knex); - const ipAddress = authCtx.ipAddress || 'unknown'; - const email = args.data.email; - - // Check rate limit before processing login - const rateLimitResult = await rateLimitService.checkLoginLimit(ipAddress, email); - if (!rateLimitResult.allowed) { - const retryAfterSec = Math.ceil((rateLimitResult.retryAfterMs || 0) / 1000); - const message = rateLimitResult.reason === 'ACCOUNT_LOCKED' - ? `Account temporarily locked. Try again in ${retryAfterSec} seconds.` - : `Too many login attempts. Try again in ${retryAfterSec} seconds.`; - - // Record the rate-limited attempt - await rateLimitService.recordLoginAttempt( - email, - ipAddress, - false, - undefined, - undefined, - rateLimitResult.reason - ); - - throw new Error(message); - } + const knex = ctx.knex; + const { userRepository, roleRepository, auditLogRepository } = this.createRepositories(knex); try { - const result = await authService.login(args.data, authCtx.ipAddress); - - // Record successful login - await rateLimitService.recordLoginAttempt( - email, - ipAddress, - true, - result.user.id + const result = await AuthUtils.register( + { userRepository, roleRepository, auditLogRepository }, + args.data, + authCtx.ipAddress ); - - return result; + return { user: result.user }; } catch (error) { - // Record failed login attempt - const failureReason = error instanceof AuthServiceError ? error.code : 'UNKNOWN'; - await rateLimitService.recordLoginAttempt( - email, - ipAddress, - false, - undefined, - undefined, - failureReason - ); - if (error instanceof AuthServiceError) { throw new Error(error.message); } @@ -237,16 +151,6 @@ export class AuthResolver { } }; - logout = async (_root: unknown, _args: unknown, ctx: IContext) => { - const authCtx = ctx as AuthContext; - if (authCtx.user) { - const knex = this.getKnex(ctx); - const { authService } = this.createRepositories(knex); - await authService.logout(authCtx.user.id, authCtx.ipAddress); - } - return true; - }; - changePassword = async ( _root: unknown, args: { data: { oldPassword: string; newPassword: string } }, @@ -254,11 +158,12 @@ export class AuthResolver { ) => { const authCtx = ctx as AuthContext; const user = this.requireAuth(authCtx); - const knex = this.getKnex(ctx); - const { authService } = this.createRepositories(knex); + const knex = ctx.knex; + const { userRepository, auditLogRepository } = this.createRepositories(knex); try { - await authService.changePassword( + await AuthUtils.changePassword( + { userRepository, auditLogRepository }, user.id, args.data.oldPassword, args.data.newPassword, @@ -279,41 +184,13 @@ export class AuthResolver { ctx: IContext ) => { const authCtx = ctx as AuthContext; - const knex = this.getKnex(ctx); - const { authService } = this.createRepositories(knex); - await authService.requestPasswordReset(args.email, authCtx.ipAddress); - return true; - }; - - refreshToken = async ( - _root: unknown, - args: { refreshToken: string }, - ctx: IContext - ): Promise => { - const authCtx = ctx as AuthContext; - const knex = this.getKnex(ctx); - const { authService } = this.createRepositories(knex); - - try { - return await authService.refreshAccessToken( - args.refreshToken, - authCtx.ipAddress - ); - } catch (error) { - if (error instanceof AuthServiceError) { - throw new Error(error.message); - } - throw error; - } - }; - - revokeAllSessions = async (_root: unknown, _args: unknown, ctx: IContext) => { - const authCtx = ctx as AuthContext; - const user = this.requireAuth(authCtx); - const knex = this.getKnex(ctx); - const { authService } = this.createRepositories(knex); - - await authService.revokeAllUserTokens(user.id); + const knex = ctx.knex; + const { userRepository, auditLogRepository } = this.createRepositories(knex); + await AuthUtils.requestPasswordReset( + { userRepository, auditLogRepository }, + args.email, + authCtx.ipAddress + ); return true; }; @@ -332,11 +209,16 @@ export class AuthResolver { ctx: IContext ) => { const authCtx = ctx as AuthContext; - const knex = this.getKnex(ctx); + const knex = ctx.knex; const repos = this.createRepositories(knex); - const currentUser = await this.requireAdmin(authCtx, repos.authService); + const currentUser = this.requireAdmin(authCtx); - const result = await repos.authService.register( + const result = await AuthUtils.register( + { + userRepository: repos.userRepository, + roleRepository: repos.roleRepository, + auditLogRepository: repos.auditLogRepository, + }, { email: args.data.email, password: args.data.password, @@ -371,9 +253,9 @@ export class AuthResolver { ctx: IContext ) => { const authCtx = ctx as AuthContext; - const knex = this.getKnex(ctx); + const knex = ctx.knex; const repos = this.createRepositories(knex); - const currentUser = await this.requireAdmin(authCtx, repos.authService); + const currentUser = this.requireAdmin(authCtx); const updateData: Partial = {}; if (args.data.displayName !== undefined) { @@ -423,9 +305,9 @@ export class AuthResolver { ctx: IContext ) => { const authCtx = ctx as AuthContext; - const knex = this.getKnex(ctx); + const knex = ctx.knex; const repos = this.createRepositories(knex); - const currentUser = await this.requireAdmin(authCtx, repos.authService); + const currentUser = this.requireAdmin(authCtx); if (args.where.id === currentUser.id) { throw new Error('Cannot delete your own account'); @@ -454,9 +336,9 @@ export class AuthResolver { ctx: IContext ) => { const authCtx = ctx as AuthContext; - const knex = this.getKnex(ctx); + const knex = ctx.knex; const repos = this.createRepositories(knex); - const currentUser = await this.requireAdmin(authCtx, repos.authService); + const currentUser = this.requireAdmin(authCtx); const role = await repos.roleRepository.createOne({ name: args.data.name.toLowerCase(), @@ -486,9 +368,9 @@ export class AuthResolver { ctx: IContext ) => { const authCtx = ctx as AuthContext; - const knex = this.getKnex(ctx); + const knex = ctx.knex; const repos = this.createRepositories(knex); - const currentUser = await this.requireAdmin(authCtx, repos.authService); + const currentUser = this.requireAdmin(authCtx); const role = await repos.roleRepository.findOneBy({ id: args.where.id } as Partial); if (!role) { @@ -533,9 +415,9 @@ export class AuthResolver { ctx: IContext ) => { const authCtx = ctx as AuthContext; - const knex = this.getKnex(ctx); + const knex = ctx.knex; const repos = this.createRepositories(knex); - const currentUser = await this.requireAdmin(authCtx, repos.authService); + const currentUser = this.requireAdmin(authCtx); const role = await repos.roleRepository.findOneBy({ id: args.where.id } as Partial); if (!role) { @@ -568,7 +450,7 @@ export class AuthResolver { ) => { const authCtx = ctx as AuthContext; const currentUser = this.requireAuth(authCtx); - const knex = this.getKnex(ctx); + const knex = ctx.knex; const repos = this.createRepositories(knex); const projectId = await this.getCurrentProjectId(ctx); @@ -621,7 +503,7 @@ export class AuthResolver { ) => { const authCtx = ctx as AuthContext; const currentUser = this.requireAuth(authCtx); - const knex = this.getKnex(ctx); + const knex = ctx.knex; const repos = this.createRepositories(knex); const projectId = await this.getCurrentProjectId(ctx); @@ -656,7 +538,7 @@ export class AuthResolver { ) => { const authCtx = ctx as AuthContext; const currentUser = this.requireAuth(authCtx); - const knex = this.getKnex(ctx); + const knex = ctx.knex; const repos = this.createRepositories(knex); const projectId = await this.getCurrentProjectId(ctx); @@ -685,7 +567,7 @@ export class AuthResolver { return { roles: async (user: UserWithRoles, _args: unknown, ctx: IContext) => { if (user.roles) return user.roles; - const knex = this.getKnex(ctx); + const knex = ctx.knex; const { userRepository } = this.createRepositories(knex); return userRepository.getUserRoles(user.id); }, @@ -696,7 +578,7 @@ export class AuthResolver { return { permissions: async (role: RoleWithPermissions, _args: unknown, ctx: IContext) => { if (role.permissions) return role.permissions; - const knex = this.getKnex(ctx); + const knex = ctx.knex; const { roleRepository } = this.createRepositories(knex); return roleRepository.getRolePermissions(role.id); }, @@ -706,13 +588,13 @@ export class AuthResolver { getProjectMemberNestedResolver() { return { user: async (member: ProjectMemberWithUser, _args: unknown, ctx: IContext) => { - const knex = this.getKnex(ctx); + const knex = ctx.knex; const { userRepository } = this.createRepositories(knex); return userRepository.findByIdWithRoles(member.userId); }, invitedBy: async (member: ProjectMemberWithUser, _args: unknown, ctx: IContext) => { if (!member.invitedBy) return null; - const knex = this.getKnex(ctx); + const knex = ctx.knex; const { userRepository } = this.createRepositories(knex); return userRepository.findByIdWithRoles(member.invitedBy); }, diff --git a/analytics-ui/src/apollo/server/schema.ts b/analytics-ui/src/apollo/server/schema.ts index 9f8cc79..9752b1f 100644 --- a/analytics-ui/src/apollo/server/schema.ts +++ b/analytics-ui/src/apollo/server/schema.ts @@ -54,8 +54,6 @@ export const typeDefs = gql` type AuthPayload { user: User! - accessToken: String! - refreshToken: String! } # Auth Inputs @@ -65,11 +63,6 @@ export const typeDefs = gql` displayName: String! } - input LoginInput { - email: String! - password: String! - } - input ChangePasswordInput { oldPassword: String! newPassword: String! @@ -1560,27 +1553,15 @@ export const typeDefs = gql` deleteInstruction(where: InstructionWhereInput!): Boolean! # ===== Authentication Mutations ===== - + # Register a new user register(data: RegisterInput!): AuthPayload! - - # Login with email and password - login(data: LoginInput!): AuthPayload! - - # Logout current user - logout: Boolean! - + # Change password for current user changePassword(data: ChangePasswordInput!): Boolean! - + # Request password reset (sends email) requestPasswordReset(email: String!): Boolean! - - # Refresh access token using refresh token - refreshToken(refreshToken: String!): AuthPayload! - - # Revoke all sessions (force logout everywhere) - revokeAllSessions: Boolean! # ===== User Management Mutations (Admin only) ===== diff --git a/analytics-ui/src/apollo/server/services/authService.ts b/analytics-ui/src/apollo/server/services/authService.ts deleted file mode 100644 index 71cada9..0000000 --- a/analytics-ui/src/apollo/server/services/authService.ts +++ /dev/null @@ -1,521 +0,0 @@ -import bcrypt from 'bcryptjs'; -import crypto from 'crypto'; -import { UserRepository, User, UserWithRoles } from '../repositories/userRepository'; -import { RoleRepository } from '../repositories/roleRepository'; -import { AuditLogRepository, AuditActions } from '../repositories/auditLogRepository'; -import { RefreshTokenRepository } from '../repositories/refreshTokenRepository'; - -// JWT configuration -const JWT_SECRET = process.env.JWT_SECRET || 'development-secret-change-in-production'; -const JWT_ACCESS_EXPIRES_IN = process.env.JWT_ACCESS_EXPIRES_IN || '7d'; -const JWT_REFRESH_EXPIRES_IN_DAYS = parseInt(process.env.JWT_REFRESH_EXPIRES_IN_DAYS || '30', 10); -const SALT_ROUNDS = 12; - -export interface AuthPayload { - user: UserWithRoles; - accessToken: string; - refreshToken: string; -} - -export interface TokenPayload { - userId: number; - email: string; - type: 'access' | 'refresh'; - iat: number; - exp: number; -} - -export interface RegisterInput { - email: string; - password: string; - displayName: string; -} - -export interface LoginInput { - email: string; - password: string; -} - -export class AuthServiceError extends Error { - constructor( - message: string, - public code: string, - public statusCode: number = 400 - ) { - super(message); - this.name = 'AuthServiceError'; - } -} - -export class AuthService { - constructor( - private userRepository: UserRepository, - private roleRepository: RoleRepository, - private auditLogRepository: AuditLogRepository, - private refreshTokenRepository?: RefreshTokenRepository - ) { } - - /** - * Register a new user - */ - public async register( - input: RegisterInput, - ipAddress?: string, - userAgent?: string - ): Promise { - const { email, password, displayName } = input; - - // Validate email format - if (!this.isValidEmail(email)) { - throw new AuthServiceError('Invalid email format', 'INVALID_EMAIL'); - } - - // Validate password strength - const passwordValidation = this.validatePassword(password); - if (!passwordValidation.valid) { - throw new AuthServiceError( - passwordValidation.message!, - 'WEAK_PASSWORD' - ); - } - - // Check if user already exists - const existingUser = await this.userRepository.findByEmail(email); - if (existingUser) { - throw new AuthServiceError( - 'A user with this email already exists', - 'EMAIL_EXISTS', - 409 - ); - } - - // Hash password - const passwordHash = await bcrypt.hash(password, SALT_ROUNDS); - - // Create user - const user = await this.userRepository.createOne({ - email: email.toLowerCase(), - passwordHash, - displayName, - isActive: true, - isVerified: false, - }); - - // Assign default viewer role - const viewerRole = await this.roleRepository.findByName('viewer'); - if (viewerRole) { - await this.userRepository.assignRole(user.id, viewerRole.id); - } - - // Get user with roles - const userWithRoles = await this.userRepository.findByIdWithRoles(user.id); - - // Generate tokens - const accessToken = this.generateAccessToken(user); - const refreshToken = await this.createRefreshToken(user.id, ipAddress, userAgent); - - // Log the registration - await this.auditLogRepository.log({ - userId: user.id, - action: AuditActions.REGISTER, - resourceType: 'user', - resourceId: user.id.toString(), - ipAddress, - }); - - return { user: userWithRoles!, accessToken, refreshToken }; - } - - /** - * Login with email and password - */ - public async login( - input: LoginInput, - ipAddress?: string, - userAgent?: string - ): Promise { - const { email, password } = input; - - // Find user by email - const user = await this.userRepository.findByEmail(email.toLowerCase()); - if (!user) { - throw new AuthServiceError( - 'Invalid email or password', - 'INVALID_CREDENTIALS', - 401 - ); - } - - // Check if user is active - if (!user.isActive) { - throw new AuthServiceError( - 'Your account has been deactivated', - 'ACCOUNT_DEACTIVATED', - 403 - ); - } - - // Verify password - const isValid = await bcrypt.compare(password, user.passwordHash); - if (!isValid) { - throw new AuthServiceError( - 'Invalid email or password', - 'INVALID_CREDENTIALS', - 401 - ); - } - - // Update last login - await this.userRepository.updateLastLogin(user.id); - - // Get user with roles - const userWithRoles = await this.userRepository.findByIdWithRoles(user.id); - - // Generate tokens - const accessToken = this.generateAccessToken(user); - const refreshToken = await this.createRefreshToken(user.id, ipAddress, userAgent); - - // Log the login - await this.auditLogRepository.log({ - userId: user.id, - action: AuditActions.LOGIN, - resourceType: 'user', - resourceId: user.id.toString(), - ipAddress, - }); - - return { user: userWithRoles!, accessToken, refreshToken }; - } - - /** - * Refresh access token using a valid refresh token - * Implements token rotation for security - */ - public async refreshAccessToken( - refreshToken: string, - ipAddress?: string, - userAgent?: string - ): Promise { - if (!this.refreshTokenRepository) { - throw new AuthServiceError( - 'Refresh token functionality not available', - 'REFRESH_NOT_AVAILABLE', - 501 - ); - } - - // Hash the incoming token to look it up - const tokenHash = RefreshTokenRepository.hashToken(refreshToken); - - // Atomically find and revoke the token to prevent concurrent refresh race conditions. - // SELECT FOR UPDATE ensures only one concurrent request can claim this token. - const storedToken = await this.refreshTokenRepository.findAndRevokeValidToken(tokenHash); - - if (!storedToken) { - // Token not found, expired, revoked, or already claimed by a concurrent request - throw new AuthServiceError( - 'Invalid or expired refresh token', - 'INVALID_REFRESH_TOKEN', - 401 - ); - } - - // Get user - const user = await this.userRepository.findByIdWithRoles(storedToken.userId); - if (!user || !user.isActive) { - // Revoke the token family if user is inactive - await this.refreshTokenRepository.revokeTokenFamily(storedToken.familyId); - throw new AuthServiceError( - 'User account is not active', - 'ACCOUNT_DEACTIVATED', - 403 - ); - } - - // Create new refresh token in the same family - const { token: newRefreshToken } = await this.refreshTokenRepository.createRefreshToken( - user.id, - storedToken.familyId, // Same family for rotation detection - JWT_REFRESH_EXPIRES_IN_DAYS, - ipAddress, - userAgent - ); - - // Generate new access token - const accessToken = this.generateAccessToken(user); - - return { user, accessToken, refreshToken: newRefreshToken }; - } - - /** - * Revoke a specific refresh token - */ - public async revokeRefreshToken(refreshToken: string): Promise { - if (!this.refreshTokenRepository) return; - - const tokenHash = RefreshTokenRepository.hashToken(refreshToken); - const storedToken = await this.refreshTokenRepository.findByTokenHash(tokenHash); - - if (storedToken) { - await this.refreshTokenRepository.revokeToken(storedToken.id); - } - } - - /** - * Revoke all refresh tokens for a user (force logout everywhere) - */ - public async revokeAllUserTokens(userId: number): Promise { - if (!this.refreshTokenRepository) return; - await this.refreshTokenRepository.revokeAllUserTokens(userId); - } - - /** - * Verify a JWT access token and return the user - */ - public async verifyToken(token: string): Promise { - try { - const payload = this.decodeToken(token); - if (!payload) return null; - - // Check if token is expired - if (payload.exp * 1000 < Date.now()) { - return null; - } - - const user = await this.userRepository.findByIdWithRoles(payload.userId); - if (!user || !user.isActive) { - return null; - } - - return user; - } catch { - return null; - } - } - - /** - * Change password for authenticated user - */ - public async changePassword( - userId: number, - oldPassword: string, - newPassword: string, - ipAddress?: string - ): Promise { - const user = await this.userRepository.findOneBy({ id: userId } as Partial); - if (!user) { - throw new AuthServiceError('User not found', 'USER_NOT_FOUND', 404); - } - - // Verify old password - const isValid = await bcrypt.compare(oldPassword, user.passwordHash); - if (!isValid) { - throw new AuthServiceError( - 'Current password is incorrect', - 'INVALID_PASSWORD', - 401 - ); - } - - // Validate new password - const passwordValidation = this.validatePassword(newPassword); - if (!passwordValidation.valid) { - throw new AuthServiceError( - passwordValidation.message!, - 'WEAK_PASSWORD' - ); - } - - // Hash and update password - const passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS); - await this.userRepository.updateOne(userId, { passwordHash } as Partial); - - // Revoke all refresh tokens (force re-login) - await this.revokeAllUserTokens(userId); - - // Log the password change - await this.auditLogRepository.log({ - userId, - action: AuditActions.PASSWORD_CHANGE, - resourceType: 'user', - resourceId: userId.toString(), - ipAddress, - }); - } - - /** - * Generate a password reset token - */ - public async requestPasswordReset( - email: string, - ipAddress?: string - ): Promise { - const user = await this.userRepository.findByEmail(email.toLowerCase()); - if (!user) { - // Don't reveal if email exists - return null; - } - - // Generate reset token - const resetToken = crypto.randomBytes(32).toString('hex'); - - // Log the request - await this.auditLogRepository.log({ - userId: user.id, - action: AuditActions.PASSWORD_RESET_REQUEST, - resourceType: 'user', - resourceId: user.id.toString(), - ipAddress, - }); - - return resetToken; - } - - /** - * Logout - revoke refresh token and log - */ - public async logout( - userId: number, - refreshToken?: string, - ipAddress?: string - ): Promise { - // Revoke the specific refresh token if provided - if (refreshToken) { - await this.revokeRefreshToken(refreshToken); - } - - await this.auditLogRepository.log({ - userId, - action: AuditActions.LOGOUT, - resourceType: 'user', - resourceId: userId.toString(), - ipAddress, - }); - } - - /** - * Check if user has a specific permission - */ - public async hasPermission( - userId: number, - resource: string, - action: string - ): Promise { - return this.userRepository.hasPermission(userId, resource, action); - } - - /** - * Check if user has admin role - */ - public async isAdmin(userId: number): Promise { - const roles = await this.userRepository.getUserRoles(userId); - return roles.some(role => role.name === 'admin'); - } - - // Private helper methods - - private async createRefreshToken( - userId: number, - ipAddress?: string, - userAgent?: string - ): Promise { - if (!this.refreshTokenRepository) { - // Fallback: return a simple token if repository not available - return crypto.randomBytes(32).toString('hex'); - } - - const familyId = RefreshTokenRepository.generateFamilyId(); - const { token } = await this.refreshTokenRepository.createRefreshToken( - userId, - familyId, - JWT_REFRESH_EXPIRES_IN_DAYS, - ipAddress, - userAgent - ); - return token; - } - - private generateAccessToken(user: User | UserWithRoles): string { - const payload: Omit = { - userId: user.id, - email: user.email, - type: 'access', - }; - - const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); - const now = Math.floor(Date.now() / 1000); - const expiresIn = this.parseExpiration(JWT_ACCESS_EXPIRES_IN); - - const payloadWithTime = { - ...payload, - iat: now, - exp: now + expiresIn, - }; - - const payloadB64 = Buffer.from(JSON.stringify(payloadWithTime)).toString('base64url'); - const signature = crypto - .createHmac('sha256', JWT_SECRET) - .update(`${header}.${payloadB64}`) - .digest('base64url'); - - return `${header}.${payloadB64}.${signature}`; - } - - private decodeToken(token: string): TokenPayload | null { - try { - const parts = token.split('.'); - if (parts.length !== 3) return null; - - const [header, payload, signature] = parts; - - // Verify signature - const expectedSignature = crypto - .createHmac('sha256', JWT_SECRET) - .update(`${header}.${payload}`) - .digest('base64url'); - - if (signature !== expectedSignature) return null; - - return JSON.parse(Buffer.from(payload, 'base64url').toString()); - } catch { - return null; - } - } - - private parseExpiration(expiration: string): number { - const match = expiration.match(/^(\d+)([smhd])$/); - if (!match) return 15 * 60; // Default 15 minutes - - const value = parseInt(match[1], 10); - const unit = match[2]; - - switch (unit) { - case 's': return value; - case 'm': return value * 60; - case 'h': return value * 60 * 60; - case 'd': return value * 24 * 60 * 60; - default: return 15 * 60; - } - } - - private isValidEmail(email: string): boolean { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email); - } - - private validatePassword(password: string): { valid: boolean; message?: string } { - if (password.length < 8) { - return { valid: false, message: 'Password must be at least 8 characters long' }; - } - if (!/[A-Z]/.test(password)) { - return { valid: false, message: 'Password must contain at least one uppercase letter' }; - } - if (!/[a-z]/.test(password)) { - return { valid: false, message: 'Password must contain at least one lowercase letter' }; - } - if (!/[0-9]/.test(password)) { - return { valid: false, message: 'Password must contain at least one number' }; - } - return { valid: true }; - } -} diff --git a/analytics-ui/src/apollo/server/services/oauthService.ts b/analytics-ui/src/apollo/server/services/oauthService.ts deleted file mode 100644 index 7f63396..0000000 --- a/analytics-ui/src/apollo/server/services/oauthService.ts +++ /dev/null @@ -1,479 +0,0 @@ -import crypto from 'crypto'; -import { Knex } from 'knex'; -import { UserRepository, User } from '../repositories/userRepository'; -import { RoleRepository } from '../repositories/roleRepository'; -import { OAuthAccountRepository, OAuthProvider, OAuthAccount } from '../repositories/oauthAccountRepository'; -export type { OAuthProvider } from '../repositories/oauthAccountRepository'; -import { AuditLogRepository, AuditActions } from '../repositories/auditLogRepository'; -import { AuthPayload } from './authService'; -import { RefreshTokenRepository } from '../repositories/refreshTokenRepository'; -import { getConfig } from '../config'; - -/** - * OAuth Provider Configuration - */ -export interface OAuthProviderConfig { - clientId: string; - clientSecret: string; - authUrl: string; - tokenUrl: string; - userInfoUrl: string; - scopes: string[]; - redirectUri?: string; -} - -/** - * OAuth User Info from provider - */ -export interface OAuthUserInfo { - id: string; - email: string; - name?: string; - avatarUrl?: string; - rawData?: Record; -} - -/** - * OAuth Tokens from provider - */ -export interface OAuthTokens { - accessToken: string; - refreshToken?: string; - expiresIn?: number; - tokenType?: string; -} - -/** - * OAuth Service - handles OAuth flows for multiple providers - */ -export class OAuthService { - private providers: Map = new Map(); - private knex: Knex; - - constructor(knex: Knex) { - this.knex = knex; - this.initializeProviders(); - } - - /** - * Initialize OAuth providers from environment variables - * Providers are only initialized if both credentials exist AND the provider is enabled - */ - private initializeProviders(): void { - const config = getConfig(); - - // Google OAuth - only if enabled via config - if (config.googleOAuthEnabled && - process.env.GOOGLE_CLIENT_ID && - process.env.GOOGLE_CLIENT_SECRET) { - this.providers.set('google', { - clientId: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', - tokenUrl: 'https://oauth2.googleapis.com/token', - userInfoUrl: 'https://www.googleapis.com/oauth2/v3/userinfo', - scopes: ['email', 'profile'], - }); - } - - // GitHub OAuth - only if enabled via config - if (config.githubOAuthEnabled && - process.env.GITHUB_CLIENT_ID && - process.env.GITHUB_CLIENT_SECRET) { - this.providers.set('github', { - clientId: process.env.GITHUB_CLIENT_ID, - clientSecret: process.env.GITHUB_CLIENT_SECRET, - authUrl: 'https://github.com/login/oauth/authorize', - tokenUrl: 'https://github.com/login/oauth/access_token', - userInfoUrl: 'https://api.github.com/user', - scopes: ['user:email'], - }); - } - } - - /** - * Get list of enabled OAuth providers - */ - public getEnabledProviders(): OAuthProvider[] { - return Array.from(this.providers.keys()); - } - - /** - * Check if a provider is enabled - */ - public isProviderEnabled(provider: string): boolean { - return this.providers.has(provider as OAuthProvider); - } - - /** - * Generate OAuth authorization URL - */ - public getAuthorizationUrl( - provider: OAuthProvider, - redirectUri: string, - state?: string - ): string { - const config = this.providers.get(provider); - if (!config) { - throw new Error(`OAuth provider '${provider}' is not configured`); - } - - const params = new URLSearchParams({ - client_id: config.clientId, - redirect_uri: redirectUri, - response_type: 'code', - scope: config.scopes.join(' '), - state: state || this.generateState(), - }); - - // Provider-specific params - if (provider === 'google') { - params.set('access_type', 'offline'); - params.set('prompt', 'consent'); - } - - return `${config.authUrl}?${params.toString()}`; - } - - /** - * Exchange authorization code for tokens - */ - public async exchangeCodeForTokens( - provider: OAuthProvider, - code: string, - redirectUri: string - ): Promise { - const config = this.providers.get(provider); - if (!config) { - throw new Error(`OAuth provider '${provider}' is not configured`); - } - - const params = new URLSearchParams({ - client_id: config.clientId, - client_secret: config.clientSecret, - code, - redirect_uri: redirectUri, - grant_type: 'authorization_code', - }); - - const response = await fetch(config.tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - }, - body: params.toString(), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Failed to exchange code: ${error}`); - } - - const data = await response.json(); - - return { - accessToken: data.access_token, - refreshToken: data.refresh_token, - expiresIn: data.expires_in, - tokenType: data.token_type, - }; - } - - /** - * Get user info from provider - */ - public async getUserInfo( - provider: OAuthProvider, - accessToken: string - ): Promise { - const config = this.providers.get(provider); - if (!config) { - throw new Error(`OAuth provider '${provider}' is not configured`); - } - - const response = await fetch(config.userInfoUrl, { - headers: { - 'Authorization': `Bearer ${accessToken}`, - 'Accept': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error('Failed to get user info from provider'); - } - - const data = await response.json(); - - // Normalize user info based on provider - return this.normalizeUserInfo(provider, data); - } - - /** - * Complete OAuth login/registration flow - */ - public async handleOAuthCallback( - provider: OAuthProvider, - code: string, - redirectUri: string, - ipAddress?: string - ): Promise { - // Exchange code for tokens - const tokens = await this.exchangeCodeForTokens(provider, code, redirectUri); - - // Get user info from provider - const userInfo = await this.getUserInfo(provider, tokens.accessToken); - - // Create repositories - const userRepository = new UserRepository(this.knex); - const roleRepository = new RoleRepository(this.knex); - const oauthAccountRepository = new OAuthAccountRepository(this.knex); - const auditLogRepository = new AuditLogRepository(this.knex); - const refreshTokenRepository = new RefreshTokenRepository(this.knex); - - // Check if OAuth account already exists - const existingOAuth = await oauthAccountRepository.findByProviderAndUserId( - provider, - userInfo.id - ); - - let user: User; - let isNewUser = false; - - if (existingOAuth) { - // Existing OAuth account - get user and update tokens - const foundUser = await userRepository.findOneBy({ id: existingOAuth.userId } as Partial); - if (!foundUser) { - throw new Error('User account not found'); - } - if (!foundUser.isActive) { - throw new Error('Your account has been deactivated'); - } - user = foundUser; - - // Update OAuth tokens - await oauthAccountRepository.updateTokens( - existingOAuth.id, - tokens.accessToken, - tokens.refreshToken, - tokens.expiresIn ? new Date(Date.now() + tokens.expiresIn * 1000) : undefined - ); - } else { - // Check if user exists with this email - const existingUserByEmail = await userRepository.findByEmail(userInfo.email); - - if (existingUserByEmail) { - // Link OAuth to existing user - user = existingUserByEmail; - - await oauthAccountRepository.linkAccount({ - userId: user.id, - provider, - providerUserId: userInfo.id, - providerEmail: userInfo.email, - displayName: userInfo.name, - avatarUrl: userInfo.avatarUrl, - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - tokenExpiresAt: tokens.expiresIn - ? new Date(Date.now() + tokens.expiresIn * 1000) - : undefined, - }); - } else { - // Create new user from OAuth - isNewUser = true; - user = await userRepository.createOne({ - email: userInfo.email, - passwordHash: '', // No password for OAuth-only users - displayName: userInfo.name || userInfo.email.split('@')[0], - avatarUrl: userInfo.avatarUrl, - isActive: true, - isVerified: true, // OAuth email is verified - }); - - // Assign default viewer role - const viewerRole = await roleRepository.findByName('viewer'); - if (viewerRole) { - await userRepository.assignRole(user.id, viewerRole.id); - } - - // Link OAuth account - await oauthAccountRepository.linkAccount({ - userId: user.id, - provider, - providerUserId: userInfo.id, - providerEmail: userInfo.email, - displayName: userInfo.name, - avatarUrl: userInfo.avatarUrl, - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - tokenExpiresAt: tokens.expiresIn - ? new Date(Date.now() + tokens.expiresIn * 1000) - : undefined, - }); - } - } - - // Update last login - await userRepository.updateLastLogin(user.id); - - // Get user with roles - const userWithRoles = await userRepository.findByIdWithRoles(user.id); - - // Log the OAuth login - await auditLogRepository.log({ - userId: user.id, - action: isNewUser ? AuditActions.REGISTER : AuditActions.LOGIN, - resourceType: 'user', - resourceId: user.id.toString(), - details: { provider, method: 'oauth' }, - ipAddress, - }); - - // Generate tokens using a simplified flow - const familyId = RefreshTokenRepository.generateFamilyId(); - const { token: refreshToken } = await refreshTokenRepository.createRefreshToken( - user.id, - familyId, - 30, - ipAddress - ); - - const accessToken = this.generateAccessToken(user); - - return { - user: userWithRoles!, - accessToken, - refreshToken, - }; - } - - /** - * Link OAuth account to existing user - */ - public async linkOAuthAccount( - userId: number, - provider: OAuthProvider, - code: string, - redirectUri: string - ): Promise { - const oauthAccountRepository = new OAuthAccountRepository(this.knex); - - // Check if already linked - const existing = await oauthAccountRepository.findByUserAndProvider(userId, provider); - if (existing) { - throw new Error(`${provider} account is already linked`); - } - - // Exchange code for tokens - const tokens = await this.exchangeCodeForTokens(provider, code, redirectUri); - - // Get user info - const userInfo = await this.getUserInfo(provider, tokens.accessToken); - - // Check if this OAuth account is linked to another user - const existingOAuth = await oauthAccountRepository.findByProviderAndUserId( - provider, - userInfo.id - ); - if (existingOAuth) { - throw new Error(`This ${provider} account is already linked to another user`); - } - - // Link account - return oauthAccountRepository.linkAccount({ - userId, - provider, - providerUserId: userInfo.id, - providerEmail: userInfo.email, - displayName: userInfo.name, - avatarUrl: userInfo.avatarUrl, - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - tokenExpiresAt: tokens.expiresIn - ? new Date(Date.now() + tokens.expiresIn * 1000) - : undefined, - }); - } - - /** - * Unlink OAuth account from user - */ - public async unlinkOAuthAccount(userId: number, provider: OAuthProvider): Promise { - const userRepository = new UserRepository(this.knex); - const oauthAccountRepository = new OAuthAccountRepository(this.knex); - - // Get user - const user = await userRepository.findOneBy({ id: userId } as Partial); - if (!user) { - throw new Error('User not found'); - } - - // Check if user has a password (can still log in) - const linkedAccounts = await oauthAccountRepository.findAllByUser(userId); - if (!user.passwordHash && linkedAccounts.length <= 1) { - throw new Error('Cannot unlink the only login method. Please set a password first.'); - } - - await oauthAccountRepository.unlinkAccount(userId, provider); - } - - /** - * Get linked OAuth accounts for a user - */ - public async getLinkedAccounts(userId: number): Promise { - const oauthAccountRepository = new OAuthAccountRepository(this.knex); - return oauthAccountRepository.findAllByUser(userId); - } - - // Private helper methods - - private normalizeUserInfo(provider: OAuthProvider, data: any): OAuthUserInfo { - switch (provider) { - case 'google': - return { - id: data.sub, - email: data.email, - name: data.name, - avatarUrl: data.picture, - rawData: data, - }; - case 'github': - return { - id: String(data.id), - email: data.email, - name: data.name || data.login, - avatarUrl: data.avatar_url, - rawData: data, - }; - default: - throw new Error(`Unknown provider: ${provider}`); - } - } - - private generateState(): string { - return crypto.randomBytes(16).toString('hex'); - } - - private generateAccessToken(user: User): string { - const JWT_SECRET = process.env.JWT_SECRET || 'development-secret'; - const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); - const now = Math.floor(Date.now() / 1000); - - const payload = { - userId: user.id, - email: user.email, - type: 'access', - iat: now, - exp: now + 15 * 60, // 15 minutes - }; - - const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); - const signature = crypto - .createHmac('sha256', JWT_SECRET) - .update(`${header}.${payloadB64}`) - .digest('base64url'); - - return `${header}.${payloadB64}.${signature}`; - } -} diff --git a/analytics-ui/src/apollo/server/types/context.ts b/analytics-ui/src/apollo/server/types/context.ts index 8e28549..dd6a47b 100644 --- a/analytics-ui/src/apollo/server/types/context.ts +++ b/analytics-ui/src/apollo/server/types/context.ts @@ -1,3 +1,5 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import type { Knex } from 'knex'; import { IConfig } from '@server/config'; import { IIbisAdaptor, @@ -48,6 +50,13 @@ import { import { ISqlPairService } from '../services/sqlPairService'; export interface IContext { + // HTTP request/response — available in all GraphQL resolvers + req: NextApiRequest; + res: NextApiResponse; + + // Database connection + knex: Knex; + config: IConfig; // telemetry telemetry: ITelemetry; diff --git a/analytics-ui/src/apollo/server/utils/authUtils.ts b/analytics-ui/src/apollo/server/utils/authUtils.ts new file mode 100644 index 0000000..97e4c93 --- /dev/null +++ b/analytics-ui/src/apollo/server/utils/authUtils.ts @@ -0,0 +1,146 @@ +import bcrypt from 'bcryptjs'; +import { UserRepository, User } from '../repositories/userRepository'; +import { RoleRepository } from '../repositories/roleRepository'; +import { AuditLogRepository, AuditActions } from '../repositories/auditLogRepository'; + +const SALT_ROUNDS = 12; + +export class AuthServiceError extends Error { + constructor(message: string, public code: string) { + super(message); + this.name = 'AuthServiceError'; + } +} + +export interface RegisterInput { + email: string; + password: string; + displayName: string; +} + +interface RegisterRepos { + userRepository: UserRepository; + roleRepository: RoleRepository; + auditLogRepository: AuditLogRepository; +} + +interface ChangePasswordRepos { + userRepository: UserRepository; + auditLogRepository: AuditLogRepository; +} + +export class AuthUtils { + static validatePassword(password: string): { valid: boolean; message?: string } { + if (password.length < 8) { + return { valid: false, message: 'Password must be at least 8 characters long' }; + } + if (!/[A-Z]/.test(password)) { + return { valid: false, message: 'Password must contain at least one uppercase letter' }; + } + if (!/[a-z]/.test(password)) { + return { valid: false, message: 'Password must contain at least one lowercase letter' }; + } + if (!/[0-9]/.test(password)) { + return { valid: false, message: 'Password must contain at least one number' }; + } + return { valid: true }; + } + + static async register(repos: RegisterRepos, input: RegisterInput, ipAddress?: string) { + const { email, password, displayName } = input; + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + throw new AuthServiceError('Invalid email format', 'INVALID_EMAIL'); + } + + const validation = AuthUtils.validatePassword(password); + if (!validation.valid) { + throw new AuthServiceError(validation.message!, 'WEAK_PASSWORD'); + } + + const existing = await repos.userRepository.findByEmail(email); + if (existing) { + throw new AuthServiceError('A user with this email already exists', 'EMAIL_EXISTS'); + } + + const passwordHash = await bcrypt.hash(password, SALT_ROUNDS); + const user = await repos.userRepository.createOne({ + email: email.toLowerCase(), + passwordHash, + displayName, + isActive: true, + isVerified: false, + }); + + const viewerRole = await repos.roleRepository.findByName('viewer'); + if (viewerRole) { + await repos.userRepository.assignRole(user.id, viewerRole.id); + } + + const userWithRoles = await repos.userRepository.findByIdWithRoles(user.id); + + await repos.auditLogRepository.log({ + userId: user.id, + action: AuditActions.REGISTER, + resourceType: 'user', + resourceId: user.id.toString(), + ipAddress, + }); + + return { user: userWithRoles! }; + } + + static async changePassword( + repos: ChangePasswordRepos, + userId: number, + oldPassword: string, + newPassword: string, + ipAddress?: string + ): Promise { + const user = await repos.userRepository.findOneBy({ id: userId } as Partial); + if (!user) { + throw new AuthServiceError('User not found', 'USER_NOT_FOUND'); + } + + const isValid = await bcrypt.compare(oldPassword, user.passwordHash); + if (!isValid) { + throw new AuthServiceError('Current password is incorrect', 'INVALID_PASSWORD'); + } + + const validation = AuthUtils.validatePassword(newPassword); + if (!validation.valid) { + throw new AuthServiceError(validation.message!, 'WEAK_PASSWORD'); + } + + const passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS); + await repos.userRepository.updateOne(userId, { passwordHash } as Partial); + + await repos.auditLogRepository.log({ + userId, + action: AuditActions.PASSWORD_CHANGE, + resourceType: 'user', + resourceId: userId.toString(), + ipAddress, + }); + } + + static async requestPasswordReset( + repos: ChangePasswordRepos, + email: string, + ipAddress?: string + ): Promise { + const user = await repos.userRepository.findByEmail(email.toLowerCase()); + if (user) { + await repos.auditLogRepository.log({ + userId: user.id, + action: AuditActions.PASSWORD_RESET_REQUEST, + resourceType: 'user', + resourceId: user.id.toString(), + ipAddress, + }); + } + // Never reveal whether the email exists + return null; + } +} diff --git a/analytics-ui/src/components/HeaderBar.tsx b/analytics-ui/src/components/HeaderBar.tsx index 66f6025..d346be0 100644 --- a/analytics-ui/src/components/HeaderBar.tsx +++ b/analytics-ui/src/components/HeaderBar.tsx @@ -88,7 +88,7 @@ export default function HeaderBar() { Data Source - {/* router.push(Path.KnowledgeQuestionSQLPairs)} @@ -96,14 +96,6 @@ export default function HeaderBar() { > Knowledge - router.push(Path.APIManagementHistory)} - block - > - API Management - */} )} diff --git a/analytics-ui/src/hooks/useAuth.tsx b/analytics-ui/src/hooks/useAuth.tsx index 28849ed..3220b74 100644 --- a/analytics-ui/src/hooks/useAuth.tsx +++ b/analytics-ui/src/hooks/useAuth.tsx @@ -1,89 +1,8 @@ -import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; -import { gql, useMutation, useQuery } from '@apollo/client'; -import { onTokenRefresh } from '@/apollo/client'; +import React, { createContext, useContext, useCallback, ReactNode } from 'react'; +import { useSession, signIn, signOut } from 'next-auth/react'; +import { useApolloClient } from '@apollo/client'; -// GraphQL queries and mutations -const ME_QUERY = gql` - query Me { - me { - id - email - displayName - avatarUrl - isActive - isVerified - roles { - id - name - permissions { - id - name - resource - action - } - } - } - } -`; - -const LOGIN_MUTATION = gql` - mutation Login($data: LoginInput!) { - login(data: $data) { - user { - id - email - displayName - avatarUrl - roles { - id - name - } - } - accessToken - refreshToken - } - } -`; - -const REGISTER_MUTATION = gql` - mutation Register($data: RegisterInput!) { - register(data: $data) { - user { - id - email - displayName - } - accessToken - refreshToken - } - } -`; - -const LOGOUT_MUTATION = gql` - mutation Logout { - logout - } -`; - -const REFRESH_TOKEN_MUTATION = gql` - mutation RefreshToken($refreshToken: String!) { - refreshToken(refreshToken: $refreshToken) { - user { - id - email - displayName - roles { - id - name - } - } - accessToken - refreshToken - } - } -`; - -// Types +// Types — kept identical to old interface so all consumers compile unchanged export interface Permission { id: number; name: string; @@ -122,280 +41,64 @@ export interface AuthContextType { const AuthContext = createContext(undefined); -// Token storage keys -const ACCESS_TOKEN_KEY = 'nqrust_access_token'; -const REFRESH_TOKEN_KEY = 'nqrust_refresh_token'; - export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [accessToken, setAccessToken] = useState(null); - - // GraphQL operations - const { refetch: refetchMe } = useQuery(ME_QUERY, { - skip: true, // We'll call this manually - fetchPolicy: 'network-only', - }); - - const [loginMutation] = useMutation(LOGIN_MUTATION); - const [registerMutation] = useMutation(REGISTER_MUTATION); - const [logoutMutation] = useMutation(LOGOUT_MUTATION); - const [refreshTokenMutation] = useMutation(REFRESH_TOKEN_MUTATION); - - // Load tokens from storage on mount and fetch user - useEffect(() => { - const initAuth = async () => { - const storedAccessToken = localStorage.getItem(ACCESS_TOKEN_KEY); - - if (!storedAccessToken) { - setIsLoading(false); - return; - } - - setAccessToken(storedAccessToken); - - try { - const { data } = await refetchMe(); - if (data?.me) { - setUser(data.me); - } - } catch { - // Token might be expired, try to refresh - const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); - if (refreshToken) { - try { - const { data } = await refreshTokenMutation({ - variables: { refreshToken }, - }); - if (data?.refreshToken) { - localStorage.setItem(ACCESS_TOKEN_KEY, data.refreshToken.accessToken); - localStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken.refreshToken); - setAccessToken(data.refreshToken.accessToken); - setUser(data.refreshToken.user); - } - } catch { - // Refresh failed, clear tokens - localStorage.removeItem(ACCESS_TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); - setAccessToken(null); - setUser(null); - } - } else { - localStorage.removeItem(ACCESS_TOKEN_KEY); - setAccessToken(null); - } - } finally { - setIsLoading(false); - } - }; - - initAuth(); - }, []); - - const saveTokens = (newAccessToken: string, newRefreshToken: string) => { - localStorage.setItem(ACCESS_TOKEN_KEY, newAccessToken); - localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken); - setAccessToken(newAccessToken); - }; - - const clearTokens = () => { - localStorage.removeItem(ACCESS_TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); - setAccessToken(null); - setUser(null); - if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { - window.location.href = '/login'; - } - }; - - const handleRefreshToken = useCallback(async (refreshToken: string) => { - try { - const { data } = await refreshTokenMutation({ - variables: { refreshToken }, - }); - if (data?.refreshToken) { - saveTokens(data.refreshToken.accessToken, data.refreshToken.refreshToken); - setUser(data.refreshToken.user); - } - } catch { - clearTokens(); - } - }, [refreshTokenMutation]); - - // Proactive token refresh: schedule refresh at 80% of token lifetime - useEffect(() => { - if (!accessToken) return; - - try { - const payload = JSON.parse(atob(accessToken.split('.')[1])); - const expiresAt = payload.exp * 1000; - const issuedAt = (payload.iat || 0) * 1000; - const now = Date.now(); - - // If token is already expired, let the Apollo error link handle it - // (don't refresh here to avoid infinite loop) - if (expiresAt <= now) return; - - // Schedule refresh at 80% of remaining lifetime (min 30s from now) - const lifetime = issuedAt > 0 ? expiresAt - issuedAt : expiresAt - now; - const refreshAt = issuedAt > 0 - ? issuedAt + lifetime * 0.8 - : now + (expiresAt - now) * 0.8; - const delay = Math.max(refreshAt - now, 30_000); - - const timerId = setTimeout(() => { - const storedRefresh = localStorage.getItem(REFRESH_TOKEN_KEY); - if (storedRefresh) handleRefreshToken(storedRefresh); - }, delay); - - return () => clearTimeout(timerId); - } catch { - // Malformed token — will be caught on next API call - } - }, [accessToken, handleRefreshToken]); - - // Cross-tab auth sync: detect when another tab clears tokens - useEffect(() => { - const handleStorageChange = (e: StorageEvent) => { - if (e.key === ACCESS_TOKEN_KEY && !e.newValue) { - setUser(null); - setAccessToken(null); - if (!window.location.pathname.startsWith('/login')) { - window.location.href = '/login'; - } - } - }; - window.addEventListener('storage', handleStorageChange); - return () => window.removeEventListener('storage', handleStorageChange); - }, []); - - // Sync React state when Apollo error link refreshes tokens in the background - useEffect(() => { - return onTokenRefresh((newAccessToken) => { - setAccessToken(newAccessToken); - // Re-fetch user profile with the new token - refetchMe().then(({ data }) => { - if (data?.me) setUser(data.me); - }).catch(() => {}); - }); - }, [refetchMe]); - - // Re-validate session when user returns to the tab after being idle - useEffect(() => { - const handleVisibilityChange = () => { - if (document.visibilityState !== 'visible') return; - const storedToken = localStorage.getItem(ACCESS_TOKEN_KEY); - if (!storedToken) return; - - // If React state token differs from localStorage, sync it - // (Apollo error link may have refreshed it while tab was idle) - if (storedToken !== accessToken) { - setAccessToken(storedToken); - } - - // Verify the session is still valid - refetchMe().then(({ data }) => { - if (data?.me) setUser(data.me); - }).catch(() => { - // Token might be expired — trigger refresh - const storedRefresh = localStorage.getItem(REFRESH_TOKEN_KEY); - if (storedRefresh) handleRefreshToken(storedRefresh); - }); - }; - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => document.removeEventListener('visibilitychange', handleVisibilityChange); - }, [accessToken, refetchMe, handleRefreshToken]); + const { data: session, status } = useSession(); + const apolloClient = useApolloClient(); + const isLoading = status === 'loading'; + const user = (session?.user as User | undefined) ?? null; const login = useCallback(async (email: string, password: string) => { - setIsLoading(true); - try { - const { data } = await loginMutation({ - variables: { data: { email, password } }, - }); - if (data?.login) { - saveTokens(data.login.accessToken, data.login.refreshToken); - setUser(data.login.user); - } - } finally { - setIsLoading(false); + const result = await signIn('credentials', { + email, + password, + redirect: false, + }); + if (result?.error) { + throw new Error('Invalid email or password'); } - }, [loginMutation]); + }, []); - const register = useCallback(async (email: string, password: string, displayName: string) => { - setIsLoading(true); - try { - const { data } = await registerMutation({ - variables: { data: { email, password, displayName } }, - }); - if (data?.register) { - saveTokens(data.register.accessToken, data.register.refreshToken); - setUser(data.register.user); - } - } finally { - setIsLoading(false); - } - }, [registerMutation]); + const register = useCallback(async (_email: string, _password: string, _displayName: string) => { + // Registration is done via GraphQL mutation register() — not via NextAuth signIn. + // After a successful register mutation the caller should call login() to start a session. + throw new Error('Use the register GraphQL mutation, then call login()'); + }, []); const logout = useCallback(async () => { - try { - await logoutMutation(); - } finally { - clearTokens(); - } - }, [logoutMutation]); + // Clear Apollo cache before signing out so active queries don't + // attempt cache reads during component unmount (avoids canonizeResults warning). + await apolloClient.clearStore(); + await signOut({ callbackUrl: '/login' }); + }, [apolloClient]); const refreshUser = useCallback(async () => { - if (!accessToken) return; - try { - const { data } = await refetchMe(); - if (data?.me) { - setUser(data.me); - } - } catch { - // Token might be expired - const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); - if (refreshToken) { - await handleRefreshToken(refreshToken); - } - } - }, [accessToken, refetchMe, handleRefreshToken]); + // NextAuth refreshes the session automatically via SessionProvider refetchInterval. + // Nothing to do here. + }, []); const hasPermission = useCallback((resource: string, action: string): boolean => { if (!user) return false; - - // Check if user has admin role (has all permissions) - if (user.roles.some(role => role.name === 'admin')) { - return true; - } - - // Check specific permission - for (const role of user.roles) { - if (role.permissions) { - for (const perm of role.permissions) { - if (perm.resource === resource && perm.action === action) { - return true; - } - } - } - } - return false; + if (user.roles.some(r => r.name === 'admin')) return true; + return user.roles.some(role => + role.permissions?.some(p => p.resource === resource && p.action === action) + ); }, [user]); const isAdmin = useCallback((): boolean => { - return user?.roles?.some(role => role.name === 'admin') ?? false; + return user?.roles?.some(r => r.name === 'admin') ?? false; }, [user]); const value: AuthContextType = { user, isLoading, - isAuthenticated: !!user, + isAuthenticated: status === 'authenticated', login, register, logout, refreshUser, hasPermission, isAdmin, - accessToken, + accessToken: null, }; return ( diff --git a/analytics-ui/src/pages/_app.tsx b/analytics-ui/src/pages/_app.tsx index dd2cfb8..9af296d 100644 --- a/analytics-ui/src/pages/_app.tsx +++ b/analytics-ui/src/pages/_app.tsx @@ -9,16 +9,20 @@ import { PostHogProvider } from 'posthog-js/react'; import { ApolloProvider } from '@apollo/client'; import { defaultIndicator } from '@/components/PageLoading'; import LicenseGuard from '@/components/LicenseGuard'; +import { SessionProvider } from 'next-auth/react'; require('../styles/index.less'); Spin.setDefaultIndicator(defaultIndicator); -function App({ Component, pageProps }: AppProps) { +function App({ Component, pageProps: { session, ...pageProps } }: AppProps) { return ( <> NQRust - Analytics + + + - - - - - -
- -
-
-
-
-
-
+ + + + + + +
+ +
+
+
+
+
+
+
); } diff --git a/analytics-ui/src/pages/api/auth/[...nextauth].ts b/analytics-ui/src/pages/api/auth/[...nextauth].ts new file mode 100644 index 0000000..43e1701 --- /dev/null +++ b/analytics-ui/src/pages/api/auth/[...nextauth].ts @@ -0,0 +1,179 @@ +import NextAuth, { NextAuthOptions, Session } from 'next-auth'; +import CredentialsProvider from 'next-auth/providers/credentials'; +import { Knex } from 'knex'; +import { components } from '@/common'; +import { UserRepository } from '@/apollo/server/repositories/userRepository'; +import { RoleRepository } from '@/apollo/server/repositories/roleRepository'; +import { AuditLogRepository, AuditActions } from '@/apollo/server/repositories/auditLogRepository'; +import { RateLimitService } from '@/apollo/server/services/rateLimitService'; +import bcrypt from 'bcryptjs'; + +const { knex } = components; + +export const authOptions: NextAuthOptions = { + secret: process.env.NEXTAUTH_SECRET, + session: { strategy: 'jwt', maxAge: 7 * 24 * 60 * 60 }, // 7 days + pages: { signIn: '/login', error: '/login' }, + + providers: [ + CredentialsProvider({ + name: 'credentials', + credentials: { + email: { label: 'Email', type: 'email' }, + password: { label: 'Password', type: 'password' }, + }, + async authorize(credentials, req) { + const { email, password } = credentials ?? {}; + if (!email || !password) return null; + + const ipAddress = (req?.headers?.['x-forwarded-for'] as string) + ?.split(',')[0].trim() || 'unknown'; + + // Rate limiting + const rateLimitService = new RateLimitService(knex); + const rateLimit = await rateLimitService.checkLoginLimit(ipAddress, email); + if (!rateLimit.allowed) { + throw new Error(rateLimit.reason === 'ACCOUNT_LOCKED' + ? 'Account temporarily locked. Try again later.' + : 'Too many login attempts. Try again later.'); + } + + const userRepository = new UserRepository(knex); + const user = await userRepository.findByEmail(email); + + if (!user || !user.isActive) { + await rateLimitService.recordLoginAttempt(email, ipAddress, false, undefined, undefined, 'INVALID_PASSWORD'); + return null; + } + + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) { + await rateLimitService.recordLoginAttempt(email, ipAddress, false, user.id, undefined, 'INVALID_PASSWORD'); + return null; + } + + await rateLimitService.recordLoginAttempt(email, ipAddress, true, user.id); + await userRepository.updateLastLogin(user.id); + + // Audit log + const auditLogRepository = new AuditLogRepository(knex); + await auditLogRepository.log({ + userId: user.id, + action: AuditActions.LOGIN, + resourceType: 'auth', + ipAddress, + }); + + return { id: String(user.id), email: user.email }; + }, + }), + + // Keycloak / NQRust Identity — only active when env vars are set + // wellKnown is intentionally NOT used: it would fetch the discovery document + // from KEYCLOAK_URL (host.docker.internal) and override our explicit authorization.url + // with a host.docker.internal URL that the browser cannot reach. + // Instead we set each endpoint explicitly: + // authorization → KEYCLOAK_PUBLIC_URL (browser-facing, e.g. localhost) + // token/userinfo/jwks → KEYCLOAK_URL (server-to-server, e.g. host.docker.internal) + ...(process.env.KEYCLOAK_OAUTH_ENABLED === 'true' && + process.env.KEYCLOAK_CLIENT_ID && + process.env.KEYCLOAK_CLIENT_SECRET + ? [{ + id: 'keycloak', + name: 'NQRust Identity', + type: 'oauth' as const, + authorization: { + url: `${process.env.KEYCLOAK_PUBLIC_URL}/realms/${process.env.KEYCLOAK_REALM || 'master'}/protocol/openid-connect/auth`, + params: { scope: 'openid email profile' }, + }, + token: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM || 'master'}/protocol/openid-connect/token`, + userinfo: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM || 'master'}/protocol/openid-connect/userinfo`, + jwks_endpoint: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM || 'master'}/protocol/openid-connect/certs`, + // issuer must match the `iss` claim Keycloak puts in the ID token. + // Keycloak uses its own public URL for `iss`, so we use KEYCLOAK_PUBLIC_URL here. + issuer: `${process.env.KEYCLOAK_PUBLIC_URL}/realms/${process.env.KEYCLOAK_REALM || 'master'}`, + clientId: process.env.KEYCLOAK_CLIENT_ID, + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET, + idToken: true, + checks: ['pkce', 'state'] as ['pkce', 'state'], + profile(profile: any) { + return { + id: profile.sub, + email: profile.email, + name: profile.name || profile.preferred_username, + image: profile.picture, + }; + }, + }] + : []), + ], + + callbacks: { + // signIn: auto-register Keycloak users into our DB + async signIn({ user, account }) { + if (account?.provider === 'keycloak') { + const userRepository = new UserRepository(knex); + const autoRegister = process.env.KEYCLOAK_AUTO_REGISTER !== 'false'; + + let dbUser = await userRepository.findByEmail(user.email!); + if (!dbUser) { + if (!autoRegister) return false; + dbUser = await knex.transaction(async (trx: Knex.Transaction) => { + const trxUserRepository = new UserRepository(trx); + const trxRoleRepository = new RoleRepository(trx); + const newUser = await trxUserRepository.createOne({ + email: user.email!, + passwordHash: '', + displayName: user.name || user.email!, + avatarUrl: (user as any).image || null, + isActive: true, + isVerified: true, + }); + const defaultRole = process.env.KEYCLOAK_DEFAULT_ROLE || 'viewer'; + const role = await trxRoleRepository.findByName(defaultRole); + if (role) await trxUserRepository.assignRole(newUser.id, role.id); + return trxUserRepository.findByIdWithRoles(newUser.id); + }); + } + + if (!dbUser?.isActive) return false; + + // Inject our DB userId so the jwt callback can pick it up + user.id = String(dbUser.id); + } + return true; + }, + + // jwt: on first sign-in load full user data from DB into the token + async jwt({ token, user }) { + if (user?.id) { + const userRepository = new UserRepository(knex); + const dbUser = await userRepository.findByIdWithRoles(Number(user.id)); + if (dbUser) { + token.userId = dbUser.id; + token.email = dbUser.email; + token.displayName = dbUser.displayName; + token.avatarUrl = dbUser.avatarUrl ?? undefined; + token.roles = dbUser.roles as any; + } + } + return token; + }, + + // session: expose enriched user data to the client + async session({ session, token }) { + session.user = { + id: token.userId, + email: token.email, + displayName: token.displayName, + avatarUrl: token.avatarUrl, + isActive: true, + isVerified: true, + roles: (token.roles || []) as Session['user']['roles'], + }; + return session; + }, + }, +}; + +export default NextAuth(authOptions); diff --git a/analytics-ui/src/pages/api/auth/config.ts b/analytics-ui/src/pages/api/auth/config.ts index 8ddb9b1..9d0385f 100644 --- a/analytics-ui/src/pages/api/auth/config.ts +++ b/analytics-ui/src/pages/api/auth/config.ts @@ -6,11 +6,13 @@ export interface AuthConfigResponse { google: boolean; github: boolean; }; + keycloakEnabled: boolean; } /** - * API endpoint to get authentication configuration - * Returns which OAuth providers are enabled for the frontend to conditionally render login buttons + * API endpoint to get authentication configuration. + * Returns which OAuth providers are enabled. + * Keycloak is configured via KEYCLOAK_* environment variables. */ export default function handler( _req: NextApiRequest, @@ -23,5 +25,7 @@ export default function handler( google: config.googleOAuthEnabled ?? false, github: config.githubOAuthEnabled ?? false, }, + keycloakEnabled: process.env.KEYCLOAK_OAUTH_ENABLED === 'true' && + !!(process.env.KEYCLOAK_CLIENT_ID && process.env.KEYCLOAK_CLIENT_SECRET), }); } diff --git a/analytics-ui/src/pages/api/auth/oauth/[provider].ts b/analytics-ui/src/pages/api/auth/oauth/[provider].ts deleted file mode 100644 index d254032..0000000 --- a/analytics-ui/src/pages/api/auth/oauth/[provider].ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; -import crypto from 'crypto'; -import { OAuthService, OAuthProvider } from '@/apollo/server/services/oauthService'; -import { initComponents } from '@/common'; - -const { knex } = initComponents(); - -// State management for OAuth (in production, use Redis or database) -const oauthStates = new Map(); - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { provider, action } = req.query; - const providerName = provider as OAuthProvider; - - const oauthService = new OAuthService(knex); - - // Check if provider is enabled - if (!oauthService.isProviderEnabled(providerName)) { - return res.status(400).json({ error: `OAuth provider '${providerName}' is not configured` }); - } - - const baseUrl = process.env.APP_URL || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers.host}`; - const redirectUri = `${baseUrl}/api/auth/oauth/${providerName}/callback`; - - // Handle different actions - if (action === 'callback' || req.query.code) { - // Handle OAuth callback - return handleCallback(req, res, oauthService, providerName, redirectUri); - } else { - // Initiate OAuth flow - return handleInitiate(req, res, oauthService, providerName, redirectUri); - } -} - -async function handleInitiate( - req: NextApiRequest, - res: NextApiResponse, - oauthService: OAuthService, - provider: OAuthProvider, - redirectUri: string -) { - try { - // Generate state for CSRF protection - const state = crypto.randomBytes(16).toString('hex'); - const redirect = (req.query.redirect as string) || '/home'; - - // Store state (expires in 10 minutes) - oauthStates.set(state, { - provider, - redirect, - expiresAt: Date.now() + 10 * 60 * 1000, - }); - - // Clean up old states - for (const [key, value] of oauthStates.entries()) { - if (value.expiresAt < Date.now()) { - oauthStates.delete(key); - } - } - - // Get authorization URL - const authUrl = oauthService.getAuthorizationUrl(provider, redirectUri, state); - - // Redirect to OAuth provider - res.redirect(authUrl); - } catch (error: any) { - console.error('OAuth initiate error:', error); - res.redirect(`/login?error=${encodeURIComponent(error.message || 'OAuth failed')}`); - } -} - -async function handleCallback( - req: NextApiRequest, - res: NextApiResponse, - oauthService: OAuthService, - provider: OAuthProvider, - redirectUri: string -) { - try { - const { code, state, error, error_description } = req.query; - - // Check for OAuth error - if (error) { - throw new Error(error_description as string || error as string); - } - - // Validate state - if (!state || typeof state !== 'string') { - throw new Error('Invalid state parameter'); - } - - const storedState = oauthStates.get(state); - if (!storedState || storedState.expiresAt < Date.now()) { - oauthStates.delete(state); - throw new Error('State expired or invalid'); - } - - // Clean up used state - oauthStates.delete(state); - - if (!code || typeof code !== 'string') { - throw new Error('No authorization code received'); - } - - // Get client IP - const ipAddress = (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() - || req.socket.remoteAddress - || 'unknown'; - - // Handle OAuth callback (login or register) - const authPayload = await oauthService.handleOAuthCallback( - provider, - code, - redirectUri.replace('/callback', ''), // Remove /callback for proper redirect_uri - ipAddress - ); - - // Set tokens in cookies - res.setHeader('Set-Cookie', [ - `nqrust_access_token=${authPayload.accessToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=900`, - `nqrust_refresh_token=${authPayload.refreshToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`, - ]); - - // Redirect to the intended destination - res.redirect(storedState.redirect); - } catch (error: any) { - console.error('OAuth callback error:', error); - res.redirect(`/login?error=${encodeURIComponent(error.message || 'OAuth authentication failed')}`); - } -} diff --git a/analytics-ui/src/pages/api/graphql.ts b/analytics-ui/src/pages/api/graphql.ts index d5a9536..308248f 100644 --- a/analytics-ui/src/pages/api/graphql.ts +++ b/analytics-ui/src/pages/api/graphql.ts @@ -1,6 +1,7 @@ import microCors from 'micro-cors'; import { NextApiRequest, NextApiResponse, PageConfig } from 'next'; import { ApolloServer } from 'apollo-server-micro'; +import { getToken } from 'next-auth/jwt'; import { typeDefs } from '@server'; import resolvers from '@server/resolvers'; import { IContext } from '@server/types'; @@ -132,16 +133,30 @@ const bootstrapServer = async () => { return defaultApolloErrorHandler(error); }, introspection: process.env.NODE_ENV !== 'production', - context: async ({ req }): Promise => { - // Extract authentication from request - const { user, ipAddress } = await import('@server/middleware/authMiddleware') - .then(mod => mod.createAuthContext(req, knex)) - .catch(() => ({ user: null, ipAddress: undefined })); + cache: 'bounded', + context: async ({ req, res }): Promise => { + // Extract authentication from NextAuth JWT cookie + let user = null; + let ipAddress: string | undefined; + try { + const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET }); + if (token?.userId) { + const dbUser = await userRepository.findByIdWithRoles(Number(token.userId)); + if (dbUser && dbUser.isActive) user = dbUser; + } + const forwarded = req.headers['x-forwarded-for']; + ipAddress = typeof forwarded === 'string' + ? forwarded.split(',')[0].trim() + : req.socket?.remoteAddress; + } catch (err) { + logger.warn('Failed to extract auth context from request:', err); + } return { + req, + res, config: serverConfig, telemetry, - // auth context user, ipAddress, knex, diff --git a/analytics-ui/src/pages/login/index.tsx b/analytics-ui/src/pages/login/index.tsx index 08689ae..f991c14 100644 --- a/analytics-ui/src/pages/login/index.tsx +++ b/analytics-ui/src/pages/login/index.tsx @@ -2,75 +2,69 @@ import React, { useState, useEffect } from 'react'; import { useRouter } from 'next/router'; import Head from 'next/head'; import { Form, Input, Button, Alert, Typography, Divider } from 'antd'; -import { MailOutlined, LockOutlined, GoogleOutlined, GithubOutlined } from '@ant-design/icons'; -import { useAuth } from '@/hooks/useAuth'; +import { MailOutlined, LockOutlined, SafetyCertificateOutlined } from '@ant-design/icons'; +import { signIn, useSession } from 'next-auth/react'; import styles from './login.module.less'; const { Title, Text } = Typography; -interface OAuthProviders { - google: boolean; - github: boolean; +interface LoginPageProps { + keycloakSSOEnabled: boolean; } -export default function LoginPage() { +export default function LoginPage({ keycloakSSOEnabled }: LoginPageProps) { const router = useRouter(); - const { login, isAuthenticated, isLoading } = useAuth(); + const { status } = useSession(); const [form] = Form.useForm(); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); - const [oauthProviders, setOauthProviders] = useState({ google: false, github: false }); - const [oauthLoading, setOauthLoading] = useState(true); - // Fetch enabled OAuth providers - useEffect(() => { - fetch('/api/auth/config') - .then((res) => res.json()) - .then((data) => { - setOauthProviders(data.providers || { google: false, github: false }); - }) - .catch(() => { - // On error, assume no OAuth providers available - setOauthProviders({ google: false, github: false }); - }) - .finally(() => { - setOauthLoading(false); - }); - }, []); - - // Check for OAuth error in URL + // Check for NextAuth error in URL (e.g., ?error=CredentialsSignin) useEffect(() => { if (router.query.error) { - setError(router.query.error as string); + const errMsg = router.query.error as string; + if (errMsg === 'CredentialsSignin') { + setError('Invalid email or password.'); + } else { + setError(errMsg); + } } }, [router.query]); // Redirect if already authenticated useEffect(() => { - if (isAuthenticated) { + if (status === 'authenticated') { router.push('/home'); } - }, [isAuthenticated, router]); + }, [status, router]); const handleSubmit = async (values: { email: string; password: string }) => { setError(null); setSubmitting(true); - try { - await login(values.email, values.password); - router.push('/home'); - } catch (err: any) { - setError(err.message || 'Login failed. Please check your credentials.'); + const result = await signIn('credentials', { + email: values.email, + password: values.password, + redirect: false, + callbackUrl: '/home', + }); + if (result?.error) { + setError('Invalid email or password.'); + } else if (result?.ok) { + router.push('/home'); + } + } catch { + setError('Login failed. Please try again.'); } finally { setSubmitting(false); } }; - const handleOAuthLogin = (provider: 'google' | 'github') => { - window.location.href = `/api/auth/oauth/${provider}`; + const handleKeycloakLogin = () => { + signIn('keycloak', { callbackUrl: '/home' }); }; - if (isLoading) { + if (status === 'loading') { return (
Loading...
@@ -108,34 +102,20 @@ export default function LoginPage() { /> )} - {/* OAuth Buttons - only show if at least one provider is enabled */} - {!oauthLoading && (oauthProviders.google || oauthProviders.github) && ( + {/* Keycloak SSO Button */} + {keycloakSSOEnabled && ( <>
- {oauthProviders.google && ( - - )} - {oauthProviders.github && ( - - )} +
- or sign in with email @@ -155,7 +135,7 @@ export default function LoginPage() { { required: true, message: 'Please enter your email' }, { pattern: /^[^\s@]+@[^\s@]+(\.[^\s@]+)?$/, - message: 'Please enter a valid email' + message: 'Please enter a valid email', }, ]} > @@ -194,3 +174,14 @@ export default function LoginPage() { ); } + +// Expose KEYCLOAK_OAUTH_ENABLED to the client at build-time via server-side props +export async function getServerSideProps() { + return { + props: { + keycloakSSOEnabled: + process.env.KEYCLOAK_OAUTH_ENABLED === 'true' && + !!(process.env.KEYCLOAK_CLIENT_ID && process.env.KEYCLOAK_CLIENT_SECRET), + }, + }; +} diff --git a/analytics-ui/src/pages/login/login.module.less b/analytics-ui/src/pages/login/login.module.less index ef9ae31..395f178 100644 --- a/analytics-ui/src/pages/login/login.module.less +++ b/analytics-ui/src/pages/login/login.module.less @@ -77,6 +77,26 @@ } } +.ssoButton { + background: #1890ff !important; + color: #fff !important; + border: none !important; + height: 40px; + font-weight: 500; + + &:hover { + opacity: 0.9; + filter: brightness(1.1); + } +} + +.ssoIcon { + width: 16px; + height: 16px; + vertical-align: middle; + margin-right: 4px; +} + .inputIcon { color: #bfbfbf; } diff --git a/analytics-ui/src/pages/settings/profile/index.tsx b/analytics-ui/src/pages/settings/profile/index.tsx index 0343649..177ee90 100644 --- a/analytics-ui/src/pages/settings/profile/index.tsx +++ b/analytics-ui/src/pages/settings/profile/index.tsx @@ -8,9 +8,9 @@ import { Card, Typography, Divider, - Avatar, Alert, } from 'antd'; +import Avatar from 'antd/es/avatar'; import { UserOutlined, LockOutlined, diff --git a/analytics-ui/src/pages/settings/users/index.tsx b/analytics-ui/src/pages/settings/users/index.tsx index 30f62a2..bc0dce9 100644 --- a/analytics-ui/src/pages/settings/users/index.tsx +++ b/analytics-ui/src/pages/settings/users/index.tsx @@ -14,8 +14,8 @@ import { message, Alert, Popconfirm, - Avatar, } from 'antd'; +import Avatar from 'antd/es/avatar'; import { UserOutlined, PlusOutlined, diff --git a/analytics-ui/src/styles/layouts/global.less b/analytics-ui/src/styles/layouts/global.less index a5875e4..cb96654 100644 --- a/analytics-ui/src/styles/layouts/global.less +++ b/analytics-ui/src/styles/layouts/global.less @@ -1,5 +1,4 @@ -// Import Montserrat font -@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap'); +// Montserrat font is loaded via in _document.tsx to avoid build-time network requests :root { .make-color-variables(); diff --git a/analytics-ui/src/types/next-auth.d.ts b/analytics-ui/src/types/next-auth.d.ts new file mode 100644 index 0000000..f28e0f5 --- /dev/null +++ b/analytics-ui/src/types/next-auth.d.ts @@ -0,0 +1,44 @@ +import NextAuth from 'next-auth'; +import { JWT } from 'next-auth/jwt'; + +declare module 'next-auth' { + interface Session { + user: { + id: number; + email: string; + displayName: string; + avatarUrl?: string; + isActive: boolean; + isVerified: boolean; + roles: Array<{ + id: number; + name: string; + permissions: Array<{ + id: number; + name: string; + resource: string; + action: string; + }>; + }>; + }; + } +} + +declare module 'next-auth/jwt' { + interface JWT { + userId: number; + email: string; + displayName: string; + avatarUrl?: string; + roles: Array<{ + id: number; + name: string; + permissions: Array<{ + id: number; + name: string; + resource: string; + action: string; + }>; + }>; + } +} diff --git a/analytics-ui/yarn.lock b/analytics-ui/yarn.lock index 6b4a140..1137e8b 100644 --- a/analytics-ui/yarn.lock +++ b/analytics-ui/yarn.lock @@ -976,6 +976,13 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.20.13": + version: 7.29.2 + resolution: "@babel/runtime@npm:7.29.2" + checksum: 10c0/30b80a0140d16467792e1bbeb06f655b0dab70407da38dfac7fedae9c859f9ae9d846ef14ad77bd3814c064295fe9b1bc551f1541ea14646ae9f22b71a8bc17a + languageName: node + linkType: hard + "@babel/template@npm:^7.28.6, @babel/template@npm:^7.3.3": version: 7.28.6 resolution: "@babel/template@npm:7.28.6" @@ -2682,6 +2689,13 @@ __metadata: languageName: node linkType: hard +"@panva/hkdf@npm:^1.0.2": + version: 1.2.1 + resolution: "@panva/hkdf@npm:1.2.1" + checksum: 10c0/1fabdec9bd2c19b8e88a3fa6fd0c25e25823c5000d9efdf4b6dfe32e9f370f8b9603cf776d120d160bec15fba17e079974cc34f0f52cebb24602cd832dfde19c + languageName: node + linkType: hard + "@peculiar/asn1-schema@npm:^2.3.13, @peculiar/asn1-schema@npm:^2.3.8": version: 2.6.0 resolution: "@peculiar/asn1-schema@npm:2.6.0" @@ -4863,6 +4877,7 @@ __metadata: micro: "npm:^9.4.1" micro-cors: "npm:^0.1.1" next: "npm:14.2.32" + next-auth: "npm:^4.24.13" next-with-less: "npm:^3.0.1" pg: "npm:^8.8.0" pg-cursor: "npm:^2.7.4" @@ -6327,6 +6342,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^0.7.0": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 + languageName: node + linkType: hard + "copy-anything@npm:^2.0.1": version: 2.0.6 resolution: "copy-anything@npm:2.0.6" @@ -10138,7 +10160,7 @@ __metadata: languageName: node linkType: hard -"jose@npm:^4.11.4": +"jose@npm:^4.11.4, jose@npm:^4.15.5, jose@npm:^4.15.9": version: 4.15.9 resolution: "jose@npm:4.15.9" checksum: 10c0/4ed4ddf4a029db04bd167f2215f65d7245e4dc5f36d7ac3c0126aab38d66309a9e692f52df88975d99429e357e5fd8bab340ff20baab544d17684dd1d940a0f4 @@ -11821,6 +11843,34 @@ __metadata: languageName: node linkType: hard +"next-auth@npm:^4.24.13": + version: 4.24.13 + resolution: "next-auth@npm:4.24.13" + dependencies: + "@babel/runtime": "npm:^7.20.13" + "@panva/hkdf": "npm:^1.0.2" + cookie: "npm:^0.7.0" + jose: "npm:^4.15.5" + oauth: "npm:^0.9.15" + openid-client: "npm:^5.4.0" + preact: "npm:^10.6.3" + preact-render-to-string: "npm:^5.1.19" + uuid: "npm:^8.3.2" + peerDependencies: + "@auth/core": 0.34.3 + next: ^12.2.5 || ^13 || ^14 || ^15 || ^16 + nodemailer: ^7.0.7 + react: ^17.0.2 || ^18 || ^19 + react-dom: ^17.0.2 || ^18 || ^19 + peerDependenciesMeta: + "@auth/core": + optional: true + nodemailer: + optional: true + checksum: 10c0/33a03d71951c30007aacfdb589afabd361440c95635068e4270108bfcdd155cf76b0ab3f82fa99d8c747888f250f0e6c675a8c4f9a2d80ad7f5bcacd8542a5c6 + languageName: node + linkType: hard + "next-with-less@npm:^3.0.1": version: 3.0.1 resolution: "next-with-less@npm:3.0.1" @@ -12111,6 +12161,13 @@ __metadata: languageName: node linkType: hard +"oauth@npm:^0.9.15": + version: 0.9.15 + resolution: "oauth@npm:0.9.15" + checksum: 10c0/52204f2a082850efca7e8406e6c6085d89318dc8a85f5a8d6c5594921da36149eb6228bba324af8e2fd9019f084d814ddf835ace6b697ced2b4be0d75f91fb30 + languageName: node + linkType: hard + "object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -12118,6 +12175,13 @@ __metadata: languageName: node linkType: hard +"object-hash@npm:^2.2.0": + version: 2.2.0 + resolution: "object-hash@npm:2.2.0" + checksum: 10c0/1527de843926c5442ed61f8bdddfc7dc181b6497f725b0e89fcf50a55d9c803088763ed447cac85a5aa65345f1e99c2469ba679a54349ef3c4c0aeaa396a3eb9 + languageName: node + linkType: hard + "object-inspect@npm:^1.13.3, object-inspect@npm:^1.13.4": version: 1.13.4 resolution: "object-inspect@npm:1.13.4" @@ -12203,6 +12267,13 @@ __metadata: languageName: node linkType: hard +"oidc-token-hash@npm:^5.0.3": + version: 5.2.0 + resolution: "oidc-token-hash@npm:5.2.0" + checksum: 10c0/4fa9a6f0a27c0b304aa2e4a37e433e871fb2e71418b62ada7305022ed7c70a4b325a81a4ee9661bf0c456cda0acdbeb564e68659bd6fe6a192b20bdec11688ea + languageName: node + linkType: hard + "once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" @@ -12230,6 +12301,18 @@ __metadata: languageName: node linkType: hard +"openid-client@npm:^5.4.0": + version: 5.7.1 + resolution: "openid-client@npm:5.7.1" + dependencies: + jose: "npm:^4.15.9" + lru-cache: "npm:^6.0.0" + object-hash: "npm:^2.2.0" + oidc-token-hash: "npm:^5.0.3" + checksum: 10c0/6aae649758562002eace7574b6eda02be7eddbb0df61eef497ae98b7a4a0ae4c6b09f3f0c1b9b6cb7fcc0c70bbde2576691bf31b870db1f19ab634c1def10bc7 + languageName: node + linkType: hard + "optimism@npm:^0.18.0": version: 0.18.1 resolution: "optimism@npm:0.18.1" @@ -12748,6 +12831,17 @@ __metadata: languageName: node linkType: hard +"preact-render-to-string@npm:^5.1.19": + version: 5.2.6 + resolution: "preact-render-to-string@npm:5.2.6" + dependencies: + pretty-format: "npm:^3.8.0" + peerDependencies: + preact: ">=10" + checksum: 10c0/fb40f952f377900d87d3274e8ede1b59271347f7a3f41ae390aedeb088d162fe15f0a8040272404bd4477551cc2ec83b8a661e2fd3084702498b1543bb08dd11 + languageName: node + linkType: hard + "preact@npm:^10.28.2": version: 10.28.4 resolution: "preact@npm:10.28.4" @@ -12755,6 +12849,13 @@ __metadata: languageName: node linkType: hard +"preact@npm:^10.6.3": + version: 10.29.0 + resolution: "preact@npm:10.29.0" + checksum: 10c0/d111381e5b48335e3a797a03adb83521cf5e9bdf880570fb2eff4fe9da9c82e6dedcbdf54538b1ed8f60bf813a0df0f4891b03dc32140ad93f8f720a8812dd5c + languageName: node + linkType: hard + "prebuild-install@npm:^7.1.1": version: 7.1.3 resolution: "prebuild-install@npm:7.1.3" @@ -12824,6 +12925,13 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^3.8.0": + version: 3.8.0 + resolution: "pretty-format@npm:3.8.0" + checksum: 10c0/69f12937bfb7b2a537a7463b9f875a16322401f1e44d7702d643faa0d21991126c24c093217ef6da403b54c15942a834174fa1c016b72e2cb9edaae6bb3729b6 + languageName: node + linkType: hard + "proc-log@npm:^6.0.0": version: 6.1.0 resolution: "proc-log@npm:6.1.0" @@ -15800,7 +15908,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^8.0.0": +"uuid@npm:^8.0.0, uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" bin: diff --git a/config.yaml b/config.yaml index 946243d..96ce104 100644 --- a/config.yaml +++ b/config.yaml @@ -3,12 +3,14 @@ provider: litellm_llm timeout: 120 models: - alias: default - model: openrouter/z-ai/glm-5 + model: gpt-5-mini context_window_size: 128000 - timeout: 600 kwargs: - max_tokens: 4096 - temperature: 0 + reasoning_effort: minimal + max_completion_tokens: 4096 + n: 1 + seed: 0 + --- type: embedder provider: litellm_embedder @@ -17,6 +19,14 @@ models: alias: default timeout: 120 +--- +type: document_store +provider: qdrant +location: http://qdrant:6333 +embedding_model_dim: 3072 +timeout: 120 +recreate_index: true + --- type: engine provider: analytics_ui @@ -27,14 +37,6 @@ type: engine provider: analytics_ibis endpoint: http://ibis-server:8000 ---- -type: document_store -provider: qdrant -location: http://qdrant:6333 -embedding_model_dim: 3072 -timeout: 120 -recreate_index: true - --- type: pipeline pipes: @@ -72,6 +74,7 @@ pipes: llm: litellm_llm.default - name: relationship_recommendation llm: litellm_llm.default + engine: analytics_ui - name: question_recommendation llm: litellm_llm.default - name: question_recommendation_db_schema_retrieval @@ -82,6 +85,10 @@ pipes: llm: litellm_llm.default engine: analytics_ui document_store: qdrant + - name: chart_generation + llm: litellm_llm.default + - name: chart_adjustment + llm: litellm_llm.default - name: intent_classification llm: litellm_llm.default embedder: litellm_embedder.default @@ -90,6 +97,10 @@ pipes: llm: litellm_llm.default - name: data_assistance llm: litellm_llm.default + - name: sql_pairs_preparation + document_store: qdrant + embedder: litellm_embedder.default + llm: litellm_llm.default - name: sql_pairs_indexing document_store: qdrant embedder: litellm_embedder.default @@ -101,10 +112,6 @@ pipes: llm: litellm_llm.default - name: sql_executor engine: analytics_ui - - name: chart_generation - llm: litellm_llm.default - - name: chart_adjustment - llm: litellm_llm.default - name: user_guide_assistance llm: litellm_llm.default - name: sql_question_generation @@ -129,6 +136,8 @@ pipes: document_store: qdrant - name: sql_tables_extraction llm: litellm_llm.default + - name: sql_diagnosis + llm: litellm_llm.default --- settings: @@ -146,8 +155,8 @@ settings: query_cache_maxsize: 1000 query_cache_ttl: 3600 langfuse_host: https://cloud.langfuse.com - langfuse_enable: true - logging_level: DEBUG + langfuse_enable: false + logging_level: INFO development: false historical_question_retrieval_similarity_threshold: 0.9 sql_pairs_similarity_threshold: 0.7 diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml index 22877c7..6c9f474 100644 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -11,7 +11,10 @@ networks: services: analytics-service: - image: ghcr.io/nexusquantum/analytics-ai-service:latest + build: + context: ./analytics-ai-service + dockerfile: docker/Dockerfile + image: analytics-service:local restart: on-failure platform: linux/amd64 expose: @@ -98,7 +101,10 @@ services: - analytics analytics-ui: - image: ghcr.io/nexusquantum/analytics-ui:latest + build: + context: ./analytics-ui + dockerfile: Dockerfile + image: analytics-ui:local restart: on-failure platform: linux/amd64 hostname: analytics-ui diff --git a/docker-compose.yaml b/docker-compose.yaml index 02ac22a..ce170b3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -118,6 +118,19 @@ services: LICENSE_FILE_PATH: ${LICENSE_FILE_PATH:-} LICENSE_GRACE_PERIOD_DAYS: ${LICENSE_GRACE_PERIOD_DAYS:-7} LICENSE_PUBLIC_KEY: ${LICENSE_PUBLIC_KEY:-} + # NextAuth + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-} + NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:13000} + JWT_SECRET: ${JWT_SECRET:-} + # Keycloak / NQRust Identity SSO + KEYCLOAK_OAUTH_ENABLED: ${KEYCLOAK_OAUTH_ENABLED:-false} + KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-} + KEYCLOAK_URL: ${KEYCLOAK_URL:-} + KEYCLOAK_REALM: ${KEYCLOAK_REALM:-master} + KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-} + KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET:-} + KEYCLOAK_DEFAULT_ROLE: ${KEYCLOAK_DEFAULT_ROLE:-viewer} + KEYCLOAK_AUTO_REGISTER: ${KEYCLOAK_AUTO_REGISTER:-true} extra_hosts: - "host.docker.internal:host-gateway" ports: