feat: auth
This commit is contained in:
217
server.js
217
server.js
@@ -1,5 +1,9 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const session = require('express-session');
|
||||
const passport = require('passport');
|
||||
const LdapStrategy = require('passport-ldapauth').Strategy;
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const axios = require('axios');
|
||||
@@ -66,11 +70,126 @@ const PORT = process.env.PORT || 3000;
|
||||
const DATA_FILE = path.join(__dirname, 'data', 'links.json');
|
||||
const LISTS_FILE = path.join(__dirname, 'data', 'lists.json');
|
||||
|
||||
// Trust proxy - required when behind reverse proxy (Traefik)
|
||||
// This allows Express to trust X-Forwarded-* headers
|
||||
app.set('trust proxy', process.env.TRUST_PROXY !== 'false'); // Default to true, set to 'false' to disable
|
||||
|
||||
// Session configuration
|
||||
const isSecure = process.env.COOKIE_SECURE === 'true' ||
|
||||
(process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production');
|
||||
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'your-secret-key-change-this-in-production',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
name: process.env.SESSION_NAME || 'connect.sid', // Custom session name to avoid conflicts
|
||||
cookie: {
|
||||
secure: isSecure, // Use secure cookies when behind HTTPS proxy
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
sameSite: process.env.COOKIE_SAMESITE || (isSecure ? 'none' : 'lax'), // 'none' for cross-site, 'lax' for same-site
|
||||
domain: process.env.COOKIE_DOMAIN || undefined, // Set if cookies need to be shared across subdomains
|
||||
path: process.env.COOKIE_PATH || '/' // Cookie path
|
||||
}
|
||||
}));
|
||||
|
||||
// Initialize Passport
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
// Configure LDAP Strategy
|
||||
// Combine base DN with additional users DN if provided
|
||||
const baseDN = process.env.LDAP_BASE_DN;
|
||||
const additionalUsersDN = process.env.LDAP_ADDITIONAL_USERS_DN || '';
|
||||
const searchBase = additionalUsersDN && baseDN
|
||||
? `${additionalUsersDN},${baseDN}`
|
||||
: baseDN;
|
||||
|
||||
const searchAttributes = [];
|
||||
if (process.env.LDAP_ATTRIBUTE_USERNAME) {
|
||||
searchAttributes.push(process.env.LDAP_ATTRIBUTE_USERNAME);
|
||||
}
|
||||
if (process.env.LDAP_ATTRIBUTE_MAIL) {
|
||||
searchAttributes.push(process.env.LDAP_ATTRIBUTE_MAIL);
|
||||
}
|
||||
if (process.env.LDAP_ATTRIBUTE_DISTINGUISHED_NAME) {
|
||||
searchAttributes.push(process.env.LDAP_ATTRIBUTE_DISTINGUISHED_NAME);
|
||||
}
|
||||
if (process.env.LDAP_ATTRIBUTE_MEMBER_OF) {
|
||||
searchAttributes.push(process.env.LDAP_ATTRIBUTE_MEMBER_OF);
|
||||
}
|
||||
|
||||
const ldapOptions = {
|
||||
server: {
|
||||
url: process.env.LDAP_ADDRESS,
|
||||
bindDN: process.env.LDAP_USER,
|
||||
bindCredentials: process.env.LDAP_PASSWORD,
|
||||
searchBase: searchBase,
|
||||
searchFilter: process.env.LDAP_USERS_FILTER,
|
||||
searchAttributes: searchAttributes.length > 0 ? searchAttributes : undefined,
|
||||
timeout: process.env.LDAP_TIMEOUT ? parseInt(process.env.LDAP_TIMEOUT) : undefined,
|
||||
connectTimeout: process.env.LDAP_TIMEOUT ? parseInt(process.env.LDAP_TIMEOUT) : undefined,
|
||||
tlsOptions: {
|
||||
rejectUnauthorized: process.env.LDAP_TLS_SKIP_VERIFY !== 'true',
|
||||
servername: process.env.LDAP_TLS_SERVER_NAME || undefined
|
||||
}
|
||||
},
|
||||
usernameField: 'username',
|
||||
passwordField: 'password'
|
||||
};
|
||||
|
||||
// Replace {username} placeholder in search filter
|
||||
if (ldapOptions.server.searchFilter && ldapOptions.server.searchFilter.includes('{{username}}')) {
|
||||
// Keep as is, passport-ldapauth will replace it
|
||||
} else if (ldapOptions.server.searchFilter && ldapOptions.server.searchFilter.includes('{username_attribute}')) {
|
||||
// Replace with actual attribute name
|
||||
const usernameAttr = process.env.LDAP_ATTRIBUTE_USERNAME;
|
||||
if (usernameAttr) {
|
||||
ldapOptions.server.searchFilter = ldapOptions.server.searchFilter.replace('{username_attribute}', usernameAttr);
|
||||
ldapOptions.server.searchFilter = ldapOptions.server.searchFilter.replace('{input}', '{{username}}');
|
||||
}
|
||||
}
|
||||
|
||||
passport.use(new LdapStrategy(ldapOptions, (user, done) => {
|
||||
// User object contains LDAP user data
|
||||
const usernameAttr = process.env.LDAP_ATTRIBUTE_USERNAME;
|
||||
const mailAttr = process.env.LDAP_ATTRIBUTE_MAIL;
|
||||
const dnAttr = process.env.LDAP_ATTRIBUTE_DISTINGUISHED_NAME;
|
||||
|
||||
return done(null, {
|
||||
id: usernameAttr ? user[usernameAttr] : user.uid,
|
||||
username: usernameAttr ? user[usernameAttr] : user.uid,
|
||||
email: mailAttr ? user[mailAttr] : user.mail,
|
||||
dn: dnAttr ? user[dnAttr] : user.dn
|
||||
});
|
||||
}));
|
||||
|
||||
// Serialize user for session
|
||||
passport.serializeUser((user, done) => {
|
||||
done(null, user);
|
||||
});
|
||||
|
||||
// Deserialize user from session
|
||||
passport.deserializeUser((user, done) => {
|
||||
done(null, user);
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Authentication middleware
|
||||
function isAuthenticated(req, res, next) {
|
||||
if (req.isAuthenticated()) {
|
||||
return next();
|
||||
}
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// Ensure data directory exists
|
||||
async function ensureDataDir() {
|
||||
const dataDir = path.dirname(DATA_FILE);
|
||||
@@ -589,12 +708,70 @@ async function extractMetadata(url) {
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication Routes
|
||||
|
||||
// Check authentication status
|
||||
app.get('/api/auth/status', (req, res) => {
|
||||
res.json({
|
||||
authenticated: req.isAuthenticated(),
|
||||
user: req.isAuthenticated() ? req.user : null
|
||||
});
|
||||
});
|
||||
|
||||
// Login endpoint
|
||||
app.post('/api/auth/login', (req, res, next) => {
|
||||
passport.authenticate('ldapauth', (err, user, info) => {
|
||||
if (err) {
|
||||
console.error('LDAP authentication error:', err);
|
||||
return res.status(500).json({ error: 'Authentication failed', details: err.message });
|
||||
}
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
req.logIn(user, (loginErr) => {
|
||||
if (loginErr) {
|
||||
return res.status(500).json({ error: 'Login failed', details: loginErr.message });
|
||||
}
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
user: user
|
||||
});
|
||||
});
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
// Logout endpoint
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
req.logout((err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
res.json({ authenticated: false });
|
||||
});
|
||||
});
|
||||
|
||||
// API Routes
|
||||
|
||||
// Get all links
|
||||
app.get('/api/links', async (req, res) => {
|
||||
try {
|
||||
const links = await readLinks();
|
||||
|
||||
// If user is not authenticated, only show links in public lists
|
||||
if (!req.isAuthenticated()) {
|
||||
const lists = await readLists();
|
||||
const publicListIds = lists.filter(list => list.public === true).map(list => list.id);
|
||||
|
||||
// Filter links to only those that are in at least one public list
|
||||
const filteredLinks = links.filter(link => {
|
||||
const linkListIds = link.listIds || [];
|
||||
return linkListIds.some(listId => publicListIds.includes(listId));
|
||||
});
|
||||
|
||||
return res.json(filteredLinks);
|
||||
}
|
||||
|
||||
// Authenticated users see all links
|
||||
res.json(links);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read links' });
|
||||
@@ -605,7 +782,19 @@ app.get('/api/links', async (req, res) => {
|
||||
app.get('/api/links/search', async (req, res) => {
|
||||
try {
|
||||
const query = req.query.q?.toLowerCase() || '';
|
||||
const links = await readLinks();
|
||||
let links = await readLinks();
|
||||
|
||||
// If user is not authenticated, only show links in public lists
|
||||
if (!req.isAuthenticated()) {
|
||||
const lists = await readLists();
|
||||
const publicListIds = lists.filter(list => list.public === true).map(list => list.id);
|
||||
|
||||
// Filter links to only those that are in at least one public list
|
||||
links = links.filter(link => {
|
||||
const linkListIds = link.listIds || [];
|
||||
return linkListIds.some(listId => publicListIds.includes(listId));
|
||||
});
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return res.json(links);
|
||||
@@ -625,7 +814,7 @@ app.get('/api/links/search', async (req, res) => {
|
||||
});
|
||||
|
||||
// Add a new link
|
||||
app.post('/api/links', async (req, res) => {
|
||||
app.post('/api/links', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
|
||||
@@ -664,7 +853,7 @@ app.post('/api/links', async (req, res) => {
|
||||
});
|
||||
|
||||
// Archive/Unarchive a link
|
||||
app.patch('/api/links/:id/archive', async (req, res) => {
|
||||
app.patch('/api/links/:id/archive', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { archived } = req.body;
|
||||
@@ -690,7 +879,7 @@ app.patch('/api/links/:id/archive', async (req, res) => {
|
||||
});
|
||||
|
||||
// Delete a link
|
||||
app.delete('/api/links/:id', async (req, res) => {
|
||||
app.delete('/api/links/:id', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const links = await readLinks();
|
||||
@@ -708,7 +897,7 @@ app.delete('/api/links/:id', async (req, res) => {
|
||||
});
|
||||
|
||||
// Update link's lists
|
||||
app.patch('/api/links/:id/lists', async (req, res) => {
|
||||
app.patch('/api/links/:id/lists', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { listIds } = req.body;
|
||||
@@ -739,6 +928,14 @@ app.patch('/api/links/:id/lists', async (req, res) => {
|
||||
app.get('/api/lists', async (req, res) => {
|
||||
try {
|
||||
const lists = await readLists();
|
||||
|
||||
// If user is not authenticated, only return public lists
|
||||
if (!req.isAuthenticated()) {
|
||||
const publicLists = lists.filter(list => list.public === true);
|
||||
return res.json(publicLists);
|
||||
}
|
||||
|
||||
// Authenticated users see all lists
|
||||
res.json(lists);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read lists' });
|
||||
@@ -746,7 +943,7 @@ app.get('/api/lists', async (req, res) => {
|
||||
});
|
||||
|
||||
// Create a new list
|
||||
app.post('/api/lists', async (req, res) => {
|
||||
app.post('/api/lists', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
|
||||
@@ -779,7 +976,7 @@ app.post('/api/lists', async (req, res) => {
|
||||
});
|
||||
|
||||
// Update a list
|
||||
app.put('/api/lists/:id', async (req, res) => {
|
||||
app.put('/api/lists/:id', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name } = req.body;
|
||||
@@ -811,7 +1008,7 @@ app.put('/api/lists/:id', async (req, res) => {
|
||||
});
|
||||
|
||||
// Toggle list public status
|
||||
app.patch('/api/lists/:id/public', async (req, res) => {
|
||||
app.patch('/api/lists/:id/public', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { public: isPublic } = req.body;
|
||||
@@ -837,7 +1034,7 @@ app.patch('/api/lists/:id/public', async (req, res) => {
|
||||
});
|
||||
|
||||
// Delete a list
|
||||
app.delete('/api/lists/:id', async (req, res) => {
|
||||
app.delete('/api/lists/:id', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const lists = await readLists();
|
||||
|
||||
Reference in New Issue
Block a user