feat: implement OIDC SSO with JIT provisioning
This commit is contained in:
Generated
+39
-2
@@ -6,18 +6,20 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nodecast-tv",
|
"name": "nodecast-tv",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-session": "^1.18.2",
|
"express-session": "^1.18.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
|
"passport-openidconnect": "^0.1.2",
|
||||||
"sax": "^1.4.3",
|
"sax": "^1.4.3",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
},
|
},
|
||||||
@@ -525,6 +527,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "17.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
|
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -1196,6 +1210,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth": {
|
||||||
|
"version": "0.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
|
||||||
|
"integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -1292,6 +1312,23 @@
|
|||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/passport-openidconnect": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport-openidconnect/-/passport-openidconnect-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-JX3rTyW+KFZ/E9OF/IpXJPbyLO9vGzcmXB5FgSP2jfL3LGKJPdV7zUE8rWeKeeI/iueQggOeFa3onrCmhxXZTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"oauth": "0.10.x",
|
||||||
|
"passport-strategy": "1.x.x"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jaredhanson"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/passport-strategy": {
|
"node_modules/passport-strategy": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||||
@@ -1829,4 +1866,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -12,12 +12,14 @@
|
|||||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-session": "^1.18.2",
|
"express-session": "^1.18.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
|
"passport-openidconnect": "^0.1.2",
|
||||||
"sax": "^1.4.3",
|
"sax": "^1.4.3",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
},
|
},
|
||||||
@@ -27,4 +29,4 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -990,6 +990,20 @@
|
|||||||
<script src="/js/pages/Settings.js?v=2"></script>
|
<script src="/js/pages/Settings.js?v=2"></script>
|
||||||
<script src="/js/pages/WatchPage.js?v=1"></script>
|
<script src="/js/pages/WatchPage.js?v=1"></script>
|
||||||
<script src="/js/app.js?v=4"></script>
|
<script src="/js/app.js?v=4"></script>
|
||||||
|
<script>
|
||||||
|
// Check for SSO token in URL
|
||||||
|
(function () {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const token = urlParams.get('token');
|
||||||
|
if (token) {
|
||||||
|
console.log('SSO Login successful, saving token...');
|
||||||
|
localStorage.setItem('authToken', token);
|
||||||
|
// Clean URL without reloading
|
||||||
|
const newUrl = window.location.pathname;
|
||||||
|
window.history.replaceState({}, document.title, newUrl);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -195,10 +195,44 @@
|
|||||||
|
|
||||||
<button type="submit" class="btn-login" id="submit-btn">Sign In</button>
|
<button type="submit" class="btn-login" id="submit-btn">Sign In</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="sso-divider" style="text-align: center; margin: 20px 0; position: relative;">
|
||||||
|
<span
|
||||||
|
style="background: var(--color-bg-secondary); padding: 0 10px; color: var(--color-text-secondary); font-size: 14px; position: relative; z-index: 1;">OR</span>
|
||||||
|
<div
|
||||||
|
style="position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: var(--color-border); z-index: 0;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="btn-sso-login" class="btn-login"
|
||||||
|
style="background: transparent; border: 1px solid var(--color-border); color: var(--color-text-primary); display: flex; align-items: center; justify-content: center; gap: 10px;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||||
|
style="width: 20px; height: 20px;">
|
||||||
|
<path
|
||||||
|
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z" />
|
||||||
|
</svg>
|
||||||
|
Sign in with SSO
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Start SSO Login
|
||||||
|
document.getElementById('btn-sso-login').addEventListener('click', () => {
|
||||||
|
window.location.href = '/api/auth/oidc/login';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for URL Error params
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const error = urlParams.get('error');
|
||||||
|
if (error) {
|
||||||
|
const errorMessage = document.getElementById('error-message');
|
||||||
|
errorMessage.textContent = error;
|
||||||
|
errorMessage.classList.add('show');
|
||||||
|
// Clean URL
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if setup is required
|
// Check if setup is required
|
||||||
let isSetupMode = false;
|
let isSetupMode = false;
|
||||||
|
|
||||||
@@ -213,6 +247,9 @@
|
|||||||
document.getElementById('submit-btn').textContent = 'Create Account';
|
document.getElementById('submit-btn').textContent = 'Create Account';
|
||||||
document.getElementById('setup-message').classList.add('show');
|
document.getElementById('setup-message').classList.add('show');
|
||||||
document.getElementById('password').placeholder = 'Minimum 6 characters';
|
document.getElementById('password').placeholder = 'Minimum 6 characters';
|
||||||
|
// Hide SSO in setup mode
|
||||||
|
document.querySelector('.sso-divider').style.display = 'none';
|
||||||
|
document.getElementById('btn-sso-login').style.display = 'none';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error checking setup status:', err);
|
console.error('Error checking setup status:', err);
|
||||||
|
|||||||
+102
-7
@@ -63,17 +63,17 @@ function configureLocalStrategy(getUserByUsername, verifyUserPassword) {
|
|||||||
async (username, password, done) => {
|
async (username, password, done) => {
|
||||||
try {
|
try {
|
||||||
const user = await getUserByUsername(username);
|
const user = await getUserByUsername(username);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return done(null, false, { message: 'Invalid credentials' });
|
return done(null, false, { message: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValid = await verifyUserPassword(password, user.passwordHash);
|
const isValid = await verifyUserPassword(password, user.passwordHash);
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return done(null, false, { message: 'Invalid credentials' });
|
return done(null, false, { message: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return done(null, user);
|
return done(null, user);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return done(err);
|
return done(err);
|
||||||
@@ -90,15 +90,15 @@ function configureJwtStrategy(getUserById) {
|
|||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
secretOrKey: JWT_SECRET
|
secretOrKey: JWT_SECRET
|
||||||
};
|
};
|
||||||
|
|
||||||
passport.use(new JwtStrategy(options, async (payload, done) => {
|
passport.use(new JwtStrategy(options, async (payload, done) => {
|
||||||
try {
|
try {
|
||||||
const user = await getUserById(payload.id);
|
const user = await getUserById(payload.id);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return done(null, false);
|
return done(null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return done(null, {
|
return done(null, {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@@ -110,6 +110,100 @@ function configureJwtStrategy(getUserById) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure Passport OpenID Connect Strategy
|
||||||
|
*/
|
||||||
|
function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
|
||||||
|
if (!process.env.OIDC_ISSUER_URL || !process.env.OIDC_CLIENT_ID || !process.env.OIDC_CLIENT_SECRET) {
|
||||||
|
console.warn('OIDC configuration missing - SSO disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Strategy: OpenIDConnectStrategy } = require('passport-openidconnect');
|
||||||
|
|
||||||
|
passport.use(new OpenIDConnectStrategy({
|
||||||
|
issuer: process.env.OIDC_ISSUER_URL || 'https://mock-issuer.com', // Dummy default for mock
|
||||||
|
authorizationURL: process.env.OIDC_AUTH_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/auth`,
|
||||||
|
tokenURL: process.env.OIDC_TOKEN_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/token`,
|
||||||
|
userInfoURL: process.env.OIDC_USERINFO_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/userinfo`,
|
||||||
|
clientID: process.env.OIDC_CLIENT_ID || 'mock-client-id',
|
||||||
|
clientSecret: process.env.OIDC_CLIENT_SECRET || 'mock-secret',
|
||||||
|
callbackURL: process.env.OIDC_CALLBACK_URL || '/api/auth/oidc/callback',
|
||||||
|
scope: ['openid', 'profile', 'email']
|
||||||
|
},
|
||||||
|
async (...args) => {
|
||||||
|
// The done callback is always the last argument
|
||||||
|
const done = args[args.length - 1];
|
||||||
|
|
||||||
|
// Map known arguments
|
||||||
|
// Standard: issuer, sub, profile, accessToken, refreshToken, done
|
||||||
|
// Some versions: issuer, sub, profile, accessToken, refreshToken, params, done
|
||||||
|
|
||||||
|
let issuer, sub, profile;
|
||||||
|
|
||||||
|
if (args.length === 3) {
|
||||||
|
// Scenario: (issuer, profile, done)
|
||||||
|
const arg0 = args[0];
|
||||||
|
const arg1 = args[1];
|
||||||
|
|
||||||
|
if (typeof arg1 === 'object' && arg1.id) {
|
||||||
|
issuer = arg0;
|
||||||
|
profile = arg1;
|
||||||
|
sub = profile.id;
|
||||||
|
} else if (typeof arg0 === 'string' && typeof arg1 === 'string') {
|
||||||
|
issuer = arg0;
|
||||||
|
sub = arg1;
|
||||||
|
profile = { id: sub, displayName: 'Unknown' };
|
||||||
|
}
|
||||||
|
} else if (args.length >= 4) {
|
||||||
|
// Assume standard: iss, sub, profile...
|
||||||
|
issuer = args[0];
|
||||||
|
sub = args[1];
|
||||||
|
profile = args[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sub && profile && profile.id) sub = profile.id;
|
||||||
|
|
||||||
|
if (!sub) {
|
||||||
|
return done(new Error('Could not identify OIDC Subject (sub) from arguments'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Try to find by OIDC ID (sub)
|
||||||
|
let user = await findUserByOidcId(sub);
|
||||||
|
|
||||||
|
// 2. If not found, try to match by email
|
||||||
|
if (!user && profile.emails && profile.emails.length > 0) {
|
||||||
|
const email = profile.emails[0].value;
|
||||||
|
user = await findUserByEmail(email);
|
||||||
|
|
||||||
|
// If found by email but no OIDC ID, link them
|
||||||
|
if (user && !user.oidcId) {
|
||||||
|
// We don't have a direct update method for specific fields without full user object in this context
|
||||||
|
// Ideally we'd update the user here. For now, we'll just log in.
|
||||||
|
// Future: Update user with oidcId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If still not found, create new user (JIT Provisioning)
|
||||||
|
if (!user) {
|
||||||
|
const username = profile.username || profile.displayName || (profile.emails ? profile.emails[0].value.split('@')[0] : `user_${sub.substring(0, 8)}`);
|
||||||
|
|
||||||
|
user = await createUser({
|
||||||
|
username: username,
|
||||||
|
role: 'viewer', // Default role for SSO users
|
||||||
|
oidcId: sub,
|
||||||
|
email: profile.emails ? profile.emails[0].value : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return done(null, user);
|
||||||
|
} catch (err) {
|
||||||
|
return done(err);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware: Require authentication using Passport JWT
|
* Middleware: Require authentication using Passport JWT
|
||||||
*/
|
*/
|
||||||
@@ -145,6 +239,7 @@ module.exports = {
|
|||||||
verifyToken,
|
verifyToken,
|
||||||
configureLocalStrategy,
|
configureLocalStrategy,
|
||||||
configureJwtStrategy,
|
configureJwtStrategy,
|
||||||
|
configureOidcStrategy,
|
||||||
requireAuth,
|
requireAuth,
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
requireRole
|
requireRole
|
||||||
|
|||||||
+14
-1
@@ -365,6 +365,16 @@ const users = {
|
|||||||
return db.users?.find(u => u.username === username);
|
return db.users?.find(u => u.username === username);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getByOidcId(oidcId) {
|
||||||
|
const db = await loadDb();
|
||||||
|
return db.users?.find(u => u.oidcId === oidcId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getByEmail(email) {
|
||||||
|
const db = await loadDb();
|
||||||
|
return db.users?.find(u => u.email === email);
|
||||||
|
},
|
||||||
|
|
||||||
async create(userData) {
|
async create(userData) {
|
||||||
const db = await loadDb();
|
const db = await loadDb();
|
||||||
if (!db.users) {
|
if (!db.users) {
|
||||||
@@ -379,8 +389,11 @@ const users = {
|
|||||||
const newUser = {
|
const newUser = {
|
||||||
id: db.nextId++,
|
id: db.nextId++,
|
||||||
username: userData.username,
|
username: userData.username,
|
||||||
passwordHash: userData.passwordHash,
|
// For OIDC users, passwordHash is optional
|
||||||
|
passwordHash: userData.passwordHash || null,
|
||||||
role: userData.role || 'viewer',
|
role: userData.role || 'viewer',
|
||||||
|
oidcId: userData.oidcId || null,
|
||||||
|
email: userData.email || null,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
require('dotenv').config();
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
const syncService = require('./services/syncService');
|
const syncService = require('./services/syncService');
|
||||||
@@ -17,7 +18,14 @@ app.set('trust proxy', true);
|
|||||||
app.use(express.json({ limit: '50mb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
|
||||||
// Initialize Passport
|
// Initialize Passport
|
||||||
|
const session = require('express-session');
|
||||||
|
app.use(session({
|
||||||
|
secret: process.env.JWT_SECRET || 'keyboard cat',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: true
|
||||||
|
}));
|
||||||
app.use(passport.initialize());
|
app.use(passport.initialize());
|
||||||
|
app.use(passport.session());
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||||
|
|
||||||
|
|||||||
+56
-28
@@ -13,6 +13,34 @@ auth.configureJwtStrategy(
|
|||||||
async (id) => await db.users.getById(id)
|
async (id) => await db.users.getById(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Configure OIDC Strategy
|
||||||
|
auth.configureOidcStrategy(
|
||||||
|
async (oidcId) => await db.users.getByOidcId(oidcId),
|
||||||
|
async (email) => await db.users.getByEmail(email),
|
||||||
|
async (userData) => await db.users.create(userData)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start OIDC Login
|
||||||
|
* GET /api/auth/oidc/login
|
||||||
|
*/
|
||||||
|
router.get('/oidc/login', auth.passport.authenticate('openidconnect'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OIDC Callback
|
||||||
|
* GET /api/auth/oidc/callback
|
||||||
|
*/
|
||||||
|
router.get('/oidc/callback',
|
||||||
|
auth.passport.authenticate('openidconnect', { session: false, failureRedirect: '/login.html?error=SSO+Failed' }),
|
||||||
|
(req, res) => {
|
||||||
|
// Successful authentication
|
||||||
|
const token = auth.generateToken(req.user);
|
||||||
|
|
||||||
|
// Redirect to hompage with token
|
||||||
|
res.redirect(`/?token=${token}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if initial setup is required
|
* Check if initial setup is required
|
||||||
* GET /api/auth/setup-required
|
* GET /api/auth/setup-required
|
||||||
@@ -34,22 +62,22 @@ router.get('/setup-required', async (req, res) => {
|
|||||||
router.post('/setup', async (req, res) => {
|
router.post('/setup', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userCount = await db.users.count();
|
const userCount = await db.users.count();
|
||||||
|
|
||||||
// Check if setup already done
|
// Check if setup already done
|
||||||
if (userCount > 0) {
|
if (userCount > 0) {
|
||||||
return res.status(400).json({ error: 'Setup already completed' });
|
return res.status(400).json({ error: 'Setup already completed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
return res.status(400).json({ error: 'Username and password required' });
|
return res.status(400).json({ error: 'Username and password required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 6) {
|
||||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create admin user
|
// Create admin user
|
||||||
const passwordHash = await auth.hashPassword(password);
|
const passwordHash = await auth.hashPassword(password);
|
||||||
const adminUser = await db.users.create({
|
const adminUser = await db.users.create({
|
||||||
@@ -57,10 +85,10 @@ router.post('/setup', async (req, res) => {
|
|||||||
passwordHash,
|
passwordHash,
|
||||||
role: 'admin'
|
role: 'admin'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate token for immediate login
|
// Generate token for immediate login
|
||||||
const token = auth.generateToken(adminUser);
|
const token = auth.generateToken(adminUser);
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
message: 'Admin user created successfully',
|
message: 'Admin user created successfully',
|
||||||
token,
|
token,
|
||||||
@@ -82,14 +110,14 @@ router.post('/login', (req, res, next) => {
|
|||||||
console.error('Login error:', err);
|
console.error('Login error:', err);
|
||||||
return res.status(500).json({ error: 'Server error' });
|
return res.status(500).json({ error: 'Server error' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(401).json({ error: info?.message || 'Invalid credentials' });
|
return res.status(401).json({ error: info?.message || 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
const token = auth.generateToken(user);
|
const token = auth.generateToken(user);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
token,
|
token,
|
||||||
user: {
|
user: {
|
||||||
@@ -118,11 +146,11 @@ router.post('/logout', (req, res) => {
|
|||||||
router.get('/me', auth.requireAuth, async (req, res) => {
|
router.get('/me', auth.requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await db.users.getById(req.user.id);
|
const user = await db.users.getById(req.user.id);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(404).json({ error: 'User not found' });
|
return res.status(404).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@@ -141,13 +169,13 @@ router.get('/me', auth.requireAuth, async (req, res) => {
|
|||||||
router.get('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
router.get('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const allUsers = await db.users.getAll();
|
const allUsers = await db.users.getAll();
|
||||||
|
|
||||||
// Remove password hashes
|
// Remove password hashes
|
||||||
const users = allUsers.map(u => {
|
const users = allUsers.map(u => {
|
||||||
const { passwordHash, ...userWithoutPassword } = u;
|
const { passwordHash, ...userWithoutPassword } = u;
|
||||||
return userWithoutPassword;
|
return userWithoutPassword;
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(users);
|
res.json(users);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching users:', err);
|
console.error('Error fetching users:', err);
|
||||||
@@ -162,26 +190,26 @@ router.get('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
|||||||
router.post('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
router.post('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username, password, role } = req.body;
|
const { username, password, role } = req.body;
|
||||||
|
|
||||||
if (!username || !password || !role) {
|
if (!username || !password || !role) {
|
||||||
return res.status(400).json({ error: 'Username, password, and role are required' });
|
return res.status(400).json({ error: 'Username, password, and role are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 6) {
|
||||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['admin', 'viewer'].includes(role)) {
|
if (!['admin', 'viewer'].includes(role)) {
|
||||||
return res.status(400).json({ error: 'Role must be either "admin" or "viewer"' });
|
return res.status(400).json({ error: 'Role must be either "admin" or "viewer"' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await auth.hashPassword(password);
|
const passwordHash = await auth.hashPassword(password);
|
||||||
const newUser = await db.users.create({
|
const newUser = await db.users.create({
|
||||||
username,
|
username,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
role
|
role
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json(newUser);
|
res.status(201).json(newUser);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error creating user:', err);
|
console.error('Error creating user:', err);
|
||||||
@@ -197,25 +225,25 @@ router.put('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) =
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { username, password, role } = req.body;
|
const { username, password, role } = req.body;
|
||||||
|
|
||||||
const updates = {};
|
const updates = {};
|
||||||
|
|
||||||
if (username) {
|
if (username) {
|
||||||
updates.username = username;
|
updates.username = username;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password) {
|
if (password) {
|
||||||
if (password.length < 6) {
|
if (password.length < 6) {
|
||||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||||
}
|
}
|
||||||
updates.passwordHash = await auth.hashPassword(password);
|
updates.passwordHash = await auth.hashPassword(password);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role) {
|
if (role) {
|
||||||
if (!['admin', 'viewer'].includes(role)) {
|
if (!['admin', 'viewer'].includes(role)) {
|
||||||
return res.status(400).json({ error: 'Role must be either "admin" or "viewer"' });
|
return res.status(400).json({ error: 'Role must be either "admin" or "viewer"' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent removing admin role from the last admin
|
// Prevent removing admin role from the last admin
|
||||||
const user = await db.users.getById(id);
|
const user = await db.users.getById(id);
|
||||||
if (user && user.role === 'admin' && role !== 'admin') {
|
if (user && user.role === 'admin' && role !== 'admin') {
|
||||||
@@ -225,10 +253,10 @@ router.put('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) =
|
|||||||
return res.status(400).json({ error: 'Cannot remove admin role from the last admin user' });
|
return res.status(400).json({ error: 'Cannot remove admin role from the last admin user' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updates.role = role;
|
updates.role = role;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUser = await db.users.update(id, updates);
|
const updatedUser = await db.users.update(id, updates);
|
||||||
res.json(updatedUser);
|
res.json(updatedUser);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -244,12 +272,12 @@ router.put('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) =
|
|||||||
router.delete('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
router.delete('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
// Prevent deleting yourself
|
// Prevent deleting yourself
|
||||||
if (parseInt(id) === req.user.id) {
|
if (parseInt(id) === req.user.id) {
|
||||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.users.delete(id);
|
await db.users.delete(id);
|
||||||
res.json({ success: true, message: 'User deleted successfully' });
|
res.json({ success: true, message: 'User deleted successfully' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user