@perfood/couch-auth
Version:
Easy and secure authentication for CouchDB/Cloudant. Based on SuperLogin, updated and rewritten in Typescript.
277 lines (276 loc) • 10.7 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.DBAuth = void 0;
const seed_1 = __importDefault(require("../design/seed"));
const util_1 = require("../util");
const couchdb_1 = require("./couchdb");
class DBAuth {
constructor(config, userDB, couchServer, couchAuthDB) {
this.config = config;
this.userDB = userDB;
this.couchServer = couchServer;
this.adapter = new couchdb_1.CouchAdapter(couchAuthDB, this.couchServer, this.config);
}
storeKey(username, user_uid, key, password, expires, roles, provider) {
return this.adapter.storeKey(username, user_uid, key, password, expires, roles, provider);
}
/**
* Step 1) During deauthorization: Removes the keys of format
* org.couchdb.user:TOKEN from the `_users` - database, if they are present.
* If this step fails, the user hasn't been deauthorized!
*/
removeKeys(keys) {
return this.adapter.removeKeys(keys);
}
retrieveKey(key) {
return this.adapter.retrieveKey(key);
}
extendKey(key, newExpiration) {
return this.adapter.extendKey(key, newExpiration);
}
/** generates a random token and password */
getApiKey() {
let token = (0, util_1.URLSafeUUID)();
// Make sure our token doesn't start with illegal characters
while (token[0] === '_' || token[0] === '-') {
token = (0, util_1.URLSafeUUID)();
}
return {
key: token,
password: (0, util_1.URLSafeUUID)()
};
}
/**
* Removes the affected from the `_users` db and from the `_security` of the
* user's personal DBs, returning the modified `sl-users` doc
*
* - 'all' -> logs out all sessions
* - 'other' -> logout all sessions except for 'currentSession'
* - 'expired' -> only logs out expired sessions
*/
async logoutUserSessions(userDoc, op, currentSession) {
let sessionsToEnd;
if (op === 'expired') {
sessionsToEnd = (0, util_1.getExpiredSessions)(userDoc, Date.now());
}
else {
sessionsToEnd = (0, util_1.getSessions)(userDoc);
if (op === 'other' && currentSession) {
sessionsToEnd = sessionsToEnd.filter(s => s !== currentSession);
}
}
if (sessionsToEnd.length) {
// 1.) Remove the keys from our couchDB auth database. Must happen first.
await this.removeKeys(sessionsToEnd);
// 2.) Deauthorize keys from each personal database
await this.deauthorizeUser(userDoc, sessionsToEnd);
sessionsToEnd.forEach(session => {
delete userDoc.session[session];
});
if (Object.keys(userDoc.session).length === 0) {
delete userDoc.session;
}
userDoc.inactiveSessions = [
...(userDoc.inactiveSessions ?? []),
...sessionsToEnd
];
}
return userDoc;
}
async authorizeKeys(db, keys) {
return this.adapter.authorizeKeys(db, keys);
}
/** removes the keys from the security doc of the db */
deauthorizeKeys(db, keys) {
return this.adapter.deauthorizeKeys(db, keys);
}
authorizeUserSessions(personalDBs, sessionKeys) {
const promises = [];
Object.keys(personalDBs).forEach(personalDB => {
const db = this.couchServer.use(personalDB);
promises.push(this.authorizeKeys(db, (0, util_1.toArray)(sessionKeys)));
});
return Promise.all(promises);
}
async addUserDB(userDoc, dbName, designDocs, type, adminRoles, memberRoles, partitioned) {
const promises = [];
adminRoles = adminRoles || [];
memberRoles = memberRoles || [];
partitioned = partitioned || false;
// Create and the database and seed it if a designDoc is specified
const prefix = this.config.userDBs.privatePrefix
? this.config.userDBs.privatePrefix + '_'
: '';
// new in 2.0: use uuid instead of username
const finalDBName = type === 'shared' ? dbName : prefix + dbName + '$' + userDoc._id;
await this.createDB(finalDBName, partitioned);
const newDB = this.couchServer.db.use(finalDBName);
await this.adapter.initSecurity(newDB, adminRoles, memberRoles);
// Seed the design docs
if (designDocs && designDocs instanceof Array) {
designDocs.forEach(ddName => {
const dDoc = this.getDesignDoc(ddName);
if (dDoc) {
promises.push((0, seed_1.default)(newDB, dDoc));
}
else {
console.warn('Failed to locate design doc: ' + ddName);
}
});
}
// Authorize the user's existing DB keys to access the new database
const keysToAuthorize = [];
if (userDoc.session) {
for (const key in userDoc.session) {
if (userDoc.session.hasOwnProperty(key) &&
userDoc.session[key].expires > Date.now()) {
keysToAuthorize.push(key);
}
}
}
if (keysToAuthorize.length > 0) {
promises.push(this.authorizeKeys(newDB, keysToAuthorize));
}
await Promise.all(promises);
return finalDBName;
}
/**
* Checks from the superlogin-userDB which keys are expired and removes them
* from:
* 1. the CouchDB authentication-DB (`_users`)
* 2. the security-doc of the user's personal DB
* 3. the user's doc in the superlogin-DB
*
* @returns an array of removed keys
* @throws This method can fail due to Connection/ CouchDB-Problems.
*/
async removeExpiredKeys() {
const alreadyProcessedUsers = new Set();
let revokedSessions = [];
// query a list of expired keys by user
const results = await this.userDB.view('auth', 'expiredKeys', {
endkey: Date.now(),
include_docs: true
});
// clean up expired session for each user in the results
for (const row of results.rows) {
const val = row.value;
const userId = val.user;
if (alreadyProcessedUsers.has(userId)) {
continue;
}
const sessionsBefore = Object.keys(row.doc.session ?? {});
const userDoc = await this.logoutUserSessions(row.doc, 'expired');
await this.userDB.insert(userDoc);
revokedSessions = revokedSessions.concat(sessionsBefore.filter(s => !userDoc.session || !userDoc.session[s]));
alreadyProcessedUsers.add(userId);
}
return revokedSessions;
}
/** deauthenticates the keys from the user's personal DB */
deauthorizeUser(userDoc, keys) {
const promises = [];
// If keys is not specified we will deauthorize all of the users sessions
if (!keys) {
keys = (0, util_1.getSessions)(userDoc);
}
keys = (0, util_1.toArray)(keys);
if (userDoc.personalDBs && typeof userDoc.personalDBs === 'object') {
Object.keys(userDoc.personalDBs).forEach(personalDB => {
const db = this.couchServer.use(personalDB);
promises.push(this.deauthorizeKeys(db, keys));
});
return Promise.all(promises);
}
else {
return Promise.resolve(false);
}
}
getDesignDoc(docName) {
if (!docName) {
return null;
}
let designDoc;
let designDocDir = this.config.userDBs.designDocDir;
if (!designDocDir) {
designDocDir = __dirname;
}
try {
designDoc = require(designDocDir + '/' + docName);
}
catch (err) {
console.warn('Design doc: ' + designDocDir + '/' + docName + ' not found.');
designDoc = null;
}
return designDoc;
}
getDBConfig(dbName, type) {
const dbConfig = {
name: dbName
};
dbConfig.adminRoles =
this.config.userDBs?.defaultSecurityRoles?.admins || [];
dbConfig.memberRoles =
this.config.userDBs?.defaultSecurityRoles?.members || [];
const dbConfigRef = this.config.userDBs?.model?.[dbName];
if (dbConfigRef) {
dbConfig.designDocs = dbConfigRef.designDocs || [];
dbConfig.type = type || dbConfigRef.type || 'private';
dbConfig.partitioned = dbConfigRef.partitioned || false;
const dbAdminRoles = dbConfigRef.adminRoles;
const dbMemberRoles = dbConfigRef.memberRoles;
if (dbAdminRoles && dbAdminRoles instanceof Array) {
dbAdminRoles.forEach(role => {
if (role && dbConfig.adminRoles.indexOf(role) === -1) {
dbConfig.adminRoles.push(role);
}
});
}
if (dbMemberRoles && dbMemberRoles instanceof Array) {
dbMemberRoles.forEach(role => {
if (role && dbConfig.memberRoles.indexOf(role) === -1) {
dbConfig.memberRoles.push(role);
}
});
}
}
else if (this.config.userDBs.model?._default) {
// Only add the default design doc to a private database
if (!type || type === 'private') {
dbConfig.designDocs =
this.config.userDBs.model._default.designDocs || [];
}
else {
dbConfig.designDocs = [];
}
dbConfig.partitioned =
this.config.userDBs.model._default.partitioned || false;
dbConfig.type = type || 'private';
}
else {
dbConfig.partitioned = false;
dbConfig.type = type || 'private';
}
return dbConfig;
}
async createDB(dbName, partitioned) {
partitioned = partitioned || false;
try {
await this.couchServer.db.create(dbName, { partitioned: partitioned });
}
catch (err) {
if (err.statusCode === 412) {
return false; // already exists
}
throw err;
}
return true;
}
removeDB(dbName) {
return this.couchServer.db.destroy(dbName);
}
}
exports.DBAuth = DBAuth;