mirror of
https://github.com/Ylianst/MeshCentral.git
synced 2025-02-12 11:01:52 +00:00
Fixed auth strategies when using with a second domain with a DNS (#4404)
This commit is contained in:
parent
0edf6883f2
commit
4092615c63
1 changed files with 299 additions and 215 deletions
514
webserver.js
514
webserver.js
|
@ -6021,6 +6021,23 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
|
||||||
if ((parent.config.domains[i].dns == null) && (parent.config.domains[i].share != null)) { obj.app.use(parent.config.domains[i].url, obj.express.static(parent.config.domains[i].share)); }
|
if ((parent.config.domains[i].dns == null) && (parent.config.domains[i].share != null)) { obj.app.use(parent.config.domains[i].url, obj.express.static(parent.config.domains[i].share)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup all domain auth strategy passport.js
|
||||||
|
for (var i in parent.config.domains) {
|
||||||
|
if (typeof parent.config.domains[i].authstrategies == 'object') {
|
||||||
|
parent.config.domains[i].authstrategies.authStrategyFlags = 0;
|
||||||
|
const authStrategyFlags = setupDomainAuthStrategy(parent.config.domains[i]);
|
||||||
|
if (authStrategyFlags > 0) {
|
||||||
|
if (parent.config.domains[i].dns != null) {
|
||||||
|
if (typeof parent.config.domains[''].authstrategies != 'object') { parent.config.domains[''].authstrategies = { authStrategyFlags: 0 }; }
|
||||||
|
parent.config.domains[''].authstrategies.authStrategyFlags |= authStrategyFlags;
|
||||||
|
} else {
|
||||||
|
if (typeof parent.config.domains[i].authstrategies != 'object') { parent.config.domains[i].authstrategies = { authStrategyFlags: 0 }; }
|
||||||
|
parent.config.domains[i].authstrategies.authStrategyFlags |= authStrategyFlags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Setup all HTTP handlers
|
// Setup all HTTP handlers
|
||||||
if (parent.multiServer != null) { obj.app.ws('/meshserver.ashx', function (ws, req) { parent.multiServer.CreatePeerInServer(parent.multiServer, ws, req, obj.args.tlsoffload == null); }); }
|
if (parent.multiServer != null) { obj.app.ws('/meshserver.ashx', function (ws, req) { parent.multiServer.CreatePeerInServer(parent.multiServer, ws, req, obj.args.tlsoffload == null); }); }
|
||||||
for (var i in parent.config.domains) {
|
for (var i in parent.config.domains) {
|
||||||
|
@ -6201,26 +6218,8 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
|
||||||
|
|
||||||
// Setup auth strategies using passport if needed
|
// Setup auth strategies using passport if needed
|
||||||
if (typeof domain.authstrategies == 'object') {
|
if (typeof domain.authstrategies == 'object') {
|
||||||
const passport = domain.passport = require('passport');
|
|
||||||
passport.serializeUser(function (user, done) { done(null, user.sid); });
|
|
||||||
passport.deserializeUser(function (sid, done) { done(null, { sid: sid }); });
|
|
||||||
obj.app.use(passport.initialize());
|
|
||||||
//obj.app.use(passport.session());
|
|
||||||
|
|
||||||
// Twitter
|
// Twitter
|
||||||
if ((typeof domain.authstrategies.twitter == 'object') && (typeof domain.authstrategies.twitter.clientid == 'string') && (typeof domain.authstrategies.twitter.clientsecret == 'string')) {
|
if ((domain.authstrategies.authStrategyFlags & domainAuthStrategyConsts.twitter) != 0) {
|
||||||
const TwitterStrategy = require('passport-twitter');
|
|
||||||
var options = { consumerKey: domain.authstrategies.twitter.clientid, consumerSecret: domain.authstrategies.twitter.clientsecret };
|
|
||||||
if (typeof domain.authstrategies.twitter.callbackurl == 'string') { options.callbackURL = domain.authstrategies.twitter.callbackurl; } else { options.callbackURL = url + 'auth-twitter-callback'; }
|
|
||||||
parent.debug('web', 'Adding Twitter SSO with options: ' + JSON.stringify(options));
|
|
||||||
passport.use('twitter-' + domain.id, new TwitterStrategy(options,
|
|
||||||
function (token, tokenSecret, profile, cb) {
|
|
||||||
parent.debug('web', 'Twitter profile: ' + JSON.stringify(profile));
|
|
||||||
var user = { sid: '~twitter:' + profile.id, name: profile.displayName, strategy: 'twitter' };
|
|
||||||
if ((typeof profile.emails == 'object') && (profile.emails[0] != null) && (typeof profile.emails[0].value == 'string')) { user.email = profile.emails[0].value; }
|
|
||||||
return cb(null, user);
|
|
||||||
}
|
|
||||||
));
|
|
||||||
obj.app.get(url + 'auth-twitter', function (req, res, next) {
|
obj.app.get(url + 'auth-twitter', function (req, res, next) {
|
||||||
var domain = getDomain(req);
|
var domain = getDomain(req);
|
||||||
if (domain.passport == null) { next(); return; }
|
if (domain.passport == null) { next(); return; }
|
||||||
|
@ -6242,19 +6241,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
|
||||||
}
|
}
|
||||||
|
|
||||||
// Google
|
// Google
|
||||||
if ((typeof domain.authstrategies.google == 'object') && (typeof domain.authstrategies.google.clientid == 'string') && (typeof domain.authstrategies.google.clientsecret == 'string')) {
|
if ((domain.authstrategies.authStrategyFlags & domainAuthStrategyConsts.google) != 0) {
|
||||||
const GoogleStrategy = require('passport-google-oauth20');
|
|
||||||
var options = { clientID: domain.authstrategies.google.clientid, clientSecret: domain.authstrategies.google.clientsecret };
|
|
||||||
if (typeof domain.authstrategies.google.callbackurl == 'string') { options.callbackURL = domain.authstrategies.google.callbackurl; } else { options.callbackURL = url + 'auth-google-callback'; }
|
|
||||||
parent.debug('web', 'Adding Google SSO with options: ' + JSON.stringify(options));
|
|
||||||
passport.use('google-' + domain.id, new GoogleStrategy(options,
|
|
||||||
function (token, tokenSecret, profile, cb) {
|
|
||||||
parent.debug('web', 'Google profile: ' + JSON.stringify(profile));
|
|
||||||
var user = { sid: '~google:' + profile.id, name: profile.displayName, strategy: 'google' };
|
|
||||||
if ((typeof profile.emails == 'object') && (profile.emails[0] != null) && (typeof profile.emails[0].value == 'string') && (profile.emails[0].verified == true)) { user.email = profile.emails[0].value; }
|
|
||||||
return cb(null, user);
|
|
||||||
}
|
|
||||||
));
|
|
||||||
obj.app.get(url + 'auth-google', function (req, res, next) {
|
obj.app.get(url + 'auth-google', function (req, res, next) {
|
||||||
var domain = getDomain(req);
|
var domain = getDomain(req);
|
||||||
if (domain.passport == null) { next(); return; }
|
if (domain.passport == null) { next(); return; }
|
||||||
|
@ -6267,20 +6254,8 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
|
||||||
}, handleStrategyLogin);
|
}, handleStrategyLogin);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Github
|
// GitHub
|
||||||
if ((typeof domain.authstrategies.github == 'object') && (typeof domain.authstrategies.github.clientid == 'string') && (typeof domain.authstrategies.github.clientsecret == 'string')) {
|
if ((domain.authstrategies.authStrategyFlags & domainAuthStrategyConsts.github) != 0) {
|
||||||
const GitHubStrategy = require('passport-github2');
|
|
||||||
var options = { clientID: domain.authstrategies.github.clientid, clientSecret: domain.authstrategies.github.clientsecret };
|
|
||||||
if (typeof domain.authstrategies.github.callbackurl == 'string') { options.callbackURL = domain.authstrategies.github.callbackurl; } else { options.callbackURL = url + 'auth-github-callback'; }
|
|
||||||
parent.debug('web', 'Adding Github SSO with options: ' + JSON.stringify(options));
|
|
||||||
passport.use('github-' + domain.id, new GitHubStrategy(options,
|
|
||||||
function (token, tokenSecret, profile, cb) {
|
|
||||||
parent.debug('web', 'Github profile: ' + JSON.stringify(profile));
|
|
||||||
var user = { sid: '~github:' + profile.id, name: profile.displayName, strategy: 'github' };
|
|
||||||
if ((typeof profile.emails == 'object') && (profile.emails[0] != null) && (typeof profile.emails[0].value == 'string')) { user.email = profile.emails[0].value; }
|
|
||||||
return cb(null, user);
|
|
||||||
}
|
|
||||||
));
|
|
||||||
obj.app.get(url + 'auth-github', function (req, res, next) {
|
obj.app.get(url + 'auth-github', function (req, res, next) {
|
||||||
var domain = getDomain(req);
|
var domain = getDomain(req);
|
||||||
if (domain.passport == null) { next(); return; }
|
if (domain.passport == null) { next(); return; }
|
||||||
|
@ -6294,19 +6269,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reddit
|
// Reddit
|
||||||
if ((typeof domain.authstrategies.reddit == 'object') && (typeof domain.authstrategies.reddit.clientid == 'string') && (typeof domain.authstrategies.reddit.clientsecret == 'string')) {
|
if ((domain.authstrategies.authStrategyFlags & domainAuthStrategyConsts.reddit) != 0) {
|
||||||
const RedditStrategy = require('passport-reddit');
|
|
||||||
var options = { clientID: domain.authstrategies.reddit.clientid, clientSecret: domain.authstrategies.reddit.clientsecret };
|
|
||||||
if (typeof domain.authstrategies.reddit.callbackurl == 'string') { options.callbackURL = domain.authstrategies.reddit.callbackurl; } else { options.callbackURL = url + 'auth-reddit-callback'; }
|
|
||||||
parent.debug('web', 'Adding Reddit SSO with options: ' + JSON.stringify(options));
|
|
||||||
passport.use('reddit-' + domain.id, new RedditStrategy.Strategy(options,
|
|
||||||
function (token, tokenSecret, profile, cb) {
|
|
||||||
parent.debug('web', 'Reddit profile: ' + JSON.stringify(profile));
|
|
||||||
var user = { sid: '~reddit:' + profile.id, name: profile.name, strategy: 'reddit' };
|
|
||||||
if ((typeof profile.emails == 'object') && (profile.emails[0] != null) && (typeof profile.emails[0].value == 'string')) { user.email = profile.emails[0].value; }
|
|
||||||
return cb(null, user);
|
|
||||||
}
|
|
||||||
));
|
|
||||||
obj.app.get(url + 'auth-reddit', function (req, res, next) {
|
obj.app.get(url + 'auth-reddit', function (req, res, next) {
|
||||||
var domain = getDomain(req);
|
var domain = getDomain(req);
|
||||||
if (domain.passport == null) { next(); return; }
|
if (domain.passport == null) { next(); return; }
|
||||||
|
@ -6332,24 +6295,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
|
||||||
}
|
}
|
||||||
|
|
||||||
// Azure
|
// Azure
|
||||||
if ((typeof domain.authstrategies.azure == 'object') && (typeof domain.authstrategies.azure.clientid == 'string') && (typeof domain.authstrategies.azure.clientsecret == 'string')) {
|
if ((domain.authstrategies.authStrategyFlags & domainAuthStrategyConsts.azure) != 0) {
|
||||||
const AzureOAuth2Strategy = require('passport-azure-oauth2');
|
|
||||||
var options = { clientID: domain.authstrategies.azure.clientid, clientSecret: domain.authstrategies.azure.clientsecret, tenant: domain.authstrategies.azure.tenantid };
|
|
||||||
if (typeof domain.authstrategies.azure.callbackurl == 'string') { options.callbackURL = domain.authstrategies.azure.callbackurl; } else { options.callbackURL = url + 'auth-azure-callback'; }
|
|
||||||
parent.debug('web', 'Adding Azure SSO with options: ' + JSON.stringify(options));
|
|
||||||
passport.use('azure-' + domain.id, new AzureOAuth2Strategy(options,
|
|
||||||
function (accessToken, refreshtoken, params, profile, done) {
|
|
||||||
var userex = null;
|
|
||||||
try { userex = require('jwt-simple').decode(params.id_token, "", true); } catch (ex) { }
|
|
||||||
parent.debug('web', 'Azure profile: ' + JSON.stringify(userex));
|
|
||||||
var user = null;
|
|
||||||
if (userex != null) {
|
|
||||||
var user = { sid: '~azure:' + userex.unique_name, name: userex.name, strategy: 'azure' };
|
|
||||||
if (typeof userex.email == 'string') { user.email = userex.email; }
|
|
||||||
}
|
|
||||||
return done(null, user);
|
|
||||||
}
|
|
||||||
));
|
|
||||||
obj.app.get(url + 'auth-azure', function (req, res, next) {
|
obj.app.get(url + 'auth-azure', function (req, res, next) {
|
||||||
var domain = getDomain(req);
|
var domain = getDomain(req);
|
||||||
if (domain.passport == null) { next(); return; }
|
if (domain.passport == null) { next(); return; }
|
||||||
|
@ -6374,155 +6320,52 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
|
||||||
}, handleStrategyLogin);
|
}, handleStrategyLogin);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic OpenID Connect
|
// Generic OpenID
|
||||||
if ((typeof domain.authstrategies.oidc == 'object') && (typeof domain.authstrategies.oidc.clientid == 'string') && (typeof domain.authstrategies.oidc.clientsecret == 'string') && (typeof domain.authstrategies.oidc.issuer == 'string')) {
|
if (domain.authstrategies.authStrategyFlags & domainAuthStrategyConsts.openid != 0) {
|
||||||
var options = {
|
|
||||||
authorizationURL: domain.authstrategies.oidc.authorizationurl,
|
|
||||||
callbackURL: domain.authstrategies.oidc.callbackurl,
|
|
||||||
clientID: domain.authstrategies.oidc.clientid,
|
|
||||||
clientSecret: domain.authstrategies.oidc.clientsecret,
|
|
||||||
issuer: domain.authstrategies.oidc.issuer,
|
|
||||||
tokenURL: domain.authstrategies.oidc.tokenurl,
|
|
||||||
userInfoURL: domain.authstrategies.oidc.userinfourl,
|
|
||||||
scope: ['openid profile email'],
|
|
||||||
responseMode: 'form_post',
|
|
||||||
state: true
|
|
||||||
};
|
|
||||||
const OIDCStrategy = require('@mstrhakr/passport-generic-oidc');
|
|
||||||
if (typeof domain.authstrategies.oidc.callbackurl == 'string') { options.callbackURL = domain.authstrategies.oidc.callbackurl; } else { options.callbackURL = url + 'oidc-callback'; }
|
|
||||||
parent.debug('web', 'Adding Generic OIDC SSO with options: ' + JSON.stringify(options));
|
|
||||||
passport.use('openidconnect', new OIDCStrategy.Strategy(options,
|
|
||||||
function verify(iss, sub, profile, cb) {
|
|
||||||
var user = { sid: '~oidc:' + profile.id, name: profile.displayName, email: profile.email, strategy: 'oidc' };
|
|
||||||
parent.debug('AUTH', 'OIDC: Configured user: ' + JSON.stringify(user));
|
|
||||||
return cb(null, user);
|
|
||||||
}
|
|
||||||
));
|
|
||||||
obj.app.get(url + 'auth-oidc', domain.passport.authenticate('openidconnect'));
|
obj.app.get(url + 'auth-oidc', domain.passport.authenticate('openidconnect'));
|
||||||
obj.app.get(url + 'oidc-callback', domain.passport.authenticate('openidconnect', { failureRedirect: '/login?failed-auth-attempt', failureFlash: true }), handleStrategyLogin);
|
obj.app.get(url + 'oidc-callback', domain.passport.authenticate('openidconnect', { failureRedirect: '/login?failed-auth-attempt', failureFlash: true }), handleStrategyLogin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Generic SAML
|
// Generic SAML
|
||||||
if (typeof domain.authstrategies.saml == 'object') {
|
if (domain.authstrategies.authStrategyFlags & domainAuthStrategyConsts.saml != 0) {
|
||||||
if ((typeof domain.authstrategies.saml.cert != 'string') || (typeof domain.authstrategies.saml.idpurl != 'string')) {
|
obj.app.get(url + 'auth-saml', function (req, res, next) {
|
||||||
console.log('ERROR: Missing SAML configuration.');
|
var domain = getDomain(req);
|
||||||
} else {
|
if (domain.passport == null) { next(); return; }
|
||||||
const certPath = obj.common.joinPath(obj.parent.datapath, domain.authstrategies.saml.cert);
|
domain.passport.authenticate('saml-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next);
|
||||||
var cert = obj.fs.readFileSync(certPath);
|
});
|
||||||
if (cert == null) {
|
obj.app.post(url + 'auth-saml-callback', obj.bodyParser.urlencoded({ extended: false }), function (req, res, next) {
|
||||||
console.log('ERROR: Unable to read SAML IdP certificate: ' + domain.authstrategies.saml.cert);
|
var domain = getDomain(req);
|
||||||
} else {
|
if (domain.passport == null) { next(); return; }
|
||||||
var options = { entryPoint: domain.authstrategies.saml.idpurl, issuer: 'meshcentral' };
|
domain.passport.authenticate('saml-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next);
|
||||||
if (typeof domain.authstrategies.saml.callbackurl == 'string') { options.callbackUrl = domain.authstrategies.saml.callbackurl; } else { options.callbackUrl = url + 'auth-saml-callback'; }
|
}, handleStrategyLogin);
|
||||||
if (domain.authstrategies.saml.disablerequestedauthncontext != null) { options.disableRequestedAuthnContext = domain.authstrategies.saml.disablerequestedauthncontext; }
|
|
||||||
if (typeof domain.authstrategies.saml.entityid == 'string') { options.issuer = domain.authstrategies.saml.entityid; }
|
|
||||||
parent.debug('web', 'Adding SAML SSO with options: ' + JSON.stringify(options));
|
|
||||||
options.cert = cert.toString().split('-----BEGIN CERTIFICATE-----').join('').split('-----END CERTIFICATE-----').join('');
|
|
||||||
const SamlStrategy = require('passport-saml').Strategy;
|
|
||||||
passport.use('saml-' + domain.id, new SamlStrategy(options,
|
|
||||||
function (profile, done) {
|
|
||||||
parent.debug('web', 'SAML profile: ' + JSON.stringify(profile));
|
|
||||||
if (typeof profile.nameID != 'string') { return done(); }
|
|
||||||
var user = { sid: '~saml:' + profile.nameID, name: profile.nameID, strategy: 'saml' };
|
|
||||||
if ((typeof profile.firstname == 'string') && (typeof profile.lastname == 'string')) { user.name = profile.firstname + ' ' + profile.lastname; }
|
|
||||||
if (typeof profile.email == 'string') { user.email = profile.email; }
|
|
||||||
return done(null, user);
|
|
||||||
}
|
|
||||||
));
|
|
||||||
obj.app.get(url + 'auth-saml', function (req, res, next) {
|
|
||||||
var domain = getDomain(req);
|
|
||||||
if (domain.passport == null) { next(); return; }
|
|
||||||
domain.passport.authenticate('saml-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next);
|
|
||||||
});
|
|
||||||
obj.app.post(url + 'auth-saml-callback', obj.bodyParser.urlencoded({ extended: false }), function (req, res, next) {
|
|
||||||
var domain = getDomain(req);
|
|
||||||
if (domain.passport == null) { next(); return; }
|
|
||||||
domain.passport.authenticate('saml-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next);
|
|
||||||
}, handleStrategyLogin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intel SAML
|
// Intel SAML
|
||||||
if (typeof domain.authstrategies.intel == 'object') {
|
if (domain.authstrategies.authStrategyFlags & domainAuthStrategyConsts.intelSaml != 0) {
|
||||||
if ((typeof domain.authstrategies.intel.cert != 'string') || (typeof domain.authstrategies.intel.idpurl != 'string')) {
|
obj.app.get(url + 'auth-intel', function (req, res, next) {
|
||||||
console.log('ERROR: Missing Intel SAML configuration.');
|
var domain = getDomain(req);
|
||||||
} else {
|
if (domain.passport == null) { next(); return; }
|
||||||
var cert = obj.fs.readFileSync(obj.common.joinPath(obj.parent.datapath, domain.authstrategies.intel.cert));
|
domain.passport.authenticate('isaml-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next);
|
||||||
if (cert == null) {
|
});
|
||||||
console.log('ERROR: Unable to read Intel SAML IdP certificate: ' + domain.authstrategies.intel.cert);
|
obj.app.post(url + 'auth-intel-callback', obj.bodyParser.urlencoded({ extended: false }), function (req, res, next) {
|
||||||
} else {
|
var domain = getDomain(req);
|
||||||
var options = { entryPoint: domain.authstrategies.intel.idpurl, issuer: 'meshcentral' };
|
if (domain.passport == null) { next(); return; }
|
||||||
if (typeof domain.authstrategies.intel.callbackurl == 'string') { options.callbackUrl = domain.authstrategies.intel.callbackurl; } else { options.callbackUrl = url + 'auth-intel-callback'; }
|
domain.passport.authenticate('isaml-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next);
|
||||||
if (domain.authstrategies.intel.disablerequestedauthncontext != null) { options.disableRequestedAuthnContext = domain.authstrategies.intel.disablerequestedauthncontext; }
|
}, handleStrategyLogin);
|
||||||
if (typeof domain.authstrategies.intel.entityid == 'string') { options.issuer = domain.authstrategies.intel.entityid; }
|
|
||||||
parent.debug('web', 'Adding Intel SSO with options: ' + JSON.stringify(options));
|
|
||||||
options.cert = cert.toString().split('-----BEGIN CERTIFICATE-----').join('').split('-----END CERTIFICATE-----').join('');
|
|
||||||
const SamlStrategy = require('passport-saml').Strategy;
|
|
||||||
passport.use('isaml-' + domain.id, new SamlStrategy(options,
|
|
||||||
function (profile, done) {
|
|
||||||
parent.debug('web', 'Intel profile: ' + JSON.stringify(profile));
|
|
||||||
if (typeof profile.nameID != 'string') { return done(); }
|
|
||||||
var user = { sid: '~intel:' + profile.nameID, name: profile.nameID, strategy: 'intel' };
|
|
||||||
if ((typeof profile.firstname == 'string') && (typeof profile.lastname == 'string')) { user.name = profile.firstname + ' ' + profile.lastname; }
|
|
||||||
else if ((typeof profile.FirstName == 'string') && (typeof profile.LastName == 'string')) { user.name = profile.FirstName + ' ' + profile.LastName; }
|
|
||||||
if (typeof profile.email == 'string') { user.email = profile.email; }
|
|
||||||
else if (typeof profile.EmailAddress == 'string') { user.email = profile.EmailAddress; }
|
|
||||||
return done(null, user);
|
|
||||||
}
|
|
||||||
));
|
|
||||||
obj.app.get(url + 'auth-intel', function (req, res, next) {
|
|
||||||
var domain = getDomain(req);
|
|
||||||
if (domain.passport == null) { next(); return; }
|
|
||||||
domain.passport.authenticate('isaml-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next);
|
|
||||||
});
|
|
||||||
obj.app.post(url + 'auth-intel-callback', obj.bodyParser.urlencoded({ extended: false }), function (req, res, next) {
|
|
||||||
var domain = getDomain(req);
|
|
||||||
if (domain.passport == null) { next(); return; }
|
|
||||||
domain.passport.authenticate('isaml-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next);
|
|
||||||
}, handleStrategyLogin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// JumpCloud SAML
|
// JumpCloud SAML
|
||||||
if (typeof domain.authstrategies.jumpcloud == 'object') {
|
if (domain.authstrategies.authStrategyFlags & domainAuthStrategyConsts.jumpCloudSaml != 0) {
|
||||||
if ((typeof domain.authstrategies.jumpcloud.cert != 'string') || (typeof domain.authstrategies.jumpcloud.idpurl != 'string')) {
|
obj.app.get(url + 'auth-jumpcloud', function (req, res, next) {
|
||||||
console.log('ERROR: Missing JumpCloud SAML configuration.');
|
var domain = getDomain(req);
|
||||||
} else {
|
if (domain.passport == null) { next(); return; }
|
||||||
var cert = obj.fs.readFileSync(obj.common.joinPath(obj.parent.datapath, domain.authstrategies.jumpcloud.cert));
|
domain.passport.authenticate('jumpcloud-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next);
|
||||||
if (cert == null) {
|
});
|
||||||
console.log('ERROR: Unable to read JumpCloud IdP certificate: ' + domain.authstrategies.jumpcloud.cert);
|
obj.app.post(url + 'auth-jumpcloud-callback', obj.bodyParser.urlencoded({ extended: false }), function (req, res, next) {
|
||||||
} else {
|
var domain = getDomain(req);
|
||||||
var options = { entryPoint: domain.authstrategies.jumpcloud.idpurl, issuer: 'meshcentral' };
|
if (domain.passport == null) { next(); return; }
|
||||||
if (typeof domain.authstrategies.jumpcloud.callbackurl == 'string') { options.callbackUrl = domain.authstrategies.jumpcloud.callbackurl; } else { options.callbackUrl = url + 'auth-jumpcloud-callback'; }
|
domain.passport.authenticate('jumpcloud-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next);
|
||||||
if (typeof domain.authstrategies.jumpcloud.entityid == 'string') { options.issuer = domain.authstrategies.jumpcloud.entityid; }
|
}, handleStrategyLogin);
|
||||||
parent.debug('web', 'Adding JumpCloud SSO with options: ' + JSON.stringify(options));
|
|
||||||
options.cert = cert.toString().split('-----BEGIN CERTIFICATE-----').join('').split('-----END CERTIFICATE-----').join('');
|
|
||||||
const SamlStrategy = require('passport-saml').Strategy;
|
|
||||||
passport.use('jumpcloud-' + domain.id, new SamlStrategy(options,
|
|
||||||
function (profile, done) {
|
|
||||||
parent.debug('web', 'JumpCloud profile: ' + JSON.stringify(profile));
|
|
||||||
if (typeof profile.nameID != 'string') { return done(); }
|
|
||||||
var user = { sid: '~jumpcloud:' + profile.nameID, name: profile.nameID, strategy: 'jumpcloud' };
|
|
||||||
if ((typeof profile.firstname == 'string') && (typeof profile.lastname == 'string')) { user.name = profile.firstname + ' ' + profile.lastname; }
|
|
||||||
if (typeof profile.email == 'string') { user.email = profile.email; }
|
|
||||||
return done(null, user);
|
|
||||||
}
|
|
||||||
));
|
|
||||||
obj.app.get(url + 'auth-jumpcloud', function (req, res, next) {
|
|
||||||
var domain = getDomain(req);
|
|
||||||
if (domain.passport == null) { next(); return; }
|
|
||||||
domain.passport.authenticate('jumpcloud-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next);
|
|
||||||
});
|
|
||||||
obj.app.post(url + 'auth-jumpcloud-callback', obj.bodyParser.urlencoded({ extended: false }), function (req, res, next) {
|
|
||||||
var domain = getDomain(req);
|
|
||||||
if (domain.passport == null) { next(); return; }
|
|
||||||
domain.passport.authenticate('jumpcloud-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next);
|
|
||||||
}, handleStrategyLogin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6793,6 +6636,247 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
|
||||||
if (doneFunc) doneFunc();
|
if (doneFunc) doneFunc();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth strategy flags
|
||||||
|
const domainAuthStrategyConsts = {
|
||||||
|
twitter: 1,
|
||||||
|
google: 2,
|
||||||
|
github: 3,
|
||||||
|
reddit: 8,
|
||||||
|
azure: 16,
|
||||||
|
openid: 32,
|
||||||
|
saml: 64,
|
||||||
|
intelSaml: 128,
|
||||||
|
jumpCloudSaml: 256
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup auth strategies for a domain
|
||||||
|
function setupDomainAuthStrategy(domain) {
|
||||||
|
// Return the auth strategies that have been setup
|
||||||
|
var authStrategyFlags = 0;
|
||||||
|
|
||||||
|
// Setup auth strategies using passport if needed
|
||||||
|
if (typeof domain.authstrategies != 'object') return authStrategyFlags;
|
||||||
|
|
||||||
|
const url = domain.url;
|
||||||
|
const passport = domain.passport = require('passport');
|
||||||
|
passport.serializeUser(function (user, done) { done(null, user.sid); });
|
||||||
|
passport.deserializeUser(function (sid, done) { done(null, { sid: sid }); });
|
||||||
|
obj.app.use(passport.initialize());
|
||||||
|
|
||||||
|
// Twitter
|
||||||
|
if ((typeof domain.authstrategies.twitter == 'object') && (typeof domain.authstrategies.twitter.clientid == 'string') && (typeof domain.authstrategies.twitter.clientsecret == 'string')) {
|
||||||
|
const TwitterStrategy = require('passport-twitter');
|
||||||
|
var options = { consumerKey: domain.authstrategies.twitter.clientid, consumerSecret: domain.authstrategies.twitter.clientsecret };
|
||||||
|
if (typeof domain.authstrategies.twitter.callbackurl == 'string') { options.callbackURL = domain.authstrategies.twitter.callbackurl; } else { options.callbackURL = url + 'auth-twitter-callback'; }
|
||||||
|
parent.debug('web', 'Adding Twitter SSO with options: ' + JSON.stringify(options));
|
||||||
|
passport.use('twitter-' + domain.id, new TwitterStrategy(options,
|
||||||
|
function (token, tokenSecret, profile, cb) {
|
||||||
|
parent.debug('web', 'Twitter profile: ' + JSON.stringify(profile));
|
||||||
|
var user = { sid: '~twitter:' + profile.id, name: profile.displayName, strategy: 'twitter' };
|
||||||
|
if ((typeof profile.emails == 'object') && (profile.emails[0] != null) && (typeof profile.emails[0].value == 'string')) { user.email = profile.emails[0].value; }
|
||||||
|
return cb(null, user);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
authStrategyFlags |= domainAuthStrategyConsts.twitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google
|
||||||
|
if ((typeof domain.authstrategies.google == 'object') && (typeof domain.authstrategies.google.clientid == 'string') && (typeof domain.authstrategies.google.clientsecret == 'string')) {
|
||||||
|
const GoogleStrategy = require('passport-google-oauth20');
|
||||||
|
var options = { clientID: domain.authstrategies.google.clientid, clientSecret: domain.authstrategies.google.clientsecret };
|
||||||
|
if (typeof domain.authstrategies.google.callbackurl == 'string') { options.callbackURL = domain.authstrategies.google.callbackurl; } else { options.callbackURL = url + 'auth-google-callback'; }
|
||||||
|
parent.debug('web', 'Adding Google SSO with options: ' + JSON.stringify(options));
|
||||||
|
passport.use('google-' + domain.id, new GoogleStrategy(options,
|
||||||
|
function (token, tokenSecret, profile, cb) {
|
||||||
|
parent.debug('web', 'Google profile: ' + JSON.stringify(profile));
|
||||||
|
var user = { sid: '~google:' + profile.id, name: profile.displayName, strategy: 'google' };
|
||||||
|
if ((typeof profile.emails == 'object') && (profile.emails[0] != null) && (typeof profile.emails[0].value == 'string') && (profile.emails[0].verified == true)) { user.email = profile.emails[0].value; }
|
||||||
|
return cb(null, user);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
authStrategyFlags |= domainAuthStrategyConsts.google;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Github
|
||||||
|
if ((typeof domain.authstrategies.github == 'object') && (typeof domain.authstrategies.github.clientid == 'string') && (typeof domain.authstrategies.github.clientsecret == 'string')) {
|
||||||
|
const GitHubStrategy = require('passport-github2');
|
||||||
|
var options = { clientID: domain.authstrategies.github.clientid, clientSecret: domain.authstrategies.github.clientsecret };
|
||||||
|
if (typeof domain.authstrategies.github.callbackurl == 'string') { options.callbackURL = domain.authstrategies.github.callbackurl; } else { options.callbackURL = url + 'auth-github-callback'; }
|
||||||
|
parent.debug('web', 'Adding Github SSO with options: ' + JSON.stringify(options));
|
||||||
|
passport.use('github-' + domain.id, new GitHubStrategy(options,
|
||||||
|
function (token, tokenSecret, profile, cb) {
|
||||||
|
parent.debug('web', 'Github profile: ' + JSON.stringify(profile));
|
||||||
|
var user = { sid: '~github:' + profile.id, name: profile.displayName, strategy: 'github' };
|
||||||
|
if ((typeof profile.emails == 'object') && (profile.emails[0] != null) && (typeof profile.emails[0].value == 'string')) { user.email = profile.emails[0].value; }
|
||||||
|
return cb(null, user);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
authStrategyFlags |= domainAuthStrategyConsts.github;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reddit
|
||||||
|
if ((typeof domain.authstrategies.reddit == 'object') && (typeof domain.authstrategies.reddit.clientid == 'string') && (typeof domain.authstrategies.reddit.clientsecret == 'string')) {
|
||||||
|
const RedditStrategy = require('passport-reddit');
|
||||||
|
var options = { clientID: domain.authstrategies.reddit.clientid, clientSecret: domain.authstrategies.reddit.clientsecret };
|
||||||
|
if (typeof domain.authstrategies.reddit.callbackurl == 'string') { options.callbackURL = domain.authstrategies.reddit.callbackurl; } else { options.callbackURL = url + 'auth-reddit-callback'; }
|
||||||
|
parent.debug('web', 'Adding Reddit SSO with options: ' + JSON.stringify(options));
|
||||||
|
passport.use('reddit-' + domain.id, new RedditStrategy.Strategy(options,
|
||||||
|
function (token, tokenSecret, profile, cb) {
|
||||||
|
parent.debug('web', 'Reddit profile: ' + JSON.stringify(profile));
|
||||||
|
var user = { sid: '~reddit:' + profile.id, name: profile.name, strategy: 'reddit' };
|
||||||
|
if ((typeof profile.emails == 'object') && (profile.emails[0] != null) && (typeof profile.emails[0].value == 'string')) { user.email = profile.emails[0].value; }
|
||||||
|
return cb(null, user);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
authStrategyFlags |= domainAuthStrategyConsts.reddit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Azure
|
||||||
|
if ((typeof domain.authstrategies.azure == 'object') && (typeof domain.authstrategies.azure.clientid == 'string') && (typeof domain.authstrategies.azure.clientsecret == 'string')) {
|
||||||
|
const AzureOAuth2Strategy = require('passport-azure-oauth2');
|
||||||
|
var options = { clientID: domain.authstrategies.azure.clientid, clientSecret: domain.authstrategies.azure.clientsecret, tenant: domain.authstrategies.azure.tenantid };
|
||||||
|
if (typeof domain.authstrategies.azure.callbackurl == 'string') { options.callbackURL = domain.authstrategies.azure.callbackurl; } else { options.callbackURL = url + 'auth-azure-callback'; }
|
||||||
|
parent.debug('web', 'Adding Azure SSO with options: ' + JSON.stringify(options));
|
||||||
|
passport.use('azure-' + domain.id, new AzureOAuth2Strategy(options,
|
||||||
|
function (accessToken, refreshtoken, params, profile, done) {
|
||||||
|
var userex = null;
|
||||||
|
try { userex = require('jwt-simple').decode(params.id_token, '', true); } catch (ex) { }
|
||||||
|
parent.debug('web', 'Azure profile: ' + JSON.stringify(userex));
|
||||||
|
var user = null;
|
||||||
|
if (userex != null) {
|
||||||
|
var user = { sid: '~azure:' + userex.unique_name, name: userex.name, strategy: 'azure' };
|
||||||
|
if (typeof userex.email == 'string') { user.email = userex.email; }
|
||||||
|
}
|
||||||
|
return done(null, user);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
authStrategyFlags |= domainAuthStrategyConsts.azure;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic OpenID Connect
|
||||||
|
if ((typeof domain.authstrategies.oidc == 'object') && (typeof domain.authstrategies.oidc.clientid == 'string') && (typeof domain.authstrategies.oidc.clientsecret == 'string') && (typeof domain.authstrategies.oidc.issuer == 'string')) {
|
||||||
|
var options = {
|
||||||
|
authorizationURL: domain.authstrategies.oidc.authorizationurl,
|
||||||
|
callbackURL: domain.authstrategies.oidc.callbackurl,
|
||||||
|
clientID: domain.authstrategies.oidc.clientid,
|
||||||
|
clientSecret: domain.authstrategies.oidc.clientsecret,
|
||||||
|
issuer: domain.authstrategies.oidc.issuer,
|
||||||
|
tokenURL: domain.authstrategies.oidc.tokenurl,
|
||||||
|
userInfoURL: domain.authstrategies.oidc.userinfourl,
|
||||||
|
scope: ['openid profile email'],
|
||||||
|
responseMode: 'form_post',
|
||||||
|
state: true
|
||||||
|
};
|
||||||
|
const OIDCStrategy = require('@mstrhakr/passport-generic-oidc');
|
||||||
|
if (typeof domain.authstrategies.oidc.callbackurl == 'string') { options.callbackURL = domain.authstrategies.oidc.callbackurl; } else { options.callbackURL = url + 'oidc-callback'; }
|
||||||
|
parent.debug('web', 'Adding Generic OIDC SSO with options: ' + JSON.stringify(options));
|
||||||
|
passport.use('openidconnect', new OIDCStrategy.Strategy(options,
|
||||||
|
function verify(iss, sub, profile, cb) {
|
||||||
|
var user = { sid: '~oidc:' + profile.id, name: profile.displayName, email: profile.email, strategy: 'oidc' };
|
||||||
|
parent.debug('AUTH', 'OIDC: Configured user: ' + JSON.stringify(user));
|
||||||
|
return cb(null, user);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
authStrategyFlags |= domainAuthStrategyConsts.openid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic SAML
|
||||||
|
if (typeof domain.authstrategies.saml == 'object') {
|
||||||
|
if ((typeof domain.authstrategies.saml.cert != 'string') || (typeof domain.authstrategies.saml.idpurl != 'string')) {
|
||||||
|
console.log('ERROR: Missing SAML configuration.');
|
||||||
|
} else {
|
||||||
|
const certPath = obj.common.joinPath(obj.parent.datapath, domain.authstrategies.saml.cert);
|
||||||
|
var cert = obj.fs.readFileSync(certPath);
|
||||||
|
if (cert == null) {
|
||||||
|
console.log('ERROR: Unable to read SAML IdP certificate: ' + domain.authstrategies.saml.cert);
|
||||||
|
} else {
|
||||||
|
var options = { entryPoint: domain.authstrategies.saml.idpurl, issuer: 'meshcentral' };
|
||||||
|
if (typeof domain.authstrategies.saml.callbackurl == 'string') { options.callbackUrl = domain.authstrategies.saml.callbackurl; } else { options.callbackUrl = url + 'auth-saml-callback'; }
|
||||||
|
if (domain.authstrategies.saml.disablerequestedauthncontext != null) { options.disableRequestedAuthnContext = domain.authstrategies.saml.disablerequestedauthncontext; }
|
||||||
|
if (typeof domain.authstrategies.saml.entityid == 'string') { options.issuer = domain.authstrategies.saml.entityid; }
|
||||||
|
parent.debug('web', 'Adding SAML SSO with options: ' + JSON.stringify(options));
|
||||||
|
options.cert = cert.toString().split('-----BEGIN CERTIFICATE-----').join('').split('-----END CERTIFICATE-----').join('');
|
||||||
|
const SamlStrategy = require('passport-saml').Strategy;
|
||||||
|
passport.use('saml-' + domain.id, new SamlStrategy(options,
|
||||||
|
function (profile, done) {
|
||||||
|
parent.debug('web', 'SAML profile: ' + JSON.stringify(profile));
|
||||||
|
if (typeof profile.nameID != 'string') { return done(); }
|
||||||
|
var user = { sid: '~saml:' + profile.nameID, name: profile.nameID, strategy: 'saml' };
|
||||||
|
if ((typeof profile.firstname == 'string') && (typeof profile.lastname == 'string')) { user.name = profile.firstname + ' ' + profile.lastname; }
|
||||||
|
if (typeof profile.email == 'string') { user.email = profile.email; }
|
||||||
|
return done(null, user);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
authStrategyFlags |= domainAuthStrategyConsts.saml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intel SAML
|
||||||
|
if (typeof domain.authstrategies.intel == 'object') {
|
||||||
|
if ((typeof domain.authstrategies.intel.cert != 'string') || (typeof domain.authstrategies.intel.idpurl != 'string')) {
|
||||||
|
console.log('ERROR: Missing Intel SAML configuration.');
|
||||||
|
} else {
|
||||||
|
var cert = obj.fs.readFileSync(obj.common.joinPath(obj.parent.datapath, domain.authstrategies.intel.cert));
|
||||||
|
if (cert == null) {
|
||||||
|
console.log('ERROR: Unable to read Intel SAML IdP certificate: ' + domain.authstrategies.intel.cert);
|
||||||
|
} else {
|
||||||
|
var options = { entryPoint: domain.authstrategies.intel.idpurl, issuer: 'meshcentral' };
|
||||||
|
if (typeof domain.authstrategies.intel.callbackurl == 'string') { options.callbackUrl = domain.authstrategies.intel.callbackurl; } else { options.callbackUrl = url + 'auth-intel-callback'; }
|
||||||
|
if (domain.authstrategies.intel.disablerequestedauthncontext != null) { options.disableRequestedAuthnContext = domain.authstrategies.intel.disablerequestedauthncontext; }
|
||||||
|
if (typeof domain.authstrategies.intel.entityid == 'string') { options.issuer = domain.authstrategies.intel.entityid; }
|
||||||
|
parent.debug('web', 'Adding Intel SSO with options: ' + JSON.stringify(options));
|
||||||
|
options.cert = cert.toString().split('-----BEGIN CERTIFICATE-----').join('').split('-----END CERTIFICATE-----').join('');
|
||||||
|
const SamlStrategy = require('passport-saml').Strategy;
|
||||||
|
passport.use('isaml-' + domain.id, new SamlStrategy(options,
|
||||||
|
function (profile, done) {
|
||||||
|
parent.debug('web', 'Intel profile: ' + JSON.stringify(profile));
|
||||||
|
if (typeof profile.nameID != 'string') { return done(); }
|
||||||
|
var user = { sid: '~intel:' + profile.nameID, name: profile.nameID, strategy: 'intel' };
|
||||||
|
if ((typeof profile.firstname == 'string') && (typeof profile.lastname == 'string')) { user.name = profile.firstname + ' ' + profile.lastname; }
|
||||||
|
else if ((typeof profile.FirstName == 'string') && (typeof profile.LastName == 'string')) { user.name = profile.FirstName + ' ' + profile.LastName; }
|
||||||
|
if (typeof profile.email == 'string') { user.email = profile.email; }
|
||||||
|
else if (typeof profile.EmailAddress == 'string') { user.email = profile.EmailAddress; }
|
||||||
|
return done(null, user);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
authStrategyFlags |= domainAuthStrategyConsts.intelSaml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JumpCloud SAML
|
||||||
|
if (typeof domain.authstrategies.jumpcloud == 'object') {
|
||||||
|
if ((typeof domain.authstrategies.jumpcloud.cert != 'string') || (typeof domain.authstrategies.jumpcloud.idpurl != 'string')) {
|
||||||
|
console.log('ERROR: Missing JumpCloud SAML configuration.');
|
||||||
|
} else {
|
||||||
|
var cert = obj.fs.readFileSync(obj.common.joinPath(obj.parent.datapath, domain.authstrategies.jumpcloud.cert));
|
||||||
|
if (cert == null) {
|
||||||
|
console.log('ERROR: Unable to read JumpCloud IdP certificate: ' + domain.authstrategies.jumpcloud.cert);
|
||||||
|
} else {
|
||||||
|
var options = { entryPoint: domain.authstrategies.jumpcloud.idpurl, issuer: 'meshcentral' };
|
||||||
|
if (typeof domain.authstrategies.jumpcloud.callbackurl == 'string') { options.callbackUrl = domain.authstrategies.jumpcloud.callbackurl; } else { options.callbackUrl = url + 'auth-jumpcloud-callback'; }
|
||||||
|
if (typeof domain.authstrategies.jumpcloud.entityid == 'string') { options.issuer = domain.authstrategies.jumpcloud.entityid; }
|
||||||
|
parent.debug('web', 'Adding JumpCloud SSO with options: ' + JSON.stringify(options));
|
||||||
|
options.cert = cert.toString().split('-----BEGIN CERTIFICATE-----').join('').split('-----END CERTIFICATE-----').join('');
|
||||||
|
const SamlStrategy = require('passport-saml').Strategy;
|
||||||
|
passport.use('jumpcloud-' + domain.id, new SamlStrategy(options,
|
||||||
|
function (profile, done) {
|
||||||
|
parent.debug('web', 'JumpCloud profile: ' + JSON.stringify(profile));
|
||||||
|
if (typeof profile.nameID != 'string') { return done(); }
|
||||||
|
var user = { sid: '~jumpcloud:' + profile.nameID, name: profile.nameID, strategy: 'jumpcloud' };
|
||||||
|
if ((typeof profile.firstname == 'string') && (typeof profile.lastname == 'string')) { user.name = profile.firstname + ' ' + profile.lastname; }
|
||||||
|
if (typeof profile.email == 'string') { user.email = profile.email; }
|
||||||
|
return done(null, user);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
authStrategyFlags |= domainAuthStrategyConsts.jumpCloudSaml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return authStrategyFlags;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle an incoming request as a web relay
|
// Handle an incoming request as a web relay
|
||||||
function handleWebRelayRequest(req, res) {
|
function handleWebRelayRequest(req, res) {
|
||||||
|
|
Loading…
Reference in a new issue