@perfood/couch-auth
Version:
Easy and secure authentication for CouchDB/Cloudant. Based on SuperLogin, updated and rewritten in Typescript.
251 lines (250 loc) • 8.57 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DbManager = void 0;
const uuid_1 = require("uuid");
const util_1 = require("../util");
class DbManager {
constructor(userDB, config) {
this.userDB = userDB;
this.config = config;
}
/** returns the `SlUserDoc` if found, else null. Rejects on other error */
getUserByUUID(uuid) {
return this.userDB.get((0, util_1.removeHyphens)(uuid)).catch(err => {
if (err.status === 404) {
return null;
}
else {
return Promise.reject(err);
}
});
}
/**
* returns the `SlUserDoc`, if found, else `null`.
* Todo: rejecting with 404 might be better!
*/
getUserBy(identifier, login) {
if (identifier === '_id') {
return this.getUserByUUID(login);
}
return this.userDB
.view('auth', identifier, { key: login, include_docs: true })
.then(results => {
if (results.rows.length === 1) {
return Promise.resolve(results.rows[0].doc);
}
else if (results.rows.length > 1) {
console.error(`Invalid state - got multiple docs for ${identifier}: ${login}`);
return Promise.reject({
status: 500,
error: 'Internal Server Error'
});
}
else {
return Promise.resolve(null);
}
});
}
async findUserDocBySession(key) {
const results = await this.userDB.view('auth', 'session', {
key,
include_docs: true
});
if (results.rows.length > 0) {
return results.rows[0].doc;
}
else {
return undefined;
}
}
/**
* generates a unique user_id used as `key` for backwards compatibility with
* old `user_id`s. Returns a URL-Safe UUID, shortened to length 8.
*/
async generateUsername() {
let keyOK = false;
let newKey;
while (!keyOK) {
newKey = (0, util_1.generateSlUserKey)();
keyOK = await this.verifyNewDBKey(newKey);
}
return newKey;
}
async verifyNewDBKey(newKey) {
const keyQuery = {
selector: {
key: newKey
},
fields: ['key']
};
const results = await this.userDB.find(keyQuery);
return results.docs.length === 0;
}
/** adds a log entry for the `action` and returns the modified `userDoc` */
logActivity(action, provider, userDoc) {
const logSize = this.config.security?.userActivityLogSize;
if (!logSize) {
return userDoc;
}
if (!userDoc.activity || !(userDoc.activity instanceof Array)) {
userDoc.activity = [];
}
const entry = {
timestamp: new Date().toISOString(),
action: action,
provider: provider
};
userDoc.activity.unshift(entry);
while (userDoc.activity.length > logSize) {
userDoc.activity.pop();
}
return userDoc;
}
getMatchingIdentifier(login, allowUUID = false) {
if ((allowUUID || this.config.local.uuidLogin) &&
[32, 36].includes(login.length) &&
!login.includes('@')) {
const testStr = login.length === 32 ? (0, util_1.hyphenizeUUID)(login) : login;
if ((0, uuid_1.validate)(testStr)) {
return '_id';
}
}
else if (this.config.local.usernameLogin && util_1.USER_REGEXP.test(login)) {
return 'key';
}
else if (util_1.EMAIL_REGEXP.test(login)) {
return 'email';
}
return undefined;
}
getUser(login, allowUUId = false) {
const identifier = this.getMatchingIdentifier(login, allowUUId);
if (!identifier) {
console.log('no matching identifier for login: ', login);
return Promise.reject({ error: 'Bad request', status: 400 });
}
return this.getUserBy(identifier, login);
}
async initLinkSocial(login, provider, auth, profile) {
let user;
// Load user doc
const results = await this.userDB.view('auth', provider, {
key: profile.id,
include_docs: true
});
if (results.rows.length === 0) {
user = await this.getUser(login);
}
else {
user = results.rows[0].doc;
const match = this.getMatchingIdentifier(login);
if (match === '_id') {
login = (0, util_1.removeHyphens)(login);
}
if (user[match] !== login) {
return Promise.reject({
error: 'Conflict',
message: 'This ' +
provider +
' profile is already in use by another account.',
status: 409
});
}
}
// Check for conflicting provider
if (user[provider] && user[provider].profile.id !== profile.id) {
return Promise.reject({
error: 'Conflict',
message: 'Your account is already linked with another ' +
provider +
'profile.',
status: 409
});
}
// Check email for conflict
if (profile.emails) {
const mailResults = await this.userDB.view('auth', 'email', {
key: profile.emails[0].value,
include_docs: true
});
if (mailResults.rows.length > 0) {
const match = this.getMatchingIdentifier(login);
if (match === '_id') {
login = (0, util_1.removeHyphens)(login);
}
if (mailResults.rows.some(row => row.doc[match] !== login)) {
throw {
error: 'Conflict',
message: 'The email ' +
profile.emails[0].value +
' is already in use by another account.',
status: 409
};
}
}
}
// Insert provider info
user[provider] = {};
user[provider].auth = auth;
user[provider].profile = profile;
if (!user.providers) {
user.providers = [];
}
if (user.providers.indexOf(provider) === -1) {
user.providers.push(provider);
}
if (!user.name) {
user.name = profile.displayName;
}
delete user[provider].profile._raw;
return user;
}
async unlink(user_id, provider) {
const user = await this.getUser(user_id);
if (!user) {
return Promise.reject({
error: 'Bad Request',
message: 400
});
}
if (!provider) {
return Promise.reject({
error: 'Unlink failed',
message: 'You must specify a provider to unlink.',
status: 400
});
}
// We can only unlink if there are at least two providers
if (!user.providers ||
!(user.providers instanceof Array) ||
user.providers.length < 2) {
return Promise.reject({
error: 'Unlink failed',
message: "You can't unlink your only provider!",
status: 400
});
}
// We cannot unlink local
if (provider === 'local') {
return Promise.reject({
error: 'Unlink failed',
message: "You can't unlink local.",
status: 400
});
}
// Check that the provider exists
if (!user[provider] || typeof user[provider] !== 'object') {
return Promise.reject({
error: 'Unlink failed',
message: 'Provider: ' + (0, util_1.capitalizeFirstLetter)(provider) + ' not found.',
status: 404
});
}
delete user[provider];
// Remove the unlinked provider from the list of providers
user.providers.splice(user.providers.indexOf(provider), 1);
await this.userDB.insert(user);
return user;
}
}
exports.DbManager = DbManager;