You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
465 lines
14 KiB
465 lines
14 KiB
// oauth2.js
|
|
// Copyright (C) 2022 DTP Technologies, LLC
|
|
// License: Apache-2.0
|
|
|
|
'use strict';
|
|
|
|
const mongoose = require('mongoose');
|
|
|
|
const OAuth2Client = mongoose.model('OAuth2Client');
|
|
const OAuth2AuthorizationCode = mongoose.model('OAuth2AuthorizationCode');
|
|
const OAuth2Token = mongoose.model('OAuth2Token');
|
|
|
|
const uuidv4 = require('uuid').v4;
|
|
const striptags = require('striptags');
|
|
|
|
const oauth2orize = require('oauth2orize');
|
|
const generatePassword = require('password-generator');
|
|
|
|
const passport = require('passport');
|
|
const BasicStrategy = require('passport-http').BasicStrategy;
|
|
const ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy;
|
|
const BearerStrategy = require('passport-http-bearer').Strategy;
|
|
|
|
const { SiteService/*, SiteError*/ } = require('../../lib/site-lib');
|
|
|
|
class OAuth2Service extends SiteService {
|
|
|
|
constructor (dtp) {
|
|
super(dtp, module.exports);
|
|
|
|
this.populateOAuth2Token = [
|
|
{
|
|
path: 'user',
|
|
select: 'username username_lc displayName picture',
|
|
},
|
|
{
|
|
path: 'client'
|
|
},
|
|
];
|
|
}
|
|
|
|
async start ( ) {
|
|
const serverOptions = { };
|
|
|
|
this.log.info('creating OAuth2 server instance', { serverOptions });
|
|
this.server = oauth2orize.createServer(serverOptions);
|
|
|
|
this.log.info('registering OAuth2 action handlers');
|
|
this.server.grant(oauth2orize.grant.code(this.processGrant.bind(this)));
|
|
this.server.exchange(oauth2orize.exchange.code(this.processExchangeCode.bind(this)));
|
|
|
|
this.log.info('registering OAuth2 serialization routines');
|
|
this.server.serializeClient(this.serializeClient.bind(this));
|
|
this.server.deserializeClient(this.deserializeClient.bind(this));
|
|
}
|
|
|
|
registerPassport ( ) {
|
|
const verifyClient = this.verifyClient.bind(this);
|
|
const verifyHttpBearer = this.verifyHttpBearer.bind(this);
|
|
const verifyKaleidoscopeBearer = this.verifyKaleidoscopeBearer.bind(this);
|
|
|
|
const basicStrategy = new BasicStrategy(verifyClient);
|
|
this.log.info('registering Basic strategy', { name: basicStrategy.name });
|
|
passport.use(basicStrategy);
|
|
|
|
const clientPasswordStrategy = new ClientPasswordStrategy(verifyClient);
|
|
this.log.info('registering ClientPassword strategy', { name: clientPasswordStrategy.name });
|
|
passport.use(clientPasswordStrategy);
|
|
|
|
const httpBearerStrategy = new BearerStrategy(verifyHttpBearer);
|
|
this.log.info('registering Bearer strategy', { name: httpBearerStrategy.name });
|
|
passport.use(httpBearerStrategy);
|
|
|
|
const kaleidoscopeBearerStrategy = new BearerStrategy(verifyKaleidoscopeBearer);
|
|
this.log.info('registering Kaleidoscope Bearer strategy');
|
|
passport.use(kaleidoscopeBearerStrategy);
|
|
}
|
|
|
|
async serializeClient (client, done) {
|
|
this.log.debug('serializeClient', { clientID: client._id.toString() });
|
|
return done(null, client._id.toString());
|
|
}
|
|
|
|
async deserializeClient (clientID, done) {
|
|
this.log.debug('deserializeClient', { clientID });
|
|
try {
|
|
const client = await OAuth2Client
|
|
.findOne({ _id: clientID })
|
|
.lean();
|
|
this.log.debug('OAuth2 client loaded', { clientID });
|
|
return done(null, client);
|
|
} catch (error) {
|
|
this.log.error('failed to deserialize OAuth2 client', { clientID, error });
|
|
return done(error);
|
|
}
|
|
}
|
|
|
|
attachRoutes (app) {
|
|
const { session: sessionService } = this.dtp.services;
|
|
const requireLogin = sessionService.authCheckMiddleware({
|
|
requireAuth: true,
|
|
loginUri: '/welcome/login'
|
|
});
|
|
app.get(
|
|
'/oauth2/authorize',
|
|
requireLogin,
|
|
this.server.authorize(this.processAuthorize.bind(this)),
|
|
this.renderAuthorizeDialog.bind(this),
|
|
);
|
|
|
|
app.post(
|
|
'/oauth2/authorize/decision',
|
|
requireLogin,
|
|
this.server.decision(),
|
|
);
|
|
|
|
app.post(
|
|
'/oauth2/token',
|
|
(req, res, next) => {
|
|
this.log.debug('POST /oauth2/token', { body: req.body, params: req.params, query: req.query });
|
|
return next();
|
|
},
|
|
passport.authenticate(['basic', 'oauth2-client-password'], { session: false }),
|
|
this.server.token(),
|
|
this.server.errorHandler(),
|
|
);
|
|
}
|
|
|
|
async renderAuthorizeDialog (req, res) {
|
|
res.locals.currentView = 'oauth2-authorize-dialog';
|
|
res.locals.oauth2 = req.oauth2;
|
|
res.locals.transactionID = req.oauth2.transactionID;
|
|
res.locals.client = req.oauth2.client;
|
|
res.render('oauth2/authorize-dialog');
|
|
}
|
|
|
|
async processAuthorize (clientID, redirectUri, done) {
|
|
try {
|
|
const client = await OAuth2Client
|
|
.findOne({ _id: mongoose.Types.ObjectId(clientID) })
|
|
.lean();
|
|
if (!client) {
|
|
this.log.alert('OAuth2 client not found', { clientID });
|
|
return done(null, false);
|
|
}
|
|
if (client.callbackUrl !== redirectUri) {
|
|
this.log.alert('OAuth2 client redirect URI mismatch', {
|
|
redirectUri,
|
|
officialUri: client.callbackUrl,
|
|
});
|
|
return done(null, false);
|
|
}
|
|
this.log.info('client authorization processed', { clientID });
|
|
return done(null, client, client.callbackUrl);
|
|
} catch (error) {
|
|
this.log.error('failed to process OAuth2 authorize', { error });
|
|
return done(error);
|
|
}
|
|
}
|
|
|
|
async processGrant (client, redirectUri, user, ares, done) {
|
|
try {
|
|
var code = uuidv4();
|
|
|
|
var ac = new OAuth2AuthorizationCode({
|
|
code,
|
|
client: client._id,
|
|
redirectUri,
|
|
user: user._id,
|
|
scopes: client.scopes,
|
|
});
|
|
await ac.save();
|
|
|
|
this.log.info('OAuth2 grant processed', { clientID: client._id, scopes: client.scopes });
|
|
return done(null, code);
|
|
} catch (error) {
|
|
this.log.error('failed to process OAuth2 grant', { error });
|
|
return done(error);
|
|
}
|
|
}
|
|
|
|
async issueTokens (authCode) {
|
|
const response = {
|
|
accessToken: generatePassword(256, false),
|
|
refreshToken: generatePassword(256, false),
|
|
params: {
|
|
coreUserId: authCode.user._id.toString(),
|
|
username: authCode.user.username,
|
|
username_lc: authCode.user.username_lc,
|
|
displayName: authCode.user.displayName,
|
|
bio: authCode.user.bio,
|
|
},
|
|
};
|
|
await Promise.all([
|
|
OAuth2Token.create({
|
|
type: 'access',
|
|
token: response.accessToken,
|
|
user: authCode.user._id,
|
|
client: authCode.client._id,
|
|
scope: authCode.scope,
|
|
}),
|
|
OAuth2Token.create({
|
|
type: 'refresh',
|
|
token: response.refreshToken,
|
|
user: authCode.user._id,
|
|
client: authCode.client._id,
|
|
scope: authCode.scope,
|
|
}),
|
|
]);
|
|
return response;
|
|
}
|
|
|
|
async processExchangeCode (client, code, redirectUri, done) {
|
|
try {
|
|
const ac = await OAuth2AuthorizationCode
|
|
.findOne({ code })
|
|
.populate([
|
|
{
|
|
path: 'client',
|
|
},
|
|
{
|
|
path: 'user',
|
|
select: 'username username_lc displayName picture bio permissions flags',
|
|
},
|
|
]);
|
|
|
|
if (!client._id.equals(ac.client._id)) {
|
|
this.log.alert('OAuth2 client ID mismatch', { provided: client.id, onfile: ac.client._id });
|
|
return done(null, false);
|
|
}
|
|
if (redirectUri !== ac.redirectUri) {
|
|
this.log.alert('OAuth2 redirect mismatch', { provided: redirectUri, onfile: ac.redirectUri });
|
|
return done(null, false);
|
|
}
|
|
|
|
const response = await this.issueTokens(ac);
|
|
this.log.info('OAuth2 grant exchanged for token', {
|
|
clientID: client._id,
|
|
params: response.params,
|
|
});
|
|
return done(null, response.accessToken, response.refreshToken, response.params);
|
|
} catch (error) {
|
|
this.log.error('failed to process OAuth2 exchange', { error });
|
|
return done(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a new OAuth2 client, and generates a Client ID and Secret for it.
|
|
* @param {Document} clientDefinition The definition of the client to be
|
|
* created including the name and domain of the node.
|
|
* @returns new client instance with valid _id.
|
|
*/
|
|
async createClient (clientDefinition) {
|
|
const NOW = new Date();
|
|
const PASSWORD_LEN = parseInt(process.env.DTP_CORE_AUTH_PASSWORD_LEN || '64', 10);
|
|
|
|
// scrub up the input data to help prevent shenanigans
|
|
clientDefinition.name = striptags(clientDefinition.name);
|
|
clientDefinition.description = striptags(clientDefinition.description);
|
|
clientDefinition.domain = striptags(clientDefinition.domain);
|
|
clientDefinition.domainKey = striptags(clientDefinition.domainKey);
|
|
|
|
clientDefinition.company = striptags(clientDefinition.company);
|
|
|
|
clientDefinition.secret = generatePassword(PASSWORD_LEN, false);
|
|
clientDefinition.coreAuth.scopes = clientDefinition.coreAuth.scopes.map((scope) => striptags(scope));
|
|
clientDefinition.coreAuth.callbackUrl = striptags(clientDefinition.coreAuth.callbackUrl);
|
|
|
|
/*
|
|
* Use an upsert to either update or create the OAuth2 client record for the
|
|
* calling host.
|
|
*/
|
|
|
|
const client = await OAuth2Client.findOneAndUpdate(
|
|
{
|
|
'site.domain': clientDefinition.domain,
|
|
'site.domainKey': clientDefinition.domainKey,
|
|
},
|
|
{
|
|
$setOnInsert: {
|
|
created: NOW,
|
|
'site.domain': clientDefinition.domain,
|
|
'site.domainKey': clientDefinition.domainKey,
|
|
},
|
|
$set: {
|
|
updated: NOW,
|
|
'site.name': clientDefinition.name,
|
|
'site.description': clientDefinition.description,
|
|
'site.company': clientDefinition.company,
|
|
secret: clientDefinition.secret,
|
|
scopes: clientDefinition.coreAuth.scopes,
|
|
callbackUrl: clientDefinition.coreAuth.callbackUrl,
|
|
kaleidoscope: {
|
|
token: generatePassword(256, false),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
upsert: true, // create if it doesn't exist
|
|
"new": true, // return the modified version
|
|
},
|
|
);
|
|
|
|
this.log.info('new OAuth2 client updated', {
|
|
clientId: client._id,
|
|
site: client.site.name,
|
|
domain: client.site.domain,
|
|
});
|
|
|
|
return client.toObject();
|
|
}
|
|
|
|
async updateClient (client, clientDefinition) {
|
|
await OAuth2Client.updateOne(
|
|
{ _id: client._id },
|
|
{
|
|
$set: {
|
|
'admin.notes': striptags(clientDefinition.notes.trim()),
|
|
'flags.isActive': clientDefinition.isActive === 'on',
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
async getClients (search, pagination) {
|
|
search = search || { };
|
|
let query = OAuth2Client
|
|
.find(search)
|
|
.sort({ 'site.domainKey': 1 });
|
|
|
|
if (pagination) {
|
|
query = query
|
|
.skip(pagination.skip)
|
|
.limit(pagination.cpp);
|
|
}
|
|
|
|
const clients = await query.lean();
|
|
|
|
return clients;
|
|
}
|
|
|
|
async getRandomClients (maxCount) {
|
|
const clients = await OAuth2Client.aggregate([
|
|
{
|
|
$match: { 'flags.isActive': true },
|
|
},
|
|
{
|
|
$sample: { size: maxCount },
|
|
}
|
|
]);
|
|
return clients;
|
|
}
|
|
|
|
async getClientById (clientId) {
|
|
const client = await OAuth2Client
|
|
.findOne({ _id: clientId })
|
|
.lean();
|
|
return client;
|
|
}
|
|
|
|
async getClientByDomain (domain) {
|
|
const client = await OAuth2Client
|
|
.findOne({ 'site.domain': domain })
|
|
.lean();
|
|
return client;
|
|
}
|
|
|
|
async getClientByDomainKey (domainKey) {
|
|
const client = await OAuth2Client
|
|
.findOne({ 'site.domainKey': domainKey })
|
|
.lean();
|
|
return client;
|
|
}
|
|
|
|
async verifyClient (clientId, clientSecret, done) {
|
|
const client = await this.getClientById(clientId);
|
|
if (!client) {
|
|
this.log.alert('OAuth2 request from unknown client', { clientId });
|
|
return done(null, false);
|
|
}
|
|
if (client.secret !== clientSecret) {
|
|
this.log.alert('OAuth2 client secret mismatch', { clientId });
|
|
return done(null, false);
|
|
}
|
|
return done(null, client);
|
|
}
|
|
|
|
async getAccessToken (accessToken) {
|
|
const token = await OAuth2Token
|
|
.findOne({ type: 'access', token: accessToken })
|
|
.populate(this.populateOAuth2Token)
|
|
.lean();
|
|
return token;
|
|
}
|
|
|
|
async verifyHttpBearer (accessToken, done) {
|
|
const token = await this.getAccessToken(accessToken);
|
|
if (!token) {
|
|
this.log.error('no bearer token for client', { accessToken });
|
|
return done(null, false);
|
|
}
|
|
return done(null, token.user, { scope: token.scope });
|
|
}
|
|
|
|
/**
|
|
* Retrieves OAuth2 access/refresh tokens for a specific CoreUser.
|
|
* @param {CoreUser} user The user for which tokens are wanted.
|
|
* @param {*} type The type of token wanted (access or refresh), or don't
|
|
* specify to receive all tokens (unfiltered).
|
|
* @returns Array of tokens for the specified user, if any.
|
|
*/
|
|
async getUserTokens (user, type) {
|
|
const tokens = await OAuth2Token
|
|
.find({ user: user._id, type })
|
|
.populate(this.populateOAuth2Token)
|
|
.lean();
|
|
return tokens;
|
|
}
|
|
|
|
async getKaleidoscopeClient (accessToken) {
|
|
const client = await OAuth2Client
|
|
.findOne({ 'kaleidoscope.token': accessToken })
|
|
.select('-secret -kaleidoscope -admin') // don't fetch them
|
|
.lean();
|
|
if (!client) {
|
|
return; // we don't have one, be undefined
|
|
}
|
|
/*
|
|
* extreme paranoia also serializes the object to absolutely prevent leaking
|
|
* a secret even if the underlying Mongoose library has a bug today.
|
|
*/
|
|
return {
|
|
_id: client._id,
|
|
created: client.created,
|
|
updated: client.updated,
|
|
site: client.site,
|
|
scopes: client.scopes,
|
|
flags: client.flags,
|
|
};
|
|
}
|
|
|
|
async verifyKaleidoscopeBearer (accessToken, done) {
|
|
const client = await this.getKaleidoscopeClient(accessToken);
|
|
if (!client) {
|
|
this.log.error('no Kaleidoscope token for client', { accessToken });
|
|
return done(null, false);
|
|
}
|
|
/*
|
|
* Minor hack here. You don't get a User or CoreUser for use with
|
|
* Kaleidoscope. This is machine-to-machine, there simply is no "user" in
|
|
* this transaction. Instead, you get a Client - the machine.
|
|
*
|
|
* So, up in controller space, req.user isn't a User or CoreUser for
|
|
* Kaleidoscope APIs. It is the OAuth2 Client or Service Node.
|
|
*/
|
|
return done(null, client);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
slug: 'oauth2',
|
|
name: 'oauth2',
|
|
create: (dtp) => { return new OAuth2Service(dtp); },
|
|
};
|