Add OpenID Connect authentication and SQLite database with user preferences and favorites system

This commit is contained in:
Ryderjj89
2025-09-13 16:50:26 -04:00
parent 9beff1e610
commit a804430536
5 changed files with 631 additions and 71 deletions

View File

@@ -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
View 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
View 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
};

View File

@@ -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'));