You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
606 lines
20 KiB
JavaScript
606 lines
20 KiB
JavaScript
const config_api = require('../config');
|
|
const CONSTS = require('../consts');
|
|
const logger = require('../logger');
|
|
const db_api = require('../db');
|
|
|
|
const jwt = require('jsonwebtoken');
|
|
const { v4: uuid } = require('uuid');
|
|
const bcrypt = require('bcryptjs');
|
|
const fs = require('fs-extra');
|
|
const path = require('path');
|
|
|
|
var LocalStrategy = require('passport-local').Strategy;
|
|
var LdapStrategy = require('passport-ldapauth');
|
|
var JwtStrategy = require('passport-jwt').Strategy,
|
|
ExtractJwt = require('passport-jwt').ExtractJwt;
|
|
|
|
// other required vars
|
|
let SERVER_SECRET = null;
|
|
let JWT_EXPIRATION = null;
|
|
let opts = null;
|
|
let saltRounds = 10;
|
|
|
|
const SAFE_UID_PATTERN = /^[A-Za-z0-9._@-]+$/;
|
|
|
|
exports.initialize = function () {
|
|
/*************************
|
|
* Authentication module
|
|
************************/
|
|
|
|
if (db_api.database_initialized) {
|
|
setupRoles();
|
|
} else {
|
|
db_api.database_initialized_bs.subscribe(init => {
|
|
if (init) setupRoles();
|
|
});
|
|
}
|
|
|
|
// Sometimes this value is not properly typed: https://github.com/voc0der/YoutubeDL-Material/issues/813
|
|
JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration');
|
|
if (!(+JWT_EXPIRATION)) {
|
|
logger.warn(`JWT expiration value improperly set to ${JWT_EXPIRATION}, auto setting to 1 day.`);
|
|
JWT_EXPIRATION = 86400;
|
|
} else {
|
|
JWT_EXPIRATION = +JWT_EXPIRATION;
|
|
}
|
|
|
|
SERVER_SECRET = null;
|
|
if (db_api.users_db.get('jwt_secret').value()) {
|
|
SERVER_SECRET = db_api.users_db.get('jwt_secret').value();
|
|
} else {
|
|
SERVER_SECRET = uuid();
|
|
db_api.users_db.set('jwt_secret', SERVER_SECRET).write();
|
|
}
|
|
|
|
opts = {}
|
|
opts.jwtFromRequest = ExtractJwt.fromUrlQueryParameter('jwt');
|
|
opts.secretOrKey = SERVER_SECRET;
|
|
|
|
exports.passport.use(new JwtStrategy(opts, async function(jwt_payload, done) {
|
|
const user = await db_api.getRecord('users', {uid: jwt_payload.user});
|
|
if (user) {
|
|
return done(null, user);
|
|
} else {
|
|
return done(null, false);
|
|
// or you could create a new account
|
|
}
|
|
}));
|
|
}
|
|
|
|
const setupRoles = async () => {
|
|
const required_roles = {
|
|
admin: {
|
|
permissions: CONSTS.AVAILABLE_PERMISSIONS
|
|
},
|
|
user: {
|
|
permissions: [
|
|
'filemanager',
|
|
'subscriptions',
|
|
'sharing'
|
|
]
|
|
}
|
|
}
|
|
|
|
const role_keys = Object.keys(required_roles);
|
|
for (let i = 0; i < role_keys.length; i++) {
|
|
const role_key = role_keys[i];
|
|
const role_in_db = await db_api.getRecord('roles', {key: role_key});
|
|
if (!role_in_db) {
|
|
// insert task metadata into table if missing
|
|
await db_api.insertRecordIntoTable('roles', {
|
|
key: role_key,
|
|
permissions: required_roles[role_key]['permissions']
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
exports.passport = require('passport');
|
|
|
|
exports.passport.serializeUser(function(user, done) {
|
|
done(null, user);
|
|
});
|
|
|
|
exports.passport.deserializeUser(function(user, done) {
|
|
done(null, user);
|
|
});
|
|
|
|
/***************************************
|
|
* Register user with hashed password
|
|
**************************************/
|
|
|
|
exports.registerUser = async (userid, username, plaintextPassword) => {
|
|
const hash = await bcrypt.hash(plaintextPassword, saltRounds);
|
|
const new_user = generateUserObject(userid, username, hash);
|
|
// check if user exists
|
|
if (await db_api.getRecord('users', {uid: userid})) {
|
|
// user id is taken!
|
|
logger.error('Registration failed: UID is already taken!');
|
|
return null;
|
|
} else if (await db_api.getRecord('users', {name: username})) {
|
|
// user name is taken!
|
|
logger.error('Registration failed: User name is already taken!');
|
|
return null;
|
|
} else {
|
|
// add to db
|
|
await db_api.insertRecordIntoTable('users', new_user);
|
|
logger.verbose(`New user created: ${new_user.name}`);
|
|
return new_user;
|
|
}
|
|
}
|
|
|
|
function parseClaimPath(claims, claimPath) {
|
|
if (!claims || !claimPath || typeof claimPath !== 'string') return undefined;
|
|
const pathParts = claimPath.split('.').filter(part => part !== '');
|
|
if (pathParts.length === 0) return undefined;
|
|
|
|
let currentValue = claims;
|
|
for (const part of pathParts) {
|
|
if (!currentValue || typeof currentValue !== 'object' || !(part in currentValue)) {
|
|
return undefined;
|
|
}
|
|
currentValue = currentValue[part];
|
|
}
|
|
return currentValue;
|
|
}
|
|
|
|
function claimToArray(value) {
|
|
if (value === undefined || value === null) return [];
|
|
if (Array.isArray(value)) return value.map(v => String(v).trim()).filter(v => v.length > 0);
|
|
if (typeof value === 'string' && value.includes(',')) {
|
|
return value.split(',').map(v => v.trim()).filter(v => v.length > 0);
|
|
}
|
|
const normalized = String(value).trim();
|
|
return normalized ? [normalized] : [];
|
|
}
|
|
|
|
function valueIncludes(expectedValue, sourceValue) {
|
|
if (!expectedValue || expectedValue.length === 0) return false;
|
|
const expected = String(expectedValue).trim().toLowerCase();
|
|
if (!expected) return false;
|
|
return claimToArray(sourceValue).some(entry => entry.toLowerCase() === expected);
|
|
}
|
|
|
|
function claimValueToString(value) {
|
|
if (value === undefined || value === null) return '';
|
|
if (typeof value === 'string') return value.trim();
|
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
return '';
|
|
}
|
|
|
|
exports.sanitizeUserUID = (rawUID) => {
|
|
const input = claimValueToString(rawUID);
|
|
if (!input) return null;
|
|
if (input === '.' || input === '..') return null;
|
|
if (!SAFE_UID_PATTERN.test(input)) return null;
|
|
return input;
|
|
}
|
|
|
|
function getOIDCIdentityFromClaims(claims, usernameClaim) {
|
|
const fallbackClaims = [usernameClaim, 'preferred_username', 'username', 'email', 'sub'];
|
|
for (const claimName of fallbackClaims) {
|
|
if (!claimName) continue;
|
|
const claimValue = parseClaimPath(claims, claimName);
|
|
const parsed = claimValueToString(claimValue);
|
|
if (parsed) return parsed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
exports.createJWTForUser = function(user_uid) {
|
|
const payload = {
|
|
exp: Math.floor(Date.now() / 1000) + JWT_EXPIRATION,
|
|
user: user_uid
|
|
};
|
|
return jwt.sign(payload, SERVER_SECRET);
|
|
}
|
|
|
|
exports.getAuthResponseObject = async function(user) {
|
|
const token = exports.createJWTForUser(user.uid);
|
|
return {
|
|
user: user,
|
|
token: token,
|
|
permissions: await exports.userPermissions(user.uid),
|
|
available_permissions: CONSTS.AVAILABLE_PERMISSIONS
|
|
};
|
|
}
|
|
|
|
exports.upsertOIDCUser = async (claims, options = {}) => {
|
|
const username_claim = options.username_claim || 'preferred_username';
|
|
const display_name_claim = options.display_name_claim || username_claim;
|
|
const groups_claim = options.groups_claim || 'groups';
|
|
const admin_claim = options.admin_claim || 'groups';
|
|
const admin_value = options.admin_value || 'admin';
|
|
const auto_register = options.auto_register !== false;
|
|
|
|
const oidc_subject = claimValueToString(parseClaimPath(claims, 'sub'));
|
|
const login_name = getOIDCIdentityFromClaims(claims, username_claim);
|
|
const display_name = claimValueToString(parseClaimPath(claims, display_name_claim)) || login_name;
|
|
const uid_to_use = exports.sanitizeUserUID(login_name);
|
|
|
|
if (!uid_to_use || !display_name) {
|
|
logger.error('OIDC login rejected: Could not derive a valid uid/name from OIDC claims.');
|
|
return null;
|
|
}
|
|
|
|
const groups = claimToArray(parseClaimPath(claims, groups_claim));
|
|
const admin_claim_value = parseClaimPath(claims, admin_claim);
|
|
const role = valueIncludes(admin_value, admin_claim_value) ? 'admin' : 'user';
|
|
|
|
let user_obj = null;
|
|
if (oidc_subject) {
|
|
user_obj = await db_api.getRecord('users', {oidc_subject: oidc_subject});
|
|
}
|
|
if (!user_obj) {
|
|
user_obj = await db_api.getRecord('users', {uid: uid_to_use});
|
|
}
|
|
if (!user_obj) {
|
|
user_obj = await db_api.getRecord('users', {name: display_name});
|
|
}
|
|
|
|
if (!user_obj) {
|
|
if (!auto_register) {
|
|
logger.error(`OIDC login rejected: user '${uid_to_use}' does not exist and auto registration is disabled.`);
|
|
return null;
|
|
}
|
|
user_obj = generateUserObject(uid_to_use, display_name, null, 'oidc');
|
|
user_obj.role = role;
|
|
user_obj.oidc_subject = oidc_subject || null;
|
|
user_obj.oidc_groups = groups;
|
|
const inserted = await db_api.insertRecordIntoTable('users', user_obj);
|
|
if (!inserted) {
|
|
logger.error(`OIDC login failed: could not create user '${uid_to_use}'.`);
|
|
return null;
|
|
}
|
|
return await db_api.getRecord('users', {uid: uid_to_use});
|
|
}
|
|
|
|
if (oidc_subject && user_obj.oidc_subject && user_obj.oidc_subject !== oidc_subject) {
|
|
logger.error(`OIDC login rejected: existing user '${user_obj.uid}' is mapped to a different subject.`);
|
|
return null;
|
|
}
|
|
|
|
const updated_user_values = {
|
|
name: display_name,
|
|
role: role,
|
|
auth_method: 'oidc',
|
|
oidc_groups: groups
|
|
};
|
|
if (oidc_subject) updated_user_values['oidc_subject'] = oidc_subject;
|
|
|
|
const updated = await db_api.updateRecord('users', {uid: user_obj.uid}, updated_user_values);
|
|
if (!updated) {
|
|
logger.error(`OIDC login failed: could not update user '${user_obj.uid}'.`);
|
|
return null;
|
|
}
|
|
return await db_api.getRecord('users', {uid: user_obj.uid});
|
|
}
|
|
|
|
exports.deleteUser = async (uid) => {
|
|
let success = false;
|
|
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
|
|
const usersBaseFolder = path.join(__dirname, usersFileFolder);
|
|
const user_folder = path.join(usersBaseFolder, uid);
|
|
const relativeUserFolder = path.relative(usersBaseFolder, user_folder);
|
|
if (relativeUserFolder.startsWith('..') || path.isAbsolute(relativeUserFolder)) {
|
|
logger.error(`Refusing to delete user folder with unsafe uid path: ${uid}`);
|
|
return false;
|
|
}
|
|
const user_db_obj = await db_api.getRecord('users', {uid: uid});
|
|
if (user_db_obj) {
|
|
// user exists, let's delete
|
|
await fs.remove(user_folder);
|
|
await db_api.removeRecord('users', {uid: uid});
|
|
success = true;
|
|
} else {
|
|
logger.error(`Could not find user with uid ${uid}`);
|
|
}
|
|
return success;
|
|
}
|
|
|
|
/***************************************
|
|
* Login methods
|
|
**************************************/
|
|
|
|
/*************************************************
|
|
* This gets called when passport.authenticate()
|
|
* gets called.
|
|
*
|
|
* This checks that the credentials are valid.
|
|
* If so, passes the user info to the next middleware.
|
|
************************************************/
|
|
|
|
|
|
exports.login = async (username, password) => {
|
|
// even if we're using LDAP, we still want users to be able to login using internal credentials
|
|
const user = await db_api.getRecord('users', {name: username});
|
|
if (!user) {
|
|
if (config_api.getConfigItem('ytdl_auth_method') === 'internal') logger.error(`User ${username} not found`);
|
|
return false;
|
|
}
|
|
if (user.auth_method && user.auth_method !== 'internal') { return false }
|
|
return await bcrypt.compare(password, user.passhash) ? user : false;
|
|
}
|
|
|
|
exports.passport.use(new LocalStrategy({
|
|
usernameField: 'username',
|
|
passwordField: 'password'},
|
|
async function(username, password, done) {
|
|
return done(null, await exports.login(username, password));
|
|
}
|
|
));
|
|
|
|
var getLDAPConfiguration = function(req, callback) {
|
|
const ldap_config = config_api.getConfigItem('ytdl_ldap_config');
|
|
const opts = {server: ldap_config};
|
|
callback(null, opts);
|
|
};
|
|
|
|
exports.passport.use(new LdapStrategy(getLDAPConfiguration,
|
|
async function(user, done) {
|
|
// check if ldap auth is enabled
|
|
const ldap_enabled = config_api.getConfigItem('ytdl_auth_method') === 'ldap';
|
|
if (!ldap_enabled) return done(null, false);
|
|
|
|
const user_uid = user.uid;
|
|
let db_user = await db_api.getRecord('users', {uid: user_uid});
|
|
if (!db_user) {
|
|
// generate DB user
|
|
let new_user = generateUserObject(user_uid, user_uid, null, 'ldap');
|
|
await db_api.insertRecordIntoTable('users', new_user);
|
|
db_user = new_user;
|
|
logger.verbose(`Generated new user ${user_uid} using LDAP`);
|
|
}
|
|
return done(null, db_user);
|
|
}
|
|
));
|
|
|
|
|
|
/**********************************
|
|
* Generating/Signing a JWT token
|
|
* And attaches the user info into
|
|
* the payload to be sent on every
|
|
* request.
|
|
*********************************/
|
|
exports.generateJWT = function(req, res, next) {
|
|
req.token = exports.createJWTForUser(req.user.uid);
|
|
next();
|
|
}
|
|
|
|
exports.returnAuthResponse = async function(req, res) {
|
|
const auth_response = await exports.getAuthResponseObject(req.user);
|
|
auth_response.token = req.token;
|
|
res.status(200).json(auth_response);
|
|
}
|
|
|
|
/***************************************
|
|
* Authorization: middleware that checks the
|
|
* JWT token for validity before allowing
|
|
* the user to access anything.
|
|
*
|
|
* It also passes the user object to the next
|
|
* middleware through res.locals
|
|
**************************************/
|
|
exports.ensureAuthenticatedElseError = (req, res, next) => {
|
|
var token = getToken(req.query);
|
|
if( token ) {
|
|
try {
|
|
var payload = jwt.verify(token, SERVER_SECRET);
|
|
// console.log('payload: ' + JSON.stringify(payload));
|
|
// check if user still exists in database if you'd like
|
|
res.locals.user = payload.user;
|
|
next();
|
|
} catch(err) {
|
|
res.status(401).send('Invalid Authentication');
|
|
}
|
|
} else {
|
|
res.status(401).send('Missing Authorization header');
|
|
}
|
|
}
|
|
|
|
// change password
|
|
exports.changeUserPassword = async (user_uid, new_pass) => {
|
|
try {
|
|
const hash = await bcrypt.hash(new_pass, saltRounds);
|
|
await db_api.updateRecord('users', {uid: user_uid}, {passhash: hash});
|
|
return true;
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// change user permissions
|
|
exports.changeUserPermissions = async (user_uid, permission, new_value) => {
|
|
try {
|
|
await db_api.pullFromRecordsArray('users', {uid: user_uid}, 'permissions', permission);
|
|
await db_api.pullFromRecordsArray('users', {uid: user_uid}, 'permission_overrides', permission);
|
|
if (new_value === 'yes') {
|
|
await db_api.pushToRecordsArray('users', {uid: user_uid}, 'permissions', permission);
|
|
await db_api.pushToRecordsArray('users', {uid: user_uid}, 'permission_overrides', permission);
|
|
} else if (new_value === 'no') {
|
|
await db_api.pushToRecordsArray('users', {uid: user_uid}, 'permission_overrides', permission);
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
logger.error(err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// change role permissions
|
|
exports.changeRolePermissions = async (role, permission, new_value) => {
|
|
try {
|
|
await db_api.pullFromRecordsArray('roles', {key: role}, 'permissions', permission);
|
|
if (new_value === 'yes') {
|
|
await db_api.pushToRecordsArray('roles', {key: role}, 'permissions', permission);
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
logger.error(err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
exports.adminExists = async function() {
|
|
return !!(await db_api.getRecord('users', {uid: 'admin'}));
|
|
}
|
|
|
|
// video stuff
|
|
|
|
exports.getUserVideos = async function(user_uid, type) {
|
|
const files = await db_api.getRecords('files', {user_uid: user_uid});
|
|
return type ? files.filter(file => file.isAudio === (type === 'audio')) : files;
|
|
}
|
|
|
|
exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false) {
|
|
const filter_obj = {uid: file_uid};
|
|
if (config_api.getConfigItem('ytdl_multi_user_mode') && user_uid !== null && user_uid !== undefined) {
|
|
filter_obj['user_uid'] = user_uid;
|
|
}
|
|
let file = await db_api.getRecord('files', filter_obj);
|
|
|
|
// prevent unauthorized users from accessing the file info
|
|
if (file && !file['sharingEnabled'] && requireSharing) file = null;
|
|
|
|
return file;
|
|
}
|
|
|
|
exports.removePlaylist = async function(user_uid, playlistID) {
|
|
await db_api.removeRecord('playlist', {playlistID: playlistID});
|
|
return true;
|
|
}
|
|
|
|
exports.getUserPlaylists = async function(user_uid) {
|
|
return await db_api.getRecords('playlists', {user_uid: user_uid});
|
|
}
|
|
|
|
exports.getUserPlaylist = async function(user_uid, playlistID, requireSharing = false) {
|
|
const filter_obj = {id: playlistID};
|
|
if (config_api.getConfigItem('ytdl_multi_user_mode') && user_uid !== null && user_uid !== undefined) {
|
|
filter_obj['user_uid'] = user_uid;
|
|
}
|
|
let playlist = await db_api.getRecord('playlists', filter_obj);
|
|
|
|
// prevent unauthorized users from accessing the file info
|
|
if (requireSharing && (!playlist || !playlist['sharingEnabled'])) playlist = null;
|
|
|
|
return playlist;
|
|
}
|
|
|
|
exports.changeSharingMode = async function(user_uid, file_uid, is_playlist, enabled) {
|
|
let success = false;
|
|
is_playlist ? await db_api.updateRecord(`playlists`, {id: file_uid}, {sharingEnabled: enabled}) : await db_api.updateRecord(`files`, {uid: file_uid}, {sharingEnabled: enabled});
|
|
success = true;
|
|
return success;
|
|
}
|
|
|
|
exports.userHasPermission = async function(user_uid, permission) {
|
|
|
|
const user_obj = await db_api.getRecord('users', ({uid: user_uid}));
|
|
const role = user_obj['role'];
|
|
if (!role) {
|
|
// role doesn't exist
|
|
logger.error('Invalid role ' + role);
|
|
return false;
|
|
}
|
|
|
|
const user_has_explicit_permission = user_obj['permissions'].includes(permission);
|
|
const permission_in_overrides = user_obj['permission_overrides'].includes(permission);
|
|
|
|
// check if user has a negative/positive override
|
|
if (user_has_explicit_permission && permission_in_overrides) {
|
|
// positive override
|
|
return true;
|
|
} else if (!user_has_explicit_permission && permission_in_overrides) {
|
|
// negative override
|
|
return false;
|
|
}
|
|
|
|
// no overrides, let's check if the role has the permission
|
|
const role_has_permission = await exports.roleHasPermissions(role, permission);
|
|
if (role_has_permission) {
|
|
return true;
|
|
} else {
|
|
logger.verbose(`User ${user_uid} failed to get permission ${permission}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
exports.roleHasPermissions = async function(role, permission) {
|
|
const role_obj = await db_api.getRecord('roles', {key: role})
|
|
if (!role) {
|
|
logger.error(`Role ${role} does not exist!`);
|
|
}
|
|
const role_permissions = role_obj['permissions'];
|
|
if (role_permissions && role_permissions.includes(permission)) return true;
|
|
else return false;
|
|
}
|
|
|
|
exports.userPermissions = async function(user_uid) {
|
|
let user_permissions = [];
|
|
const user_obj = await db_api.getRecord('users', ({uid: user_uid}));
|
|
const role = user_obj['role'];
|
|
if (!role) {
|
|
// role doesn't exist
|
|
logger.error('Invalid role ' + role);
|
|
return null;
|
|
}
|
|
const role_obj = await db_api.getRecord('roles', {key: role});
|
|
const role_permissions = role_obj['permissions'];
|
|
|
|
for (let i = 0; i < CONSTS.AVAILABLE_PERMISSIONS.length; i++) {
|
|
let permission = CONSTS.AVAILABLE_PERMISSIONS[i];
|
|
|
|
const user_has_explicit_permission = user_obj['permissions'].includes(permission);
|
|
const permission_in_overrides = user_obj['permission_overrides'].includes(permission);
|
|
|
|
// check if user has a negative/positive override
|
|
if (user_has_explicit_permission && permission_in_overrides) {
|
|
// positive override
|
|
user_permissions.push(permission);
|
|
} else if (!user_has_explicit_permission && permission_in_overrides) {
|
|
// negative override
|
|
continue;
|
|
}
|
|
|
|
// no overrides, let's check if the role has the permission
|
|
if (role_permissions.includes(permission)) {
|
|
user_permissions.push(permission);
|
|
} else {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return user_permissions;
|
|
}
|
|
|
|
function getToken(queryParams) {
|
|
if (queryParams && queryParams.jwt) {
|
|
var parted = queryParams.jwt.split(' ');
|
|
if (parted.length === 2) {
|
|
return parted[1];
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
function generateUserObject(userid, username, hash, auth_method = 'internal') {
|
|
let new_user = {
|
|
name: username,
|
|
uid: userid,
|
|
passhash: auth_method === 'internal' ? hash : null,
|
|
files: [],
|
|
playlists: [],
|
|
subscriptions: [],
|
|
created: Date.now(),
|
|
role: userid === 'admin' && auth_method === 'internal' ? 'admin' : 'user',
|
|
permissions: [],
|
|
permission_overrides: [],
|
|
auth_method: auth_method
|
|
};
|
|
return new_user;
|
|
}
|