Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
17
dashcaddy-api/routes/auth/index.js
Normal file
17
dashcaddy-api/routes/auth/index.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const express = require('express');
|
||||
const initTotp = require('./totp');
|
||||
const initKeys = require('./keys');
|
||||
const initSessionHandlers = require('./session-handlers');
|
||||
const initSsoGate = require('./sso-gate');
|
||||
|
||||
module.exports = function(ctx) {
|
||||
const router = express.Router();
|
||||
|
||||
const { getAppSession, appSessionCache } = initSessionHandlers(ctx);
|
||||
|
||||
router.use(initTotp(ctx));
|
||||
router.use(initKeys(ctx));
|
||||
router.use(initSsoGate(ctx, getAppSession, appSessionCache));
|
||||
|
||||
return router;
|
||||
};
|
||||
130
dashcaddy-api/routes/auth/keys.js
Normal file
130
dashcaddy-api/routes/auth/keys.js
Normal file
@@ -0,0 +1,130 @@
|
||||
const express = require('express');
|
||||
|
||||
module.exports = function(ctx) {
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to parse expiration strings to milliseconds
|
||||
function parseExpiration(expStr) {
|
||||
const match = expStr.match(/^(\d+)([smhdy])$/);
|
||||
if (!match) return 24 * 60 * 60 * 1000; // default 24h
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
const multipliers = {
|
||||
s: 1000,
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
y: 365 * 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
return value * (multipliers[unit] || multipliers.h);
|
||||
}
|
||||
|
||||
// List all API keys
|
||||
router.get('/auth/keys', ctx.asyncHandler(async (req, res) => {
|
||||
// Require session authentication (not API key - can't manage keys with key itself)
|
||||
if (!req.auth || req.auth.type !== 'session') {
|
||||
return ctx.errorResponse(res, 403, 'API key management requires TOTP session authentication');
|
||||
}
|
||||
|
||||
const keys = await ctx.authManager.listAPIKeys();
|
||||
res.json({ success: true, keys });
|
||||
}, 'auth-keys-list'));
|
||||
|
||||
// Generate new API key
|
||||
router.post('/auth/keys', ctx.asyncHandler(async (req, res) => {
|
||||
// Require session authentication
|
||||
if (!req.auth || req.auth.type !== 'session') {
|
||||
return ctx.errorResponse(res, 403, 'API key generation requires TOTP session authentication');
|
||||
}
|
||||
|
||||
const { name, scopes } = req.body;
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return ctx.errorResponse(res, 400, 'API key name is required');
|
||||
}
|
||||
|
||||
// Validate scopes if provided
|
||||
const validScopes = ['read', 'write', 'admin'];
|
||||
if (scopes && (!Array.isArray(scopes) || !scopes.every(s => validScopes.includes(s)))) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid scopes', { validScopes });
|
||||
}
|
||||
|
||||
const keyData = await ctx.authManager.generateAPIKey(
|
||||
name.trim(),
|
||||
scopes || ['read', 'write']
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
key: keyData.key,
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
scopes: keyData.scopes,
|
||||
createdAt: keyData.createdAt,
|
||||
warning: 'Save this key securely - it will not be shown again'
|
||||
});
|
||||
}, 'auth-keys-generate'));
|
||||
|
||||
// Revoke API key
|
||||
router.delete('/auth/keys/:keyId', ctx.asyncHandler(async (req, res) => {
|
||||
// Require session authentication
|
||||
if (!req.auth || req.auth.type !== 'session') {
|
||||
return ctx.errorResponse(res, 403, 'API key revocation requires TOTP session authentication');
|
||||
}
|
||||
|
||||
const { keyId } = req.params;
|
||||
|
||||
if (!keyId || typeof keyId !== 'string') {
|
||||
return ctx.errorResponse(res, 400, 'Key ID is required');
|
||||
}
|
||||
|
||||
const success = await ctx.authManager.revokeAPIKey(keyId);
|
||||
|
||||
if (success) {
|
||||
res.json({ success: true, message: 'API key revoked successfully' });
|
||||
} else {
|
||||
const { NotFoundError } = require('../../errors');
|
||||
throw new NotFoundError('API key');
|
||||
}
|
||||
}, 'auth-keys-revoke'));
|
||||
|
||||
// Generate JWT from TOTP session
|
||||
router.post('/auth/jwt', ctx.asyncHandler(async (req, res) => {
|
||||
// Require session authentication
|
||||
if (!req.auth || req.auth.type !== 'session') {
|
||||
return ctx.errorResponse(res, 403, 'JWT generation requires TOTP session authentication');
|
||||
}
|
||||
|
||||
const { expiresIn, userId } = req.body;
|
||||
|
||||
// Validate expiresIn format if provided (e.g., '24h', '7d', '1y')
|
||||
const validExpiresIn = /^(\d+[smhdy])$/.test(expiresIn || '24h');
|
||||
if (expiresIn && !validExpiresIn) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid expiresIn format. Use: 60s, 15m, 24h, 7d, 1y');
|
||||
}
|
||||
|
||||
const token = await ctx.authManager.generateJWT(
|
||||
{
|
||||
sub: userId || 'dashcaddy-admin',
|
||||
scope: ['admin'] // Session-generated JWTs have admin scope
|
||||
},
|
||||
expiresIn || '24h'
|
||||
);
|
||||
|
||||
// Calculate expiration timestamp
|
||||
const expiresInMs = parseExpiration(expiresIn || '24h');
|
||||
const expiresAt = new Date(Date.now() + expiresInMs).toISOString();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
expiresAt,
|
||||
usage: 'Include in Authorization header as: Bearer <token>'
|
||||
});
|
||||
}, 'auth-jwt-generate'));
|
||||
|
||||
return router;
|
||||
};
|
||||
177
dashcaddy-api/routes/auth/session-handlers.js
Normal file
177
dashcaddy-api/routes/auth/session-handlers.js
Normal file
@@ -0,0 +1,177 @@
|
||||
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../constants');
|
||||
const { createCache, CACHE_CONFIGS } = require('../../cache-config');
|
||||
|
||||
module.exports = function(ctx) {
|
||||
// App session cache for auto-login
|
||||
const appSessionCache = createCache(CACHE_CONFIGS.appSessions);
|
||||
|
||||
async function getAppSession(serviceId, baseUrl, username, password) {
|
||||
const cached = appSessionCache.get(serviceId);
|
||||
if (cached && cached.exp > Date.now()) {
|
||||
if (cached.failed) return null;
|
||||
return cached.cookies;
|
||||
}
|
||||
|
||||
let loginUrl, loginBody, contentType = 'application/x-www-form-urlencoded';
|
||||
const extraHeaders = {};
|
||||
let expectJsonToken = false;
|
||||
const formEncode = (s) => encodeURIComponent(s).replace(/\*/g, '%2A');
|
||||
|
||||
switch (serviceId) {
|
||||
case 'torrent':
|
||||
loginUrl = `${baseUrl}api/v2/auth/login`;
|
||||
loginBody = `username=${formEncode(username)}&password=${formEncode(password)}`;
|
||||
extraHeaders['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
break;
|
||||
case 'router': {
|
||||
const routerBody = `username=${formEncode(username)}&password=${formEncode(password)}&Continue=Continue`;
|
||||
try {
|
||||
const { spawnSync } = require('child_process');
|
||||
const proc = spawnSync('wget', [
|
||||
'-q', '-S', `--post-data=${routerBody}`, '-O', '/dev/null',
|
||||
`${baseUrl}/cgi-bin/login.ha`
|
||||
], { timeout: 5000, encoding: 'utf8' });
|
||||
const result = (proc.stderr || '').split('\n').slice(0, 2).join('\n');
|
||||
const locationMatch = result.match(/Location:\s*(.+)/);
|
||||
const location = locationMatch ? locationMatch[1].trim() : '';
|
||||
if (location && !location.includes('login')) {
|
||||
appSessionCache.set(serviceId, { cookies: '__ip_session=1', exp: Date.now() + SESSION_TTL.IP_SESSION });
|
||||
ctx.log.info('auth', 'Router auto-login successful (IP-based session)', { serviceId });
|
||||
return '__ip_session=1';
|
||||
}
|
||||
ctx.log.warn('auth', 'Router auto-login failed', { serviceId });
|
||||
} catch (e) {
|
||||
ctx.log.warn('auth', 'Router auto-login error', { serviceId, error: e.message?.substring(0, 100) });
|
||||
}
|
||||
appSessionCache.set(serviceId, { failed: true, exp: Date.now() + SESSION_TTL.FAILED_LOGIN });
|
||||
return null;
|
||||
}
|
||||
case 'sync':
|
||||
loginUrl = `${baseUrl}/rest/noauth/auth/password`;
|
||||
contentType = 'application/json';
|
||||
loginBody = JSON.stringify({ username, password });
|
||||
break;
|
||||
case 'chat':
|
||||
loginUrl = `${baseUrl}/api/v1/auths/signin`;
|
||||
contentType = 'application/json';
|
||||
loginBody = JSON.stringify({ email: username, password });
|
||||
expectJsonToken = true;
|
||||
break;
|
||||
case 'jellyfin':
|
||||
case 'emby': {
|
||||
const mediaAuth = buildMediaAuth(APP.DEVICE_IDS.SSO);
|
||||
try {
|
||||
const authResp = await ctx.fetchT(`${baseUrl}/Users/AuthenticateByName`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Emby-Authorization': mediaAuth },
|
||||
body: JSON.stringify({ Username: username, Pw: password }),
|
||||
}, TIMEOUTS.HTTP_LONG);
|
||||
const authData = await authResp.json();
|
||||
if (authData.AccessToken) {
|
||||
const tokenData = {
|
||||
token: authData.AccessToken, userId: authData.User?.Id,
|
||||
serverId: authData.ServerId, serverName: authData.User?.ServerName || serviceId,
|
||||
};
|
||||
appSessionCache.set(serviceId, { cookies: `token=${authData.AccessToken}`, token: authData.AccessToken, tokenData, exp: Date.now() + SESSION_TTL.TOKEN_SESSION });
|
||||
ctx.log.info('auth', 'Auto-login successful (token + userId obtained)', { serviceId });
|
||||
return `token=${authData.AccessToken}`;
|
||||
}
|
||||
ctx.log.warn('auth', 'Auto-login failed', { serviceId, status: authResp.status });
|
||||
} catch (e) {
|
||||
ctx.log.warn('auth', 'Auto-login error', { serviceId, error: e.message });
|
||||
}
|
||||
appSessionCache.set(serviceId, { failed: true, exp: Date.now() + SESSION_TTL.FAILED_LOGIN });
|
||||
return null;
|
||||
}
|
||||
case 'plex': {
|
||||
try {
|
||||
const plexResp = await ctx.fetchT(PLEX.AUTH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json', 'Content-Type': 'application/json',
|
||||
'Authorization': `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
|
||||
'X-Plex-Client-Identifier': APP.DEVICE_IDS.SSO,
|
||||
'X-Plex-Product': APP.NAME, 'X-Plex-Version': APP.VERSION,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}, TIMEOUTS.HTTP_LONG);
|
||||
const plexData = await plexResp.json();
|
||||
const token = plexData?.user?.authToken;
|
||||
if (token) {
|
||||
appSessionCache.set(serviceId, { cookies: `plexToken=${token}`, token, exp: Date.now() + SESSION_TTL.TOKEN_SESSION });
|
||||
ctx.log.info('auth', 'Plex auto-login successful via plex.tv', { serviceId });
|
||||
return `plexToken=${token}`;
|
||||
}
|
||||
ctx.log.warn('auth', 'Plex auto-login failed: no token in response', { serviceId, status: plexResp.status });
|
||||
} catch (e) {
|
||||
ctx.log.warn('auth', 'Plex auto-login error', { serviceId, error: e.message });
|
||||
}
|
||||
appSessionCache.set(serviceId, { failed: true, exp: Date.now() + SESSION_TTL.FAILED_LOGIN });
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
loginUrl = `${baseUrl}login`;
|
||||
loginBody = `username=${formEncode(username)}&password=${formEncode(password)}&rememberMe=on`;
|
||||
extraHeaders['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await ctx.fetchT(loginUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': contentType, ...extraHeaders },
|
||||
body: loginBody, redirect: 'manual',
|
||||
}, TIMEOUTS.HTTP_LONG);
|
||||
|
||||
if (expectJsonToken) {
|
||||
try {
|
||||
const data = await resp.json();
|
||||
if (data.token) {
|
||||
const cookies = `token=${data.token}`;
|
||||
appSessionCache.set(serviceId, { cookies, exp: Date.now() + SESSION_TTL.COOKIE_SESSION });
|
||||
ctx.log.info('auth', 'Auto-login successful (JWT token cached)', { serviceId });
|
||||
return cookies;
|
||||
}
|
||||
} catch (e) { /* JSON parse failed */ }
|
||||
ctx.log.warn('auth', 'Auto-login: no token in response', { serviceId, status: resp.status });
|
||||
appSessionCache.set(serviceId, { failed: true, exp: Date.now() + SESSION_TTL.FAILED_LOGIN });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (serviceId === 'torrent') {
|
||||
const text = await resp.text();
|
||||
if (text.trim() !== 'Ok.') {
|
||||
ctx.log.warn('auth', 'Auto-login failed', { serviceId, response: text.trim() });
|
||||
appSessionCache.set(serviceId, { failed: true, exp: Date.now() + SESSION_TTL.FAILED_LOGIN });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const setCookies = resp.headers.getSetCookie?.() || [];
|
||||
if (setCookies.length > 0) {
|
||||
const cookies = setCookies.map(c => c.split(';')[0]).join('; ');
|
||||
appSessionCache.set(serviceId, { cookies, exp: Date.now() + SESSION_TTL.COOKIE_SESSION });
|
||||
ctx.log.info('auth', 'Auto-login successful, session cached', { serviceId, cookieCount: setCookies.length });
|
||||
return cookies;
|
||||
}
|
||||
|
||||
const rawCookie = resp.headers.get('set-cookie');
|
||||
if (rawCookie) {
|
||||
const cookies = rawCookie.split(/,(?=[^ ])/).map(c => c.split(';')[0].trim()).join('; ');
|
||||
appSessionCache.set(serviceId, { cookies, exp: Date.now() + SESSION_TTL.COOKIE_SESSION });
|
||||
ctx.log.info('auth', 'Auto-login successful (fallback), session cached', { serviceId });
|
||||
return cookies;
|
||||
}
|
||||
|
||||
ctx.log.warn('auth', 'Auto-login: no cookies in response', { serviceId, status: resp.status });
|
||||
appSessionCache.set(serviceId, { failed: true, exp: Date.now() + SESSION_TTL.FAILED_LOGIN });
|
||||
} catch (e) {
|
||||
ctx.log.warn('auth', 'Auto-login error', { serviceId, error: e.message });
|
||||
appSessionCache.set(serviceId, { failed: true, exp: Date.now() + SESSION_TTL.FAILED_LOGIN });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Expose both the function and the cache so sso-gate can use them
|
||||
return { getAppSession, appSessionCache };
|
||||
};
|
||||
182
dashcaddy-api/routes/auth/sso-gate.js
Normal file
182
dashcaddy-api/routes/auth/sso-gate.js
Normal file
@@ -0,0 +1,182 @@
|
||||
const express = require('express');
|
||||
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../constants');
|
||||
|
||||
module.exports = function(ctx, getAppSession, appSessionCache) {
|
||||
const router = express.Router();
|
||||
|
||||
// Caddy forward_auth gate: checks TOTP session + injects service credentials
|
||||
router.get('/auth/gate/:serviceId', ctx.asyncHandler(async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
const serviceId = req.params.serviceId;
|
||||
|
||||
// Check TOTP session first
|
||||
if (ctx.totpConfig.enabled && ctx.totpConfig.sessionDuration !== 'never') {
|
||||
const valid = ctx.session.isValid(req);
|
||||
if (!valid) return ctx.errorResponse(res, 401, 'Session expired or invalid', { authenticated: false });
|
||||
}
|
||||
|
||||
// Session valid (or TOTP disabled) - inject credentials if premium SSO is active
|
||||
let injected = false;
|
||||
const ssoEnabled = ctx.licenseManager.hasFeature('sso');
|
||||
if (!ssoEnabled) {
|
||||
// Free tier: TOTP gate passes but no credential injection
|
||||
return res.status(200).json({ authenticated: true, credentialsInjected: false, premiumRequired: true });
|
||||
}
|
||||
try {
|
||||
const services = await ctx.servicesStateManager.read();
|
||||
const service = services.find(s => s.id === serviceId);
|
||||
|
||||
// External services: inject seedhost Basic Auth
|
||||
if (service && service.isExternal) {
|
||||
const sharedUser = await ctx.credentialManager.retrieve('seedhost.username').catch(() => null);
|
||||
const svcPass = await ctx.credentialManager.retrieve(`seedhost.password.${serviceId}`).catch(() => null);
|
||||
const sharedPass = await ctx.credentialManager.retrieve('seedhost.password').catch(() => null);
|
||||
const password = svcPass || sharedPass;
|
||||
if (sharedUser && password) {
|
||||
const basicAuth = Buffer.from(`${sharedUser}:${password}`).toString('base64');
|
||||
res.setHeader('Authorization', `Basic ${basicAuth}`);
|
||||
injected = true;
|
||||
if (service.externalUrl) {
|
||||
const appCookies = await getAppSession(serviceId, service.externalUrl, sharedUser, password);
|
||||
if (appCookies) res.setHeader('X-App-Cookie', appCookies);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-external services: check per-service Basic Auth
|
||||
if (!service || !service.isExternal) {
|
||||
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
||||
const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||
if (username && password) {
|
||||
const basicAuth = Buffer.from(`${username}:${password}`).toString('base64');
|
||||
res.setHeader('Authorization', `Basic ${basicAuth}`);
|
||||
injected = true;
|
||||
if (service && service.url) {
|
||||
const appCookies = await getAppSession(serviceId, service.url, username, password);
|
||||
if (appCookies) res.setHeader('X-App-Cookie', appCookies);
|
||||
if (serviceId === 'plex') {
|
||||
const plexCached = appSessionCache.get('plex');
|
||||
if (plexCached && plexCached.token) res.setHeader('X-Plex-Token', plexCached.token);
|
||||
}
|
||||
if (serviceId === 'jellyfin' || serviceId === 'emby') {
|
||||
const mediaCached = appSessionCache.get(serviceId);
|
||||
if (mediaCached && mediaCached.token) res.setHeader('X-Emby-Token', mediaCached.token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inject API key
|
||||
const arrKey = await ctx.credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
|
||||
const svcKey = await ctx.credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
||||
const apiKey = arrKey || svcKey;
|
||||
if (apiKey) { res.setHeader('X-Api-Key', apiKey); injected = true; }
|
||||
} catch (e) {
|
||||
ctx.log.warn('auth', 'Credential error', { serviceId, error: e.message });
|
||||
}
|
||||
|
||||
res.status(200).json({ authenticated: true, credentialsInjected: injected });
|
||||
}, 'auth-gate'));
|
||||
|
||||
// Return cached app session token for client-side auth (Premium SSO feature)
|
||||
router.get('/auth/app-token/:serviceId', ctx.licenseManager.requirePremium('sso'), ctx.asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
if (ctx.totpConfig.enabled && ctx.totpConfig.sessionDuration !== 'never') {
|
||||
if (!ctx.session.isValid(req)) return ctx.errorResponse(res, 401, 'Not authenticated');
|
||||
}
|
||||
|
||||
// Jellyfin/Emby: separate browser-specific token
|
||||
if (serviceId === 'jellyfin' || serviceId === 'emby') {
|
||||
const browserCacheKey = `${serviceId}_browser`;
|
||||
const browserCached = appSessionCache.get(browserCacheKey);
|
||||
if (browserCached && browserCached.exp > Date.now()) {
|
||||
if (browserCached.failed) return ctx.errorResponse(res, 500, 'Login recently failed');
|
||||
if (browserCached.token) {
|
||||
const resp = { token: browserCached.token };
|
||||
if (browserCached.tokenData) Object.assign(resp, browserCached.tokenData);
|
||||
return res.json(resp);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
||||
const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||
if (!username || !password) return ctx.errorResponse(res, 404, '[DC-500] No credentials stored');
|
||||
const service = await ctx.getServiceById(serviceId);
|
||||
const baseUrl = service?.url;
|
||||
if (!baseUrl) return ctx.errorResponse(res, 404, 'No service URL');
|
||||
const mediaAuth = buildMediaAuth(APP.DEVICE_IDS.BROWSER);
|
||||
const authResp = await ctx.fetchT(`${baseUrl}/Users/AuthenticateByName`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Emby-Authorization': mediaAuth },
|
||||
body: JSON.stringify({ Username: username, Pw: password }),
|
||||
}, TIMEOUTS.HTTP_LONG);
|
||||
const authData = await authResp.json();
|
||||
if (authData.AccessToken) {
|
||||
const tokenData = { userId: authData.User?.Id, serverId: authData.ServerId, serverName: authData.User?.ServerName || serviceId };
|
||||
appSessionCache.set(browserCacheKey, { token: authData.AccessToken, tokenData, exp: Date.now() + SESSION_TTL.TOKEN_SESSION });
|
||||
return res.json({ token: authData.AccessToken, ...tokenData });
|
||||
}
|
||||
return ctx.errorResponse(res, 500, '[DC-501] Authentication failed');
|
||||
} catch (e) {
|
||||
ctx.log.warn('auth', 'Browser token error', { serviceId, error: e.message });
|
||||
return ctx.errorResponse(res, 500, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cached = appSessionCache.get(serviceId);
|
||||
if (cached && cached.exp > Date.now()) {
|
||||
if (cached.failed) return ctx.errorResponse(res, 500, '[DC-501] Login recently failed, retrying in a few minutes');
|
||||
if (cached.token) {
|
||||
const resp = { token: cached.token };
|
||||
if (cached.tokenData) Object.assign(resp, cached.tokenData);
|
||||
return res.json(resp);
|
||||
}
|
||||
const m = cached.cookies.match(/^token=(.+)$/);
|
||||
if (m) return res.json({ token: m[1] });
|
||||
return res.json({ cookies: cached.cookies });
|
||||
}
|
||||
|
||||
// No cache — get fresh session
|
||||
try {
|
||||
const service = await ctx.getServiceById(serviceId);
|
||||
if (!service) return ctx.errorResponse(res, 404, 'Service not found');
|
||||
const baseUrl = service.externalUrl || service.url;
|
||||
if (!baseUrl) return ctx.errorResponse(res, 404, 'No service URL');
|
||||
|
||||
let username, password;
|
||||
if (service.isExternal) {
|
||||
username = await ctx.credentialManager.retrieve('seedhost.username').catch(() => null);
|
||||
const svcPass = await ctx.credentialManager.retrieve(`seedhost.password.${serviceId}`).catch(() => null);
|
||||
const sharedPass = await ctx.credentialManager.retrieve('seedhost.password').catch(() => null);
|
||||
password = svcPass || sharedPass;
|
||||
} else {
|
||||
username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
||||
password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||
}
|
||||
|
||||
if (!username || !password) return ctx.errorResponse(res, 404, '[DC-500] No credentials stored');
|
||||
|
||||
const appCookies = await getAppSession(serviceId, baseUrl, username, password);
|
||||
if (appCookies) {
|
||||
const freshCached = appSessionCache.get(serviceId);
|
||||
if (freshCached && freshCached.token) {
|
||||
const resp = { token: freshCached.token };
|
||||
if (freshCached.tokenData) Object.assign(resp, freshCached.tokenData);
|
||||
return res.json(resp);
|
||||
}
|
||||
const m = appCookies.match(/^token=(.+)$/);
|
||||
if (m) return res.json({ token: m[1] });
|
||||
return res.json({ cookies: appCookies });
|
||||
}
|
||||
|
||||
ctx.errorResponse(res, 500, '[DC-501] Login failed');
|
||||
} catch (e) {
|
||||
ctx.log.warn('auth', 'App-token error', { error: e.message });
|
||||
ctx.errorResponse(res, 500, e.message);
|
||||
}
|
||||
}, 'auth-app-token'));
|
||||
|
||||
return router;
|
||||
};
|
||||
185
dashcaddy-api/routes/auth/totp.js
Normal file
185
dashcaddy-api/routes/auth/totp.js
Normal file
@@ -0,0 +1,185 @@
|
||||
const express = require('express');
|
||||
|
||||
module.exports = function(ctx) {
|
||||
const router = express.Router();
|
||||
|
||||
// Get current TOTP config (public route)
|
||||
router.get('/totp/config', ctx.asyncHandler(async (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
enabled: ctx.totpConfig.enabled,
|
||||
sessionDuration: ctx.totpConfig.sessionDuration,
|
||||
isSetUp: ctx.totpConfig.isSetUp
|
||||
}
|
||||
});
|
||||
}, 'totp-config-get'));
|
||||
|
||||
// Generate new TOTP secret + QR code
|
||||
router.post('/totp/setup', ctx.asyncHandler(async (req, res) => {
|
||||
const { authenticator } = require('otplib');
|
||||
const QRCode = require('qrcode');
|
||||
|
||||
// Accept user-provided secret or generate a new one
|
||||
let secret;
|
||||
if (req.body && req.body.secret) {
|
||||
secret = req.body.secret.replace(/\s/g, '').toUpperCase();
|
||||
if (!/^[A-Z2-7]{16,}$/.test(secret)) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid secret key format. Must be a Base32 string (letters A-Z and digits 2-7).');
|
||||
}
|
||||
} else {
|
||||
secret = authenticator.generateSecret();
|
||||
}
|
||||
await ctx.credentialManager.store('totp.pending_secret', secret);
|
||||
|
||||
const otpauth = authenticator.keyuri('user', 'DashCaddy', secret);
|
||||
const qrDataUrl = await QRCode.toDataURL(otpauth, {
|
||||
width: 256, margin: 2,
|
||||
color: { dark: '#ffffff', light: '#00000000' }
|
||||
});
|
||||
|
||||
res.json({ success: true, qrCode: qrDataUrl, manualKey: secret, issuer: 'DashCaddy', imported: !!req.body?.secret });
|
||||
}, 'totp-setup'));
|
||||
|
||||
// Verify first code to confirm setup, then activate TOTP
|
||||
router.post('/totp/verify-setup', ctx.asyncHandler(async (req, res) => {
|
||||
const { authenticator } = require('otplib');
|
||||
const { code } = req.body;
|
||||
|
||||
if (!code || !/^\d{6}$/.test(code)) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid code format');
|
||||
}
|
||||
|
||||
const pendingSecret = await ctx.credentialManager.retrieve('totp.pending_secret');
|
||||
if (!pendingSecret) {
|
||||
return ctx.errorResponse(res, 400, 'No pending TOTP setup. Call /api/totp/setup first.');
|
||||
}
|
||||
|
||||
authenticator.options = { window: 1 };
|
||||
if (!authenticator.verify({ token: code, secret: pendingSecret })) {
|
||||
return ctx.errorResponse(res, 401, '[DC-111] Invalid code. Please try again.');
|
||||
}
|
||||
|
||||
// Promote pending secret to active
|
||||
await ctx.credentialManager.store('totp.secret', pendingSecret);
|
||||
await ctx.credentialManager.delete('totp.pending_secret');
|
||||
|
||||
ctx.totpConfig.isSetUp = true;
|
||||
ctx.totpConfig.enabled = true;
|
||||
ctx.totpConfig.secret = pendingSecret; // Persist to file for auto-restore
|
||||
if (ctx.totpConfig.sessionDuration === 'never') {
|
||||
ctx.totpConfig.sessionDuration = '24h';
|
||||
}
|
||||
await ctx.saveTotpConfig();
|
||||
|
||||
// Set session so user doesn't get locked out immediately
|
||||
ctx.session.create(req, ctx.totpConfig.sessionDuration);
|
||||
ctx.session.setCookie(res, ctx.totpConfig.sessionDuration);
|
||||
|
||||
res.json({ success: true, message: 'TOTP enabled successfully', sessionDuration: ctx.totpConfig.sessionDuration });
|
||||
}, 'totp-verify-setup'));
|
||||
|
||||
// Login: verify TOTP code and set session cookie
|
||||
router.post('/totp/verify', ctx.asyncHandler(async (req, res) => {
|
||||
const { authenticator } = require('otplib');
|
||||
const { code } = req.body;
|
||||
|
||||
if (!code || !/^\d{6}$/.test(code)) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid code format');
|
||||
}
|
||||
|
||||
if (!ctx.totpConfig.enabled || !ctx.totpConfig.isSetUp) {
|
||||
return ctx.errorResponse(res, 400, 'TOTP is not enabled');
|
||||
}
|
||||
|
||||
const secret = await ctx.credentialManager.retrieve('totp.secret');
|
||||
if (!secret) {
|
||||
return ctx.errorResponse(res, 500, 'TOTP secret not found');
|
||||
}
|
||||
|
||||
authenticator.options = { window: 1 };
|
||||
if (!authenticator.verify({ token: code, secret })) {
|
||||
return ctx.errorResponse(res, 401, '[DC-111] Invalid code');
|
||||
}
|
||||
|
||||
ctx.log.info('auth', 'TOTP verified, creating session', { ip: ctx.session.getClientIP(req), duration: ctx.totpConfig.sessionDuration });
|
||||
ctx.session.create(req, ctx.totpConfig.sessionDuration);
|
||||
ctx.session.setCookie(res, ctx.totpConfig.sessionDuration);
|
||||
ctx.log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
|
||||
res.json({ success: true, message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration });
|
||||
}, 'totp-verify'));
|
||||
|
||||
// Check session validity (used by Caddy forward_auth)
|
||||
router.get('/totp/check-session', ctx.asyncHandler(async (req, res) => {
|
||||
// Never cache session checks — stale cached 200s cause auth loops
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
|
||||
if (!ctx.totpConfig.enabled || ctx.totpConfig.sessionDuration === 'never') {
|
||||
return res.status(200).json({ authenticated: true });
|
||||
}
|
||||
|
||||
const valid = ctx.session.isValid(req);
|
||||
ctx.log.debug('auth', 'Session check', { ip: ctx.session.getClientIP(req), valid, sessions: ctx.session.ipSessions.size });
|
||||
if (valid) {
|
||||
return res.status(200).json({ authenticated: true });
|
||||
}
|
||||
|
||||
return ctx.errorResponse(res, 401, 'Session expired or invalid', { authenticated: false });
|
||||
}, 'totp-check-session'));
|
||||
|
||||
// Disable TOTP
|
||||
router.post('/totp/disable', ctx.asyncHandler(async (req, res) => {
|
||||
const { code } = req.body;
|
||||
|
||||
if (ctx.totpConfig.enabled && ctx.totpConfig.isSetUp && code) {
|
||||
const { authenticator } = require('otplib');
|
||||
const secret = await ctx.credentialManager.retrieve('totp.secret');
|
||||
if (secret) {
|
||||
authenticator.options = { window: 1 };
|
||||
if (!authenticator.verify({ token: code, secret })) {
|
||||
return ctx.errorResponse(res, 401, '[DC-111] Invalid code');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.credentialManager.delete('totp.secret');
|
||||
await ctx.credentialManager.delete('totp.pending_secret');
|
||||
|
||||
ctx.totpConfig.enabled = false;
|
||||
ctx.totpConfig.isSetUp = false;
|
||||
ctx.totpConfig.sessionDuration = 'never';
|
||||
delete ctx.totpConfig.secret; // Remove backup
|
||||
await ctx.saveTotpConfig();
|
||||
|
||||
ctx.session.clear(req);
|
||||
ctx.session.clearCookie(res);
|
||||
res.json({ success: true, message: 'TOTP disabled' });
|
||||
}, 'totp-disable'));
|
||||
|
||||
// Update TOTP settings (session duration)
|
||||
router.post('/totp/config', ctx.asyncHandler(async (req, res) => {
|
||||
const { sessionDuration } = req.body;
|
||||
|
||||
if (sessionDuration && !ctx.session.durations.hasOwnProperty(sessionDuration)) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid session duration', {
|
||||
validOptions: Object.keys(ctx.session.durations)
|
||||
});
|
||||
}
|
||||
|
||||
if (sessionDuration) {
|
||||
ctx.totpConfig.sessionDuration = sessionDuration;
|
||||
if (sessionDuration === 'never') {
|
||||
ctx.totpConfig.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.saveTotpConfig();
|
||||
res.json({
|
||||
success: true,
|
||||
config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp }
|
||||
});
|
||||
}, 'totp-config'));
|
||||
|
||||
return router;
|
||||
};
|
||||
Reference in New Issue
Block a user