Add OpenID Connect authentication and SQLite database with user preferences and favorites system
This commit is contained in:
@@ -11,9 +11,14 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"express": "^4.18.0",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.1.0"
|
||||
"helmet": "^6.0.0",
|
||||
"sqlite3": "^5.1.6",
|
||||
"express-session": "^1.17.3",
|
||||
"passport": "^0.6.0",
|
||||
"passport-openidconnect": "^0.1.1",
|
||||
"bcrypt": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
|
||||
156
backend/src/auth.js
Normal file
156
backend/src/auth.js
Normal file
@@ -0,0 +1,156 @@
|
||||
const passport = require('passport');
|
||||
const OpenIDConnectStrategy = require('passport-openidconnect').Strategy;
|
||||
const { userOps } = require('./database');
|
||||
|
||||
// Configure OpenID Connect strategy
|
||||
function configureAuth(app) {
|
||||
// Check if OIDC is configured
|
||||
const isOIDCConfigured = process.env.OIDC_ISSUER &&
|
||||
process.env.OIDC_CLIENT_ID &&
|
||||
process.env.OIDC_CLIENT_SECRET &&
|
||||
process.env.OIDC_AUTH_URL &&
|
||||
process.env.OIDC_TOKEN_URL &&
|
||||
process.env.OIDC_USERINFO_URL;
|
||||
|
||||
if (!isOIDCConfigured) {
|
||||
console.log('OpenID Connect not configured. Authentication features disabled.');
|
||||
console.log('To enable authentication, set the following environment variables:');
|
||||
console.log('- OIDC_ISSUER');
|
||||
console.log('- OIDC_CLIENT_ID');
|
||||
console.log('- OIDC_CLIENT_SECRET');
|
||||
console.log('- OIDC_AUTH_URL');
|
||||
console.log('- OIDC_TOKEN_URL');
|
||||
console.log('- OIDC_USERINFO_URL');
|
||||
console.log('- OIDC_CALLBACK_URL (optional, defaults to /auth/callback)');
|
||||
|
||||
// Add disabled auth routes
|
||||
app.get('/auth/login', (req, res) => {
|
||||
res.status(501).json({ error: 'Authentication not configured' });
|
||||
});
|
||||
|
||||
app.get('/auth/user', (req, res) => {
|
||||
res.status(401).json({ error: 'Authentication not configured' });
|
||||
});
|
||||
|
||||
app.post('/auth/logout', (req, res) => {
|
||||
res.status(501).json({ error: 'Authentication not configured' });
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Session configuration
|
||||
const session = require('express-session');
|
||||
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'your-secret-key-change-in-production',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||
}
|
||||
}));
|
||||
|
||||
// Initialize Passport
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
// Configure OpenID Connect strategy
|
||||
passport.use('oidc', new OpenIDConnectStrategy({
|
||||
issuer: process.env.OIDC_ISSUER,
|
||||
authorizationURL: process.env.OIDC_AUTH_URL,
|
||||
tokenURL: process.env.OIDC_TOKEN_URL,
|
||||
userInfoURL: process.env.OIDC_USERINFO_URL,
|
||||
clientID: process.env.OIDC_CLIENT_ID,
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||
callbackURL: process.env.OIDC_CALLBACK_URL || '/auth/callback',
|
||||
scope: 'openid email profile'
|
||||
}, (issuer, sub, profile, accessToken, refreshToken, done) => {
|
||||
// Extract user info from profile
|
||||
const userProfile = {
|
||||
sub: profile.id,
|
||||
email: profile.emails?.[0]?.value,
|
||||
name: profile.displayName
|
||||
};
|
||||
|
||||
userOps.findOrCreateUser(userProfile, (err, user) => {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
return done(null, user);
|
||||
});
|
||||
}));
|
||||
|
||||
// Serialize user for session
|
||||
passport.serializeUser((user, done) => {
|
||||
done(null, user.id);
|
||||
});
|
||||
|
||||
// Deserialize user from session
|
||||
passport.deserializeUser((id, done) => {
|
||||
userOps.findById(id, (err, user) => {
|
||||
done(err, user);
|
||||
});
|
||||
});
|
||||
|
||||
// Auth routes
|
||||
app.get('/auth/login', passport.authenticate('oidc'));
|
||||
|
||||
app.get('/auth/callback',
|
||||
passport.authenticate('oidc', { failureRedirect: '/login' }),
|
||||
(req, res) => {
|
||||
// Successful authentication, redirect to frontend
|
||||
res.redirect(process.env.FRONTEND_URL || '/');
|
||||
}
|
||||
);
|
||||
|
||||
app.post('/auth/logout', (req, res) => {
|
||||
req.logout((err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Session destruction failed' });
|
||||
}
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Get current user
|
||||
app.get('/auth/user', (req, res) => {
|
||||
if (req.isAuthenticated()) {
|
||||
res.json({
|
||||
user: {
|
||||
id: req.user.id,
|
||||
email: req.user.email,
|
||||
name: req.user.name
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Middleware to require authentication
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.isAuthenticated()) {
|
||||
return next();
|
||||
}
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// Middleware to optionally include user info
|
||||
function optionalAuth(req, res, next) {
|
||||
// Always proceed, but user info will be available if authenticated
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configureAuth,
|
||||
requireAuth,
|
||||
optionalAuth
|
||||
};
|
||||
200
backend/src/database.js
Normal file
200
backend/src/database.js
Normal file
@@ -0,0 +1,200 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
// Database file path
|
||||
const DB_PATH = path.join(__dirname, '../data/bible.db');
|
||||
|
||||
// Initialize database
|
||||
const db = new sqlite3.Database(DB_PATH, (err) => {
|
||||
if (err) {
|
||||
console.error('Error opening database:', err.message);
|
||||
} else {
|
||||
console.log('Connected to SQLite database');
|
||||
initializeTables();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize database tables
|
||||
function initializeTables() {
|
||||
// Users table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
openid_sub TEXT UNIQUE NOT NULL,
|
||||
email TEXT,
|
||||
name TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// User preferences table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
font_size TEXT DEFAULT 'medium' CHECK(font_size IN ('small', 'medium', 'large')),
|
||||
dark_mode BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Favorites table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
book TEXT NOT NULL,
|
||||
chapter TEXT NOT NULL,
|
||||
verse_start INTEGER,
|
||||
verse_end INTEGER,
|
||||
note TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id, book, chapter, verse_start, verse_end)
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('Database tables initialized');
|
||||
}
|
||||
|
||||
// User operations
|
||||
const userOps = {
|
||||
// Find or create user by OpenID Connect subject
|
||||
findOrCreateUser: (profile, callback) => {
|
||||
const { sub, email, name } = profile;
|
||||
|
||||
db.get(
|
||||
'SELECT * FROM users WHERE openid_sub = ?',
|
||||
[sub],
|
||||
(err, user) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
// Update user info
|
||||
db.run(
|
||||
'UPDATE users SET email = ?, name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[email, name, user.id],
|
||||
(err) => {
|
||||
if (err) return callback(err);
|
||||
callback(null, user);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Create new user
|
||||
db.run(
|
||||
'INSERT INTO users (openid_sub, email, name) VALUES (?, ?, ?)',
|
||||
[sub, email, name],
|
||||
function(err) {
|
||||
if (err) return callback(err);
|
||||
|
||||
const newUser = {
|
||||
id: this.lastID,
|
||||
openid_sub: sub,
|
||||
email,
|
||||
name
|
||||
};
|
||||
|
||||
// Create default preferences
|
||||
db.run(
|
||||
'INSERT INTO user_preferences (user_id) VALUES (?)',
|
||||
[newUser.id],
|
||||
(err) => {
|
||||
if (err) console.error('Error creating default preferences:', err);
|
||||
callback(null, newUser);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
// Find user by ID
|
||||
findById: (id, callback) => {
|
||||
db.get('SELECT * FROM users WHERE id = ?', [id], callback);
|
||||
}
|
||||
};
|
||||
|
||||
// Preferences operations
|
||||
const preferencesOps = {
|
||||
// Get user preferences
|
||||
getPreferences: (userId, callback) => {
|
||||
db.get(
|
||||
'SELECT * FROM user_preferences WHERE user_id = ?',
|
||||
[userId],
|
||||
callback
|
||||
);
|
||||
},
|
||||
|
||||
// Update user preferences
|
||||
updatePreferences: (userId, preferences, callback) => {
|
||||
const { font_size, dark_mode } = preferences;
|
||||
|
||||
db.run(
|
||||
`UPDATE user_preferences
|
||||
SET font_size = ?, dark_mode = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = ?`,
|
||||
[font_size, dark_mode ? 1 : 0, userId],
|
||||
callback
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Favorites operations
|
||||
const favoritesOps = {
|
||||
// Get user favorites
|
||||
getFavorites: (userId, callback) => {
|
||||
db.all(
|
||||
'SELECT * FROM favorites WHERE user_id = ? ORDER BY created_at DESC',
|
||||
[userId],
|
||||
callback
|
||||
);
|
||||
},
|
||||
|
||||
// Add favorite
|
||||
addFavorite: (userId, favorite, callback) => {
|
||||
const { book, chapter, verse_start, verse_end, note } = favorite;
|
||||
|
||||
db.run(
|
||||
`INSERT INTO favorites (user_id, book, chapter, verse_start, verse_end, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[userId, book, chapter, verse_start, verse_end, note],
|
||||
callback
|
||||
);
|
||||
},
|
||||
|
||||
// Remove favorite
|
||||
removeFavorite: (userId, favoriteId, callback) => {
|
||||
db.run(
|
||||
'DELETE FROM favorites WHERE id = ? AND user_id = ?',
|
||||
[favoriteId, userId],
|
||||
callback
|
||||
);
|
||||
},
|
||||
|
||||
// Check if verse is favorited
|
||||
isFavorited: (userId, book, chapter, verse_start, verse_end, callback) => {
|
||||
db.get(
|
||||
'SELECT id FROM favorites WHERE user_id = ? AND book = ? AND chapter = ? AND verse_start = ? AND verse_end = ?',
|
||||
[userId, book, chapter, verse_start, verse_end],
|
||||
(err, row) => {
|
||||
if (err) return callback(err);
|
||||
callback(null, !!row);
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
db,
|
||||
userOps,
|
||||
preferencesOps,
|
||||
favoritesOps
|
||||
};
|
||||
@@ -3,6 +3,8 @@ const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { configureAuth, requireAuth, optionalAuth } = require('./auth');
|
||||
const { preferencesOps, favoritesOps } = require('./database');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -14,9 +16,15 @@ app.use(helmet({
|
||||
crossOriginEmbedderPolicy: false,
|
||||
originAgentCluster: false
|
||||
}));
|
||||
app.use(cors());
|
||||
app.use(cors({
|
||||
origin: process.env.FRONTEND_URL || true,
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
// Configure authentication
|
||||
configureAuth(app);
|
||||
|
||||
// Serve static files from the React build
|
||||
app.use(express.static(path.join(__dirname, '../../frontend/build')));
|
||||
|
||||
@@ -120,6 +128,107 @@ app.get('/books/:book/:chapter', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// User preferences routes
|
||||
app.get('/api/preferences', requireAuth, (req, res) => {
|
||||
preferencesOps.getPreferences(req.user.id, (err, preferences) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Failed to get preferences' });
|
||||
}
|
||||
res.json({
|
||||
font_size: preferences?.font_size || 'medium',
|
||||
dark_mode: !!preferences?.dark_mode
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/preferences', requireAuth, (req, res) => {
|
||||
const { font_size, dark_mode } = req.body;
|
||||
|
||||
// Validate font_size
|
||||
if (font_size && !['small', 'medium', 'large'].includes(font_size)) {
|
||||
return res.status(400).json({ error: 'Invalid font_size' });
|
||||
}
|
||||
|
||||
preferencesOps.updatePreferences(req.user.id, { font_size, dark_mode }, (err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Failed to update preferences' });
|
||||
}
|
||||
res.json({ message: 'Preferences updated successfully' });
|
||||
});
|
||||
});
|
||||
|
||||
// Favorites routes
|
||||
app.get('/api/favorites', requireAuth, (req, res) => {
|
||||
favoritesOps.getFavorites(req.user.id, (err, favorites) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Failed to get favorites' });
|
||||
}
|
||||
res.json({ favorites });
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/favorites', requireAuth, (req, res) => {
|
||||
const { book, chapter, verse_start, verse_end, note } = req.body;
|
||||
|
||||
if (!book || !chapter) {
|
||||
return res.status(400).json({ error: 'Book and chapter are required' });
|
||||
}
|
||||
|
||||
const favorite = {
|
||||
book,
|
||||
chapter,
|
||||
verse_start: verse_start || null,
|
||||
verse_end: verse_end || null,
|
||||
note: note || null
|
||||
};
|
||||
|
||||
favoritesOps.addFavorite(req.user.id, favorite, function(err) {
|
||||
if (err) {
|
||||
if (err.code === 'SQLITE_CONSTRAINT') {
|
||||
return res.status(409).json({ error: 'Favorite already exists' });
|
||||
}
|
||||
return res.status(500).json({ error: 'Failed to add favorite' });
|
||||
}
|
||||
res.json({
|
||||
message: 'Favorite added successfully',
|
||||
id: this.lastID
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/favorites/:id', requireAuth, (req, res) => {
|
||||
const favoriteId = req.params.id;
|
||||
|
||||
favoritesOps.removeFavorite(req.user.id, favoriteId, (err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Failed to remove favorite' });
|
||||
}
|
||||
res.json({ message: 'Favorite removed successfully' });
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/favorites/check', requireAuth, (req, res) => {
|
||||
const { book, chapter, verse_start, verse_end } = req.query;
|
||||
|
||||
if (!book || !chapter) {
|
||||
return res.status(400).json({ error: 'Book and chapter are required' });
|
||||
}
|
||||
|
||||
favoritesOps.isFavorited(
|
||||
req.user.id,
|
||||
book,
|
||||
chapter,
|
||||
verse_start || null,
|
||||
verse_end || null,
|
||||
(err, isFavorited) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Failed to check favorite status' });
|
||||
}
|
||||
res.json({ isFavorited });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Catch-all handler: send back React's index.html for client-side routing
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../../frontend/build/index.html'));
|
||||
|
||||
Reference in New Issue
Block a user