DTP Social Engine
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.
 
 
 
 
 

476 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 ( ) {
await super.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);
}
/**
* Removes and fully de-authorizes an OAuth2Client from the system.
* @param {OAuth2Client} client the client to be removed
*/
async removeClient (client) {
this.log.info('removing client', { clientId: client._id, });
await OAuth2Client.deleteOne({ _id: client._id });
}
}
module.exports = {
slug: 'oauth2',
name: 'oauth2',
create: (dtp) => { return new OAuth2Service(dtp); },
};