From 09397417457e4768175687cfebb22350b2326fe3 Mon Sep 17 00:00:00 2001 From: Jordan Dalby Date: Sun, 3 Nov 2024 22:51:16 +0000 Subject: [PATCH] Added authentication with JWT tokens, including a few new environment variables for configuration --- client/src/App.tsx | 32 ++++- client/src/api/auth.ts | 108 +++++++++++++++++ client/src/api/snippets.ts | 85 ++++++++----- client/src/components/auth/LoginPage.tsx | 84 +++++++++++++ .../components/snippets/SnippetStorage.tsx | 20 +++- client/src/context/AuthContext.tsx | 83 +++++++++++++ client/src/hooks/useSnippets.ts | 27 ++++- docker-compose.yaml | 11 +- package-lock.json | 113 ++++++++++++++++++ server/package.json | 1 + server/src/app.js | 8 +- server/src/middleware/auth.js | 38 ++++++ server/src/routes/authRoutes.js | 46 +++++++ 13 files changed, 609 insertions(+), 47 deletions(-) create mode 100644 client/src/api/auth.ts create mode 100644 client/src/components/auth/LoginPage.tsx create mode 100644 client/src/context/AuthContext.tsx create mode 100644 server/src/middleware/auth.js create mode 100644 server/src/routes/authRoutes.js diff --git a/client/src/App.tsx b/client/src/App.tsx index 31afa28..5837f77 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,14 +1,36 @@ import React from 'react'; import SnippetStorage from './components/snippets/SnippetStorage'; import { ToastProvider } from './components/toast/Toast'; +import { AuthProvider, useAuth } from './context/AuthContext'; +import LoginPage from './components/auth/LoginPage'; + +const AuthenticatedApp: React.FC = () => { + const { isAuthenticated, isAuthRequired, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (isAuthRequired && !isAuthenticated) { + return ; + } + + return ; +}; const App: React.FC = () => { return ( - -
- -
-
+ + +
+ +
+
+
); }; diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts new file mode 100644 index 0000000..a62d630 --- /dev/null +++ b/client/src/api/auth.ts @@ -0,0 +1,108 @@ +interface AuthConfig { + authRequired: boolean; + } + + interface LoginResponse { + token: string; + } + + declare global { + interface Window { + BASE_PATH?: string; + } + } + + const getBasePath = (): string => { + return window?.BASE_PATH?.endsWith('/') ? window.BASE_PATH.slice(0, -1) : window.BASE_PATH || ''; + }; + + const BASE_PATH = getBasePath(); + export const AUTH_API_URL = `${BASE_PATH}/api/auth`; + + interface ApiError extends Error { + status?: number; + } + + export const AUTH_ERROR_EVENT = 'bytestash:auth_error'; + export const authErrorEvent = new CustomEvent(AUTH_ERROR_EVENT); + + const handleResponse = async (response: Response) => { + if (response.status === 401 || response.status === 403) { + window.dispatchEvent(authErrorEvent); + + const error = new Error('Authentication required') as ApiError; + error.status = response.status; + throw error; + } + + if (!response.ok) { + const text = await response.text(); + console.error('Error response body:', text); + const error = new Error(`Request failed: ${response.status} ${response.statusText}`) as ApiError; + error.status = response.status; + throw error; + } + + return response; + }; + + const getHeaders = (includeAuth = false) => { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (includeAuth) { + const token = localStorage.getItem('token'); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + } + + return headers; + }; + + export const getAuthConfig = async (): Promise => { + try { + const response = await fetch(`${AUTH_API_URL}/config`); + await handleResponse(response); + return response.json(); + } catch (error) { + console.error('Error fetching auth config:', error); + throw error; + } + }; + + export const verifyToken = async (): Promise => { + try { + const response = await fetch(`${AUTH_API_URL}/verify`, { + headers: getHeaders(true) + }); + + if (response.status === 401 || response.status === 403) { + return false; + } + + const data = await response.json(); + return data.valid; + } catch (error) { + console.error('Error verifying token:', error); + return false; + } + }; + + export const login = async (username: string, password: string): Promise => { + try { + const response = await fetch(`${AUTH_API_URL}/login`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify({ username, password }) + }); + + await handleResponse(response); + const data: LoginResponse = await response.json(); + return data.token; + } catch (error) { + console.error('Error logging in:', error); + throw error; + } + }; \ No newline at end of file diff --git a/client/src/api/snippets.ts b/client/src/api/snippets.ts index 50005d2..3612720 100644 --- a/client/src/api/snippets.ts +++ b/client/src/api/snippets.ts @@ -11,24 +11,59 @@ const getBasePath = (): string => { }; const BASE_PATH = getBasePath(); - export const API_URL = `${BASE_PATH}/api/snippets`; +interface ApiError extends Error { + status?: number; +} + +export const AUTH_ERROR_EVENT = 'bytestash:auth_error'; +export const authErrorEvent = new CustomEvent(AUTH_ERROR_EVENT); + +const handleResponse = async (response: Response) => { + if (response.status === 401 || response.status === 403) { + window.dispatchEvent(authErrorEvent); + + const error = new Error('Authentication required') as ApiError; + error.status = response.status; + throw error; + } + + if (!response.ok) { + const text = await response.text(); + console.error('Error response body:', text); + const error = new Error(`Request failed: ${response.status} ${response.statusText}`) as ApiError; + error.status = response.status; + throw error; + } + + return response; +}; + +const getHeaders = () => { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + const token = localStorage.getItem('token'); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + return headers; +}; + export const fetchSnippets = async (): Promise => { try { - const response = await fetch(API_URL); - - if (!response.ok) { - const text = await response.text(); - console.error('Error response body:', text); - throw new Error(`Failed to fetch snippets: ${response.status} ${response.statusText}`); - } + const response = await fetch(API_URL, { + headers: getHeaders() + }); + await handleResponse(response); const text = await response.text(); try { - const data = JSON.parse(text); - return data; + return JSON.parse(text); } catch (e) { console.error('Failed to parse JSON:', e); console.error('Full response:', text); @@ -44,18 +79,12 @@ export const createSnippet = async (snippet: Omit) try { const response = await fetch(API_URL, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: getHeaders(), body: JSON.stringify(snippet), }); - if (!response.ok) { - throw new Error('Failed to create snippet'); - } - - const createdSnippet = await response.json(); - return createdSnippet; + await handleResponse(response); + return response.json(); } catch (error) { console.error('Error creating snippet:', error); throw error; @@ -66,12 +95,10 @@ export const deleteSnippet = async (id: string): Promise => { try { const response = await fetch(`${API_URL}/${id}`, { method: 'DELETE', + headers: getHeaders(), }); - if (!response.ok) { - throw new Error('Failed to delete snippet'); - } - + await handleResponse(response); await response.json(); return id; } catch (error) { @@ -84,18 +111,12 @@ export const editSnippet = async (id: string, snippet: Omit { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const { login: setAuth } = useAuth(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(''); + + try { + const token = await login(username, password); + setAuth(token); + } catch (err) { + setError('Invalid username or password'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+

+ ByteStash +

+

+ Please sign in to continue +

+
+
+ {error && ( +
+ {error} +
+ )} +
+
+ setUsername(e.target.value)} + disabled={isLoading} + /> +
+
+ setPassword(e.target.value)} + disabled={isLoading} + /> +
+
+ +
+ +
+
+
+
+ ); +}; + +export default LoginPage; \ No newline at end of file diff --git a/client/src/components/snippets/SnippetStorage.tsx b/client/src/components/snippets/SnippetStorage.tsx index 3b8c1bf..013f080 100644 --- a/client/src/components/snippets/SnippetStorage.tsx +++ b/client/src/components/snippets/SnippetStorage.tsx @@ -1,4 +1,5 @@ import React, { useState, useMemo, useCallback, useEffect } from 'react'; +import { LogOut } from 'lucide-react'; import SearchAndFilter from './SearchAndFilter'; import SnippetList from './SnippetList'; import SnippetModal from './SnippetModal'; @@ -6,12 +7,14 @@ import EditSnippetModal from './EditSnippetModal'; import SettingsModal from '../settings/SettingsModal'; import { useSnippets } from '../../hooks/useSnippets'; import { useSettings } from '../../hooks/useSettings'; +import { useAuth } from '../../context/AuthContext'; import { getLanguageLabel } from '../../utils/languageUtils'; import { Snippet } from '../../types/types'; import { initializeMonaco } from '../../utils/languageUtils'; const SnippetStorage: React.FC = () => { const { snippets, isLoading, addSnippet, updateSnippet, removeSnippet } = useSnippets(); + const { logout, isAuthRequired } = useAuth(); const { viewMode, setViewMode, compactView, showCodePreview, previewLines, includeCodeInSearch, updateSettings, @@ -44,6 +47,10 @@ const SnippetStorage: React.FC = () => { }); }, []); + const handleLogout = () => { + logout(); + }; + const languages = useMemo(() => [...new Set(snippets.map(snippet => getLanguageLabel(snippet.language)))], [snippets] @@ -115,7 +122,18 @@ const SnippetStorage: React.FC = () => { return (
-

ByteStash

+
+

ByteStash

+ {isAuthRequired && ( + + )} +
void; + logout: () => void; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isAuthRequired, setIsAuthRequired] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const handleAuthError = () => { + localStorage.removeItem('token'); + setIsAuthenticated(false); + }; + + window.addEventListener(AUTH_ERROR_EVENT, handleAuthError); + + return () => { + window.removeEventListener(AUTH_ERROR_EVENT, handleAuthError); + }; + }, []); + + useEffect(() => { + const initializeAuth = async () => { + try { + const config = await getAuthConfig(); + setIsAuthRequired(config.authRequired); + + const token = localStorage.getItem('token'); + if (token && config.authRequired) { + const isValid = await verifyToken(); + setIsAuthenticated(isValid); + if (!isValid) { + localStorage.removeItem('token'); + } + } else { + setIsAuthenticated(false); + } + } catch (error) { + console.error('Auth initialization error:', error); + setIsAuthRequired(false); + setIsAuthenticated(false); + } finally { + setIsLoading(false); + } + }; + + initializeAuth(); + }, []); + + const login = (token: string) => { + localStorage.setItem('token', token); + setIsAuthenticated(true); + }; + + const logout = () => { + localStorage.removeItem('token'); + setIsAuthenticated(false); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; \ No newline at end of file diff --git a/client/src/hooks/useSnippets.ts b/client/src/hooks/useSnippets.ts index 1f45f2e..d5b578b 100644 --- a/client/src/hooks/useSnippets.ts +++ b/client/src/hooks/useSnippets.ts @@ -1,33 +1,45 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { fetchSnippets, createSnippet, editSnippet, deleteSnippet } from '../api/snippets'; import { useToast } from '../components/toast/Toast'; +import { useAuth } from '../context/AuthContext'; import { Snippet } from '../types/types'; export const useSnippets = () => { const [snippets, setSnippets] = useState([]); const [isLoading, setIsLoading] = useState(true); const { addToast } = useToast(); + const { logout } = useAuth(); const hasLoadedRef = useRef(false); + const handleAuthError = (error: any) => { + if (error.status === 401 || error.status === 403) { + logout(); + addToast('Session expired. Please login again.', 'error'); + } + }; + const loadSnippets = useCallback(async () => { if (!isLoading || hasLoadedRef.current) return; try { const fetchedSnippets = await fetchSnippets(); - const sortedSnippets = fetchedSnippets.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); + const sortedSnippets = fetchedSnippets.sort((a, b) => + new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() + ); setSnippets(sortedSnippets); if (!hasLoadedRef.current) { addToast('Snippets loaded successfully', 'success'); hasLoadedRef.current = true; } - } catch (error) { + } catch (error: any) { console.error('Failed to fetch snippets:', error); + handleAuthError(error); addToast('Failed to load snippets. Please try again.', 'error'); } finally { setIsLoading(false); } - }, [isLoading, addToast]); + }, [isLoading, addToast, logout]); useEffect(() => { loadSnippets(); @@ -45,8 +57,9 @@ export const useSnippets = () => { setSnippets(prevSnippets => [...prevSnippets, newSnippet]); addToast('New snippet created successfully', 'success'); return newSnippet; - } catch (error) { + } catch (error: any) { console.error('Error creating snippet:', error); + handleAuthError(error); addToast('Failed to create snippet', 'error'); throw error; } @@ -60,8 +73,9 @@ export const useSnippets = () => { ); addToast('Snippet updated successfully', 'success'); return updatedSnippet; - } catch (error) { + } catch (error: any) { console.error('Error updating snippet:', error); + handleAuthError(error); addToast('Failed to update snippet', 'error'); throw error; } @@ -75,8 +89,9 @@ export const useSnippets = () => { return updatedSnippets; }); addToast('Snippet deleted successfully', 'success'); - } catch (error) { + } catch (error: any) { console.error('Failed to delete snippet:', error); + handleAuthError(error); addToast('Failed to delete snippet. Please try again.', 'error'); throw error; } diff --git a/docker-compose.yaml b/docker-compose.yaml index fb37774..bdf037e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,6 +7,15 @@ services: - "5000:5000" environment: - PORT=5000 - - BASE_PATH= + - BASE_PATH=/bytestash + # if auth username or password are left blank then authorisation is disabled + # the username used for logging in + - AUTH_USERNAME=bytestash + # the password used for logging in + - AUTH_PASSWORD=password + # the jwt secret used by the server, make sure to generate your own secret token to replace this one + - JWT_SECRET=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.nhan23TF0qyO4l4rDMkJ8ebNLMgV62NGfBozt9huymA + # how long the token lasts, examples: "2 days", "10h", "7d", "1m", "60s" + - TOKEN_EXPIRY=24h volumes: - ./data:/data/snippets \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0c62f48..156e102 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2865,6 +2865,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3845,6 +3851,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6108,6 +6123,61 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6165,6 +6235,48 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -10379,6 +10491,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", + "jsonwebtoken": "^9.0.2", "openai": "^4.67.3" }, "devDependencies": { diff --git a/server/package.json b/server/package.json index 1cb527d..4e69e1f 100644 --- a/server/package.json +++ b/server/package.json @@ -16,6 +16,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", + "jsonwebtoken": "^9.0.2", "openai": "^4.67.3" }, "devDependencies": { diff --git a/server/src/app.js b/server/src/app.js index 5af30d3..d88ec54 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -4,6 +4,8 @@ const path = require('path'); const fs = require('fs'); const { initializeDatabase } = require('./config/database'); const snippetRoutes = require('./routes/snippetRoutes'); +const authRoutes = require('./routes/authRoutes'); +const { authenticateToken } = require('./middleware/auth'); const expressApp = express(); const port = process.env.PORT || 5000; @@ -27,14 +29,16 @@ function app(server) { const normalizedBasePath = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath; server.get(normalizedBasePath, injectBasePath); server.use(express.static(path.join(__dirname, '../../client/build'))); - server.use(`${normalizedBasePath}/api/snippets`, snippetRoutes); + server.use(`${normalizedBasePath}/api/auth`, authRoutes); + server.use(`${normalizedBasePath}/api/snippets`, authenticateToken, snippetRoutes); server.get(`${normalizedBasePath}/*`, injectBasePath); server.get('/', (req, res) => { res.redirect(normalizedBasePath); }); } else { server.use(express.static(path.join(__dirname, '../../client/build'))); - server.use('/api/snippets', snippetRoutes); + server.use('/api/auth', authRoutes); + server.use('/api/snippets', authenticateToken, snippetRoutes); server.get('*', injectBasePath); } } diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js new file mode 100644 index 0000000..08701db --- /dev/null +++ b/server/src/middleware/auth.js @@ -0,0 +1,38 @@ +const jwt = require('jsonwebtoken'); + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; +const AUTH_USERNAME = process.env.AUTH_USERNAME; +const AUTH_PASSWORD = process.env.AUTH_PASSWORD; +const TOKEN_EXPIRY = process.env.TOKEN_EXPIRY || '24h'; + +const authRequired = !!(AUTH_USERNAME && AUTH_PASSWORD); + +const authenticateToken = (req, res, next) => { + if (!authRequired) { + return next(); + } + + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ error: 'Authentication required' }); + } + + jwt.verify(token, JWT_SECRET, (err, user) => { + if (err) { + return res.status(403).json({ error: 'Invalid token' }); + } + req.user = user; + next(); + }); +}; + +module.exports = { + authenticateToken, + authRequired, + JWT_SECRET, + AUTH_USERNAME, + AUTH_PASSWORD, + TOKEN_EXPIRY +}; \ No newline at end of file diff --git a/server/src/routes/authRoutes.js b/server/src/routes/authRoutes.js new file mode 100644 index 0000000..0fb92c7 --- /dev/null +++ b/server/src/routes/authRoutes.js @@ -0,0 +1,46 @@ +const express = require('express'); +const jwt = require('jsonwebtoken'); +const { JWT_SECRET, AUTH_USERNAME, AUTH_PASSWORD, authRequired, TOKEN_EXPIRY } = require('../middleware/auth'); + +const router = express.Router(); + +router.get('/config', (req, res) => { + res.json({ authRequired }); +}); + +router.get('/verify', (req, res) => { + if (!authRequired) { + return res.status(200).json({ valid: true }); + } + + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ valid: false }); + } + + try { + jwt.verify(token, JWT_SECRET); + res.status(200).json({ valid: true }); + } catch (err) { + res.status(401).json({ valid: false }); + } +}); + +router.post('/login', (req, res) => { + if (!authRequired) { + return res.status(400).json({ error: 'Authentication is not required' }); + } + + const { username, password } = req.body; + + if (username === AUTH_USERNAME && password === AUTH_PASSWORD) { + const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }); + res.json({ token }); + } else { + res.status(401).json({ error: 'Invalid credentials' }); + } +}); + +module.exports = router; \ No newline at end of file