@perfood/couch-auth
Version:
Easy and secure authentication for CouchDB/Cloudant. Based on SuperLogin, updated and rewritten in Typescript.
1,240 lines • 52.3 kB
JavaScript
'use strict';
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.User = exports.ValidErr = void 0;
const sofa_model_1 = __importDefault(require("@sl-nx/sofa-model"));
const deepmerge_1 = __importDefault(require("deepmerge"));
const url_1 = __importDefault(require("url"));
const uuid_1 = require("uuid");
const dbauth_1 = require("./dbauth");
const session_hashing_1 = require("./session-hashing");
const user_hashing_1 = require("./user-hashing");
const DbManager_1 = require("./user/DbManager");
const util_1 = require("./util");
var ValidErr;
(function (ValidErr) {
ValidErr["exists"] = "already in use";
ValidErr["emailInvalid"] = "invalid email";
ValidErr["userInvalid"] = "invalid username";
})(ValidErr || (exports.ValidErr = ValidErr = {}));
class User {
constructor(config, userDB, couchAuthDB, mailer, emitter, couchServer) {
this.config = config;
this.userDB = userDB;
this.couchAuthDB = couchAuthDB;
this.mailer = mailer;
this.emitter = emitter;
this.couchServer = couchServer;
this.dbAuth = new dbauth_1.DBAuth(config, userDB, couchServer, couchAuthDB);
this.onCreateActions = [];
this.onLinkActions = [];
this.hasher = new user_hashing_1.UserHashing(config);
this.session = new session_hashing_1.SessionHashing(config);
this.userDbManager = new DbManager_1.DbManager(userDB, config);
this.passwordConstraints = config.local.passwordConstraints;
// the validation functions are public and callable without `this` context
this.validateUsername = async function (username) {
if (!username) {
return;
}
if (username.startsWith('_') || !username.match(util_1.USER_REGEXP)) {
return ValidErr.userInvalid;
}
try {
const result = await userDB.view('auth', 'key', { key: username });
if (result.rows.length === 0) {
// Pass!
return;
}
else {
return ValidErr.exists;
}
}
catch (err) {
throw new Error(err);
}
};
this.validateEmail = async function (email) {
if (!email) {
return;
}
if (!email.match(util_1.EMAIL_REGEXP)) {
return ValidErr.emailInvalid;
}
try {
const result = await userDB.view('auth', 'email', { key: email });
if (result.rows.length === 0) {
// Pass!
return;
}
else {
return ValidErr.exists;
}
}
catch (err) {
throw new Error(err);
}
};
const requiredConsents = [];
for (const [k, v] of Object.entries(config.local.consents ?? {})) {
if (v.required) {
requiredConsents.push(k);
}
}
this.validateConsents = function (initialConsents) {
if (initialConsents === undefined && !requiredConsents.length) {
return;
}
const err = (0, util_1.verifyConsentUpdate)(initialConsents, config);
if (err) {
return err;
}
const providedConsents = new Set(Object.keys(initialConsents));
if (requiredConsents.some(c => !providedConsents.has(c))) {
return 'must include all required consents';
}
};
// `consents`, `sessionType` are added dynamically based on the config
const userModel = {
async: true,
whitelist: ['name', 'username', 'email', 'password', 'confirmPassword'],
customValidators: {
validateEmail: this.validateEmail,
validateUsername: this.validateUsername,
matches: this.matches,
validateConsents: this.validateConsents
},
sanitize: {
name: ['trim'],
username: ['trim', 'toLowerCase'],
email: ['trim', 'toLowerCase']
},
validate: {
email: {
presence: true,
validateEmail: true
},
username: {
presence: true,
validateUsername: true
},
password: this.passwordConstraints,
confirmPassword: {
presence: true
}
},
static: {
type: 'user',
roles: config.security.defaultRoles,
providers: ['local']
},
rename: {
username: 'key'
}
};
this.resetPasswordModel = {
async: true,
customValidators: {
matches: this.matches
},
validate: {
token: {
presence: true
},
password: this.passwordConstraints,
confirmPassword: {
presence: true
}
}
};
this.changePasswordModel = {
async: true,
customValidators: {
matches: this.matches
},
validate: {
newPassword: this.passwordConstraints,
confirmPassword: {
presence: true
}
}
};
if (config.local.emailUsername) {
delete userModel.validate.username;
}
if (config.local.consents) {
userModel.whitelist.push('consents');
userModel.validate.consents = {
validateConsents: true
};
if (requiredConsents.length) {
userModel.validate.consents.presence = true;
}
}
if (config.security.sessionConfig) {
userModel.whitelist.push('sessionType');
const sessionValidator = {
inclusion: {
within: Object.keys(config.security.sessionConfig)
}
};
userModel.validate.sessionType = sessionValidator;
this.resetPasswordModel.validate.sessionType = sessionValidator;
}
this.userModel = userModel;
}
/**
* Hashes a password using PBKDF2 and returns an object containing `salt` and
* `derived_key`.
*/
hashPassword(pw) {
return this.hasher.hashUserPassword(pw);
}
/**
* Verifies a password using a hash object. If you have a user doc, pass in
* `local` as the hash object.
* @returns resolves with `true` if valid, `false` if not
*/
verifyPassword(obj, pw) {
return this.hasher.verifyUserPassword(obj, pw);
}
/**
* Use this to add as many functions as you want to transform the new user
* document before it is saved. Your function should accept two arguments
* (userDoc, provider) and return a Promise that resolves to the modified
* user document.
* onCreate functions will be chained in the order they were added.
* @param {Function} fn
*/
onCreate(fn) {
if (typeof fn === 'function') {
this.onCreateActions.push(fn);
}
else {
throw new TypeError('onCreate: You must pass in a function');
}
}
/**
* Does the same thing as onCreate, but is called every time a user links a
* new provider, or their profile information is refreshed.
* This allows you to process profile information and, for example, create a
* master profile.
* If an object called profile exists inside the user doc it will be passed
* to the client along with session information at each login.
*/
onLink(fn) {
if (typeof fn === 'function') {
this.onLinkActions.push(fn);
}
else {
throw new TypeError('onLink: You must pass in a function');
}
}
/** Validation function for ensuring that two fields match */
matches(value, option, key, attributes) {
if (attributes && attributes[option] !== value) {
return 'does not match ' + option;
}
}
async processTransformations(fnArray, userDoc, provider) {
for (const fn of fnArray) {
userDoc = await fn.call(null, userDoc, provider);
}
return userDoc;
}
/**
* retrieves by email (default) or username or uuid if the config options are
* set. Rejects if no valid format.
*/
getUser(login, allowUUID = false) {
return this.userDbManager.getUser(login, allowUUID);
}
async handleEmailExists(email, req) {
const existingUser = await this.userDbManager.getUserBy('email', email);
if (this.config.local.sendExistingUserEmail && !this.config.mailer.useCustomMailer) {
await this.mailer.sendEmail('signupExistingEmail', email, {
user: existingUser,
req
});
}
this.emitter.emit('signup-attempt', existingUser, 'local');
}
/**
* Creates a new local user with a username/email and password.
* @param form requires the following: `username` and/or `email`, `password`,
* and `confirmPassword`. `name` is optional. Any additional fields must be
* whitelisted in your config under `userModel` or they will be removed.
* @param req additional request data passed to the email template
* @returns `SlUserDoc` of the created user. Note that the `_rev` won't be
* correct if `config.security.loginOnRegistration` is `false`: This is done
* to prevent [time-based attacks](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#authentication-responses).
* Send a response right after this function resolves and subscribe to the
* `signup` event instead for further processing.
*/
async createUser(form, req) {
req = req || {};
let finalUserModel = this.userModel;
const newUserModel = this.config.userModel;
if (typeof newUserModel === 'object') {
let whitelist;
if (newUserModel.whitelist) {
whitelist = (0, util_1.arrayUnion)(this.userModel.whitelist, newUserModel.whitelist);
}
const addUserModel = this.config.userModel;
finalUserModel = (0, deepmerge_1.default)(this.userModel, addUserModel ? addUserModel : {});
finalUserModel.whitelist = whitelist || finalUserModel.whitelist;
}
const UserModel = (0, sofa_model_1.default)(finalUserModel);
const user = new UserModel(form);
let newUser = {};
let hasError = false;
try {
newUser = await user.process();
}
catch (err) {
hasError = true;
let doThrow = true;
if (err.email &&
this.config.local.emailUsername &&
this.config.local.requireEmailConfirm) {
const inUseIdx = err.email.findIndex((s) => s.endsWith(ValidErr.exists));
if (inUseIdx >= 0) {
err.email.splice(inUseIdx, 1);
if (err.email.length === 0) {
delete err.email;
if (Object.keys(err).length === 0) {
this.handleEmailExists(form.email, req);
doThrow = false;
}
}
}
}
if (doThrow) {
throw {
error: 'Validation failed',
validationErrors: err,
status: 400
};
}
}
// TODO: This is an instance of the promise constructor anti-pattern
return new Promise(async (resolve, reject) => {
newUser = await this.prepareNewUser(newUser);
if (hasError || !this.config.security.loginOnRegistration) {
resolve(hasError ? undefined : newUser);
}
if (!hasError) {
const finalUser = await this.insertNewUserDocument(newUser, req);
this.emitter.emit('signup', finalUser, 'local');
if (this.config.security.loginOnRegistration) {
resolve(finalUser);
}
}
});
}
async prepareNewUser(newUser) {
const uid = (0, uuid_1.v4)();
// todo: remove, this is just for backwards compat...
if (this.config.local.sendNameAndUUID) {
newUser.user_uid = uid;
}
newUser._id = (0, util_1.removeHyphens)(uid);
if (this.config.local.emailUsername) {
newUser.key = await this.userDbManager.generateUsername();
}
if (this.config.local.sendConfirmEmail) {
newUser.unverifiedEmail = {
email: newUser.email,
token: (0, util_1.URLSafeUUID)()
};
delete newUser.email;
}
newUser.local = await this.hashPassword(newUser.password ?? (0, util_1.URLSafeUUID)());
delete newUser.password;
delete newUser.confirmPassword;
if (newUser.consents) {
for (const [k, v] of Object.entries(newUser.consents)) {
v.timestamp = new Date().toISOString();
newUser.consents[k] = [v];
}
}
newUser.signUp = {
provider: 'local',
timestamp: new Date().toISOString()
};
return newUser;
}
async insertNewUserDocument(newUser, req) {
newUser = await this.addUserDBs(newUser);
newUser = this.userDbManager.logActivity('signup', 'local', newUser);
const finalNewUser = await this.processTransformations(this.onCreateActions, newUser, 'local');
const result = await this.userDB.insert(finalNewUser);
newUser._rev = result.rev;
if (this.config.local.sendConfirmEmail) {
await this.sendConfirmEmail(newUser, req);
}
return newUser;
}
async sendConfirmEmail(user, req) {
if (!this.config.mailer.useCustomMailer) {
try {
await this.mailer.sendEmail('confirmEmail', user.unverifiedEmail.email, {
req: req,
user: user
});
}
catch (err) {
this.emitter.emit('confirmation-email-error', user);
console.warn('error sending confirmation email to ' + user.unverifiedEmail?.email, err);
}
}
}
/**
* Creates a new user following authentication from an OAuth provider.
* If the user already exists it will update the profile.
* @param provider the name of the provider in lowercase, (e.g. 'facebook')
* @param {any} auth credentials supplied by the provider
* @param {any} profile the profile supplied by the provider
*/
async createUserSocial(provider, auth, profile) {
let user;
let newAccount = false;
// This used to be consumed by `.nodeify` from Bluebird. I hope `callbackify` works just as well...
const results = await this.userDB.view('auth', provider, {
key: profile.id,
include_docs: true
});
if (results.rows.length > 0) {
user = results.rows[0].doc;
}
else {
newAccount = true;
user = {
email: profile.emails ? profile.emails[0].value : undefined,
providers: [provider],
type: 'user',
roles: this.config.security.defaultRoles,
signUp: {
provider: provider,
timestamp: new Date().toISOString()
}
};
user[provider] = {};
// Now we need to generate a username
if (!user.email) {
throw {
error: 'No email provided',
message: `An email is required for registration, but ${provider} didn't supply one.`,
status: 400
};
}
const emailCheck = await this.validateEmail(user.email);
if (emailCheck) {
throw {
error: 'Email already in use',
message: 'Your email is already in use. Try signing in first and then linking this account.',
status: 409
};
}
user.key = await this.userDbManager.generateUsername();
if (this.config.providers[provider].confirmEmail) {
user.unverifiedEmail = {
email: user.email,
token: (0, util_1.URLSafeUUID)()
};
delete user.email;
}
}
user[provider].auth = auth;
user[provider].profile = profile;
if (!user.name) {
user.name = profile.displayName;
}
delete user[provider].profile._raw;
if (newAccount) {
user._id = (0, util_1.removeHyphens)((0, uuid_1.v4)());
user.user_uid = (0, util_1.hyphenizeUUID)(user._id);
user = await this.addUserDBs(user);
}
let finalUser = await this.processTransformations(newAccount ? this.onCreateActions : this.onLinkActions, user, provider);
const action = newAccount ? 'signup' : 'create-social';
finalUser = this.userDbManager.logActivity(action, provider, finalUser);
await this.userDB.insert(finalUser);
this.emitter.emit(action, user, provider);
if (this.config.providers[provider].confirmEmail) {
await this.sendConfirmEmail(user);
}
return user;
}
/**
* like `createUserSocial`, but for an already existing user identified by
* `login`
*/
async linkUserSocial(login, provider, auth, profile) {
let userDoc = await this.userDbManager.initLinkSocial(login, provider, auth, profile);
userDoc = await this.processTransformations(this.onLinkActions, userDoc, provider);
userDoc = this.userDbManager.logActivity('link-social', provider, userDoc);
await this.userDB.insert(userDoc);
this.emitter.emit('link-social', userDoc, provider);
return userDoc;
}
/**
* Removes the specified provider from the user's account.
* `local` cannot be removed. If there is only one provider left it will fail.
* Returns the modified user, if successful.
* @param login email, username or UUID
* @param provider the OAuth provider
*/
unlinkUserSocial(login, provider) {
return this.userDbManager.unlink(login, provider);
}
/**
* Creates a new session for a user
* @param params: The login options.
* - `login`: the email, username or UUID (depending on your config)
* - `provider`: 'local' or one of the configured OAuth providers
* - `byUUID`: if `true`, interpret `login` always as UUID
* - `sessionType`: see `security` -> `sessionConfig` for details
* @returns the new session
*/
async createSession(params) {
const login = params.login;
const provider = params.provider;
let user = params.byUUID
? await this.userDbManager.getUserByUUID(login)
: await this.getUser(login);
if (!user) {
console.warn('createSession - could not retrieve: ', login);
throw { error: 'Bad Request', status: 400 };
}
const now = Date.now();
const password = (0, util_1.URLSafeUUID)();
let sessionLife = this.config.security.sessionLife * 1000;
if (params.sessionType) {
const sessionConfig = this.config.security.sessionConfig[params.sessionType];
(0, util_1.verifySessionConfigRoles)(user.roles, sessionConfig);
sessionLife = sessionConfig.lifetime * 1000;
}
const token = {
key: this.config.security.reuseInactiveSessions !== false
? user.inactiveSessions?.shift() ?? (0, util_1.getSessionKey)()
: (0, util_1.getSessionKey)(),
password,
_id: user._id,
issued: now,
expires: now + sessionLife,
roles: user.roles,
provider
};
try {
await this.dbAuth.storeKey(user.key, (0, util_1.hyphenizeUUID)(user._id), token.key, password, token.expires, user.roles, provider);
}
catch (error) {
let msg = 'Could not create session token with key: ' +
token.key +
' - was inactiveSessions copied and does the key already exist?';
if (error.status) {
msg += ', status: ' + error.status;
}
console.error(msg);
throw error;
}
// authorize the new session across all dbs
if (user.personalDBs) {
await this.dbAuth.authorizeUserSessions(user.personalDBs, token.key);
}
if (!user.session) {
user.session = {};
}
const newSession = {
issued: token.issued,
expires: token.expires,
provider: provider,
sessionType: params.sessionType ?? undefined
};
user.session[token.key] = newSession;
// Clear any failed login attempts
if (provider === 'local') {
if (!user.local)
user.local = {};
delete user.local.failedLoginAttempts;
delete user.local.lockedUntil;
}
const userDoc = this.userDbManager.logActivity('login', provider, user);
// Clean out expired sessions on login
const finalUser = await this.dbAuth.logoutUserSessions(userDoc, 'expired');
user = finalUser;
await this.userDB.insert(finalUser);
newSession.token = token.key;
newSession.password = password;
newSession.user_id = user.key;
newSession.roles = user.roles;
// Inject the list of userDBs
if (typeof user.personalDBs === 'object') {
const userDBs = {};
let publicURL;
if (this.config.dbServer.publicURL) {
const dbObj = url_1.default.parse(this.config.dbServer.publicURL);
dbObj.auth = newSession.token + ':' + newSession.password;
publicURL = url_1.default.format(dbObj);
}
else {
publicURL =
this.config.dbServer.protocol +
newSession.token +
':' +
newSession.password +
'@' +
this.config.dbServer.host +
'/';
}
Object.keys(user.personalDBs).forEach(finalDBName => {
userDBs[user.personalDBs[finalDBName].name] = publicURL + finalDBName;
});
newSession.userDBs = userDBs;
}
if (user.profile) {
newSession.profile = user.profile;
}
if (this.config.local.sendNameAndUUID) {
if (user.name) {
newSession.name = user.name;
}
newSession.user_uid = (0, util_1.hyphenizeUUID)(user._id);
}
this.emitter.emit('login', newSession, provider);
return newSession;
}
/**
* Extends the life of your current token and returns updated token information.
* The only field that will change is expires. Expired sessions are removed.
* todo:
* - handle error if invalid state occurs that doc is not present.
*/
async refreshSession(sessionId) {
let userDoc = await this.userDbManager.findUserDocBySession(sessionId);
let minutesToExtend = this.config.security.sessionLife;
if (userDoc.session[sessionId].sessionType) {
minutesToExtend =
this.config.security.sessionConfig[userDoc.session[sessionId].sessionType].lifetime;
}
const newExpiration = Date.now() + minutesToExtend * 1000;
userDoc.session[sessionId].expires = newExpiration;
// Clean out expired sessions on refresh
userDoc = await this.dbAuth.logoutUserSessions(userDoc, 'expired');
userDoc = this.userDbManager.logActivity('refresh', sessionId, userDoc);
await this.userDB.insert(userDoc);
await this.dbAuth.extendKey(sessionId, newExpiration);
const newSession = {
...userDoc.session[sessionId],
token: sessionId,
user_uid: (0, util_1.hyphenizeUUID)(userDoc._id),
user_id: userDoc.key,
roles: userDoc.roles
};
delete newSession['ip'];
this.emitter.emit('refresh', newSession);
return newSession;
}
/**
* Required form fields: token, password, and confirmPassword
*/
async resetPassword(form, req = undefined) {
req = req || {};
const ResetPasswordModel = (0, sofa_model_1.default)(this.resetPasswordModel);
const passwordResetForm = new ResetPasswordModel(form);
let user;
try {
await passwordResetForm.validate();
}
catch (err) {
throw {
error: 'Validation failed',
validationErrors: err,
status: 400
};
}
const tokenHash = (0, util_1.hashToken)(form.token);
const results = await this.userDB.view('auth', 'passwordReset', {
key: tokenHash,
include_docs: true
});
if (!results.rows.length) {
throw { status: 400, error: 'Invalid token' };
}
user = results.rows[0].doc;
if (user.forgotPassword.expires < Date.now()) {
return Promise.reject({ status: 400, error: 'Token expired' });
}
if (this.config.security.passwordResetRateLimit) {
const username = form[this.config.local.usernameField || 'username'];
if (!username) {
throw { status: 400, error: 'Invalid token' };
}
const slUser = await this.getUser(form[this.config.local.usernameField || 'username']);
if (user._id !== slUser._id) {
throw { status: 400, error: 'Invalid token' };
}
}
const hash = await this.hashPassword(form.password);
if (!user.local) {
user.local = {};
}
user.local = { ...user.local, ...hash };
if (user.providers.indexOf('local') === -1) {
user.providers.push('local');
}
// logout user completely
user = await this.dbAuth.logoutUserSessions(user, 'all');
delete user.forgotPassword;
if (user.unverifiedEmail) {
user = await this.markEmailAsVerified(user);
}
user = this.userDbManager.logActivity('password-reset', 'local', user);
await this.userDB.insert(user);
await this.sendModifiedPasswordEmail(user, req);
this.emitter.emit('password-reset', user);
return user;
}
/**
* Changes the password of a user, validating the provided data.
* @param login the `email`, `_id` or `key` of the `sl-user` to updated
* @param form `newPassword`, `confirmPassword` (same) and `currentPassword`
* as sent by the user.
* @param req additional data that will be passed to the template as `req`
*/
async changePasswordSecure(login, form, req) {
req = req || {};
const ChangePasswordModel = (0, sofa_model_1.default)(this.changePasswordModel);
const changePasswordForm = new ChangePasswordModel(form);
try {
await changePasswordForm.validate();
}
catch (err) {
throw {
error: 'Validation failed',
validationErrors: err,
status: 400
};
}
try {
const user = await this.getUser(login);
if (!user) {
throw { error: 'Bad Request', status: 400 }; // should exist.
}
if (user.local && user.local.salt && user.local.derived_key) {
// Password is required
if (!form.currentPassword) {
throw {
error: 'Password change failed',
message: 'You must supply your current password in order to change it.',
status: 400
};
}
await this.verifyPassword(user.local, form.currentPassword);
}
await this.changePassword(user._id, form.newPassword, user, req);
}
catch (err) {
throw (err || {
error: 'Password change failed',
message: 'The current password you supplied is incorrect.',
status: 400
});
}
if (req.user && req.user.key) {
await this.logoutOthers(req.user.key);
}
}
async forgotUsername(email, req) {
if (!email || !email.match(util_1.EMAIL_REGEXP)) {
throw { error: 'invalid email', status: 400 };
}
req = req || {};
try {
const user = await this.userDbManager.getUserBy('email', email);
if (!user) {
throw {
error: 'User not found',
status: 404
};
}
if (!this.config.mailer.useCustomMailer) {
await this.mailer.sendEmail('forgotUsername', user.email || user.unverifiedEmail.email, { user: user, req: req });
}
this.emitter.emit('forgot-username', user);
}
catch (err) {
this.emitter.emit('forgot-username-attempt', email);
if (err.status !== 404) {
throw err;
}
}
}
/**
* Changes the password of a user. Note that this method does not perform
* any validations of the supplied password as `changePasswordSecure` does.
* @param user_uid the UUID of the user (without hypens, `_id` in `sl-users`)
* @param newPassword the new password for the user
* @param userDoc the `SlUserDoc` of the user. Will be retrieved by the
* `user_uid` if not passed.
* @param req additional data that will be passed to the template as `req`
*/
async changePassword(user_uid, newPassword, userDoc, req) {
req = req || {};
if (!userDoc) {
try {
userDoc = await this.userDB.get(user_uid);
}
catch (error) {
throw {
error: 'User not found',
status: 404
};
}
}
const hash = await this.hashPassword(newPassword);
if (!userDoc.local) {
userDoc.local = {};
}
if (userDoc.providers.indexOf('local') === -1) {
userDoc.providers.push('local');
}
userDoc.local = { ...userDoc.local, ...hash };
const finalUser = this.userDbManager.logActivity('password-change', 'local', userDoc);
await this.userDB.insert(finalUser);
await this.sendModifiedPasswordEmail(userDoc, req);
this.emitter.emit('password-change', userDoc);
}
/**
* Upgrades the password hash of a user.
* @param userDoc the `SlUserDoc` of the user
* @param password the password for the user
* @returns 'upgraded' if the hash was upgraded, 'not-needed' if the
* hash is already up to date, 'no-hash' if no local password hash exists.
*/
async upgradePasswordHashIfNeeded(userDoc, password) {
if (!userDoc.local || !userDoc.local.derived_key || !userDoc.local.salt || userDoc.providers.indexOf('local') === -1) {
return 'no-hash';
}
if (!this.hasher.isUpgradeNeeded(userDoc.local)) {
return 'not-needed';
}
const hash = await this.hashPassword(password);
userDoc.local = { ...userDoc.local, ...hash };
await this.userDB.insert(userDoc);
return 'upgraded';
}
async sendModifiedPasswordEmail(user, req) {
if (this.config.local.sendPasswordChangedEmail && !this.config.mailer.useCustomMailer) {
await this.mailer.sendEmail('modifiedPassword', user.email || user.unverifiedEmail.email, { user: user, req: req });
}
}
/**
* sends out a passwort reset email, if the user exists
* @param email email of the user
* @param req additional request data, passed to the template as `req`
*/
async forgotPassword(email, req) {
email = email.toLowerCase();
if (!email || !email.match(util_1.EMAIL_REGEXP)) {
return Promise.reject({ error: 'invalid email', status: 400 });
}
req = req || {};
try {
const user = await this.userDbManager.getUserBy('email', email);
if (!user) {
throw {
error: 'User not found', // not sent as response.
status: 404
};
}
this.completeForgotPassRequest(user, req).catch(err => {
this.emitter.emit('forgot-password-attempt', email);
console.warn('forgot-password: failed for ', user._id, ' with: ', err);
});
}
catch (err) {
this.emitter.emit('forgot-password-attempt', email);
if (err.status !== 404) {
return Promise.reject(err);
}
}
}
async completeForgotPassRequest(user, req) {
let token = (0, util_1.URLSafeUUID)();
if (this.config.local.tokenLengthOnReset) {
token = token.substring(0, this.config.local.tokenLengthOnReset);
}
const tokenHash = (0, util_1.hashToken)(token);
user.forgotPassword = {
token: tokenHash, // Store secure hashed token
issued: Date.now(),
expires: Date.now() + this.config.security.tokenLife * 1000
};
user = this.userDbManager.logActivity('forgot-password', 'local', user);
await this.userDB.insert(user);
if (!this.config.mailer.useCustomMailer) {
await this.mailer.sendEmail('forgotPassword', user.email || user.unverifiedEmail.email, { user: user, req: req, token: token });
}
this.emitter.emit('forgot-password', { user, token });
}
/**
* Marks the user's email as verified. `token` comes from the confirmation
* email. Resolves if the verification is successful or the token was still
* saved from a previous verification. Rejects if token is invalid.
* @param token
*/
async verifyEmail(token) {
const lastTokenQuery = this.config.local.keepEmailConfirmToken
? this.userDB.view('auth', 'lastEmailToken', {
key: token,
include_docs: true
})
: Promise.resolve({ rows: [] });
const [verifyResult, lastTokenResult] = await Promise.all([
this.userDB.view('auth', 'verifyEmail', {
key: token,
include_docs: true
}),
lastTokenQuery
]);
if (!verifyResult.rows.length &&
(!this.config.local.keepEmailConfirmToken || !lastTokenResult.rows.length)) {
return Promise.reject({ error: 'Invalid token', status: 400 });
}
if (verifyResult.rows.length > 1) {
console.error('Duplicate email confirm token for verifyEmail view: ' + token);
return Promise.reject({
status: 500,
error: 'Internal Server Error'
});
}
if (verifyResult.rows.length === 1) {
let user = verifyResult.rows[0].doc;
user = await this.markEmailAsVerified(user);
await this.userDB.insert(user);
}
}
async markEmailAsVerified(userDoc) {
if (this.config.local.keepEmailConfirmToken) {
userDoc.lastEmailToken = userDoc.unverifiedEmail.token;
}
userDoc.email = userDoc.unverifiedEmail.email;
delete userDoc.unverifiedEmail;
this.emitter.emit('email-verified', userDoc);
return this.userDbManager.logActivity('email-verified', 'local', userDoc);
}
async completeEmailChange(login, newEmail, req) {
const user = await this.getUser(login);
if (!user) {
throw { error: 'Bad Request', status: 400 }; // should exist.
}
if (this.config.local.sendConfirmEmail) {
delete user.lastEmailToken;
user.unverifiedEmail = {
email: newEmail,
token: (0, util_1.URLSafeUUID)()
};
if (!this.config.mailer.useCustomMailer) {
await this.mailer.sendEmail('confirmEmailChange', user.unverifiedEmail.email, {
req: req,
user: user
});
}
}
else {
user.email = newEmail;
}
const finalUser = this.userDbManager.logActivity('email-changed', req.user.provider, user);
await this.userDB.insert(finalUser);
this.emitter.emit('email-changed', user);
}
/**
* Changes the user's email. If email verification is enabled
* (`local.sendConfirmEmail`), a confirmation email will be sent out.
* @param login user's email, username or UUID (depending on your config)
* @param newEmail the new email
* @param req additional request data, passed to the template as `req`
*/
async changeEmail(login, newEmail, req) {
req = req || {};
if (!req.user) {
req.user = { provider: 'local' };
}
newEmail = newEmail.toLowerCase().trim();
const emailError = await this.validateEmail(newEmail);
if (emailError) {
this.emitter.emit('email-change-attempt', login, newEmail);
if (this.config.local.requireEmailConfirm &&
emailError === ValidErr.exists) {
return;
}
else {
throw emailError;
}
}
this.completeEmailChange(login, newEmail, req);
}
/**
* Deauthorizes the specified database from the user's account, and optionally destroys it.
* @param login email, username or UUID of the user (depending on your config)
* @param dbName full path for a shared db, or base name for a private db
* @param deletePrivate when true, will destroy a private DB with the `dbName`
* @param deleteShared when true, will destroy a shared DB with the `dbName.
* Caution: may destroy other users' data!
*/
async removeUserDB(login, dbName, deletePrivate = false, deleteShared = false) {
let update = false;
const user = await this.getUser(login);
if (!user) {
throw { status: 404, error: 'User not found' };
}
if (user.personalDBs && typeof user.personalDBs === 'object') {
for (const db of Object.keys(user.personalDBs)) {
if (user.personalDBs[db].name === dbName) {
const type = user.personalDBs[db].type;
delete user.personalDBs[db];
update = true;
if (type === 'private' && deletePrivate) {
await this.dbAuth.removeDB(dbName);
}
if (type === 'shared' && deleteShared) {
await this.dbAuth.removeDB(dbName);
}
}
}
}
if (update) {
this.emitter.emit('user-db-removed', user.key, dbName);
return this.userDB.insert(user);
}
}
/**
* Logs out all of a user's sessions. One of `login` or `session_id` must be
* provided.
* @param login the email, username or UUID of the user
* @param session_id the id of the session - i.e. `org.couchdb.user:${suffix}`
* @returns
*/
async logoutAll(login, session_id) {
let user;
if (login) {
user = await this.getUser(login);
}
else if (session_id) {
user = await this.userDbManager.findUserDocBySession(session_id);
login = session_id;
}
if (!user) {
return Promise.reject({
error: 'unauthorized',
status: 401
});
}
await this.dbAuth.logoutUserSessions(user, 'all');
user = this.userDbManager.logActivity('logout-all', session_id, user);
this.emitter.emit('logout-all', user.key);
return this.userDB.insert(user);
}
/**
* Logs out the specified session. Note that in case of a server error, it can
* happen that only the entry in the `_users` DB is removed. This deauthorizes
* the user, but the records in `sl-users` might not be accurate in that case.
*/
async logoutSession(session_id) {
let startSessions = 0;
let endSessions = 0;
let user = await this.userDbManager.findUserDocBySession(session_id);
if (!user) {
throw {
error: 'unauthorized',
status: 401
};
}
if (user.session) {
startSessions = Object.keys(user.session).length;
if (user.session[session_id]) {
delete user.session[session_id];
if (this.config.security.reuseInactiveSessions !== false) {
user.inactiveSessions = [...(user.inactiveSessions ?? []), session_id];
}
}
}
// 1.) if this fails, the whole logout has failed! Else ok, will be cleaned up later.
await this.dbAuth.removeKeys(session_id);
//console.log('1.) - removed keys for', session_id);
let caughtError = {};
try {
// 2) deauthorize from user's dbs
await this.dbAuth.deauthorizeUser(user, session_id);
//console.log('2.) - deauthorized user for ', session_id);
// Clean out expired sessions
user = await this.dbAuth.logoutUserSessions(user, 'expired');
//console.log('3.) - cleaned up for user', user.key);
if (user.session) {
endSessions = Object.keys(user.session).length;
}
this.emitter.emit('logout', user.key);
if (startSessions !== endSessions) {
// 3) update the sl-doc
user = this.userDbManager.logActivity('logout', session_id, user);
return this.userDB.insert(user);
}
else {
return false;
}
}
catch (error) {
caughtError = {
err: error.err,
reason: error.reason,
statusCode: error.statusCode
};
console.warn('Error during logoutSessions() - err: ' +
error.err +
', reason: ' +
error.reason +
', status: ' +
error.statusCode);
}
return caughtError;
}
/** Logs out all of a user's sessions, except for the one specified. */
async logoutOthers(sessionId) {
let user = await this.userDbManager.findUserDocBySession(sessionId);
if (user) {
if (user.session && user.session[sessionId]) {
user = await this.dbAuth.logoutUserSessions(user, 'other', sessionId);
user = this.userDbManager.logActivity('logout-others', sessionId, user);
this.emitter.emit('logout-others', user.key);
return this.userDB.insert(user);
}
}
return false;
}
/**
* Removes a user and terminates all of his sessions
* @param login the user's uuid or email/key
* @param destroyDBs use `true` to also remove the personal DBs of the user
* @param reason additional information to be emmitted by `'user-deleted'`
*/
async removeUser(login, destroyDBs = false, reason) {
const promises = [];
let user = await this.getUser(login, true);
if (!user) {
throw { status: 404, error: 'not found' };
}
user = await this.dbAuth.logoutUserSessions(user, 'all');
if (destroyDBs && user.personalDBs) {
Object.keys(user.personalDBs).forEach(userdb => {
if (user.personalDBs[userdb].type === 'private') {
promises.push(this.dbAuth.removeDB(userdb));
}
});
await Promise.all(promises);
}
await this.userDB.destroy(user._id, user._rev);
this.emitter.emit('user-deleted', user, reason);
}
/**
* Confirms the user:password that has been passed as Bearer Token. Returns
* the passed `key` + the `user_uid`, `expires`, `roles`, `provider` from the
* the contents of the doc in the `_users`-DB.
*/
async confirmSession(key, password) {
try {
const doc = await this.dbAuth.retrieveKey(key);
if (!doc.provider || !doc.expires) {
const msg = `_users doc ${key} is not managed by CouchAuth`;
console.warn(msg);
throw msg;
}
if (doc.expires > Date.now()) {
if (await this.session.verifySessionPassword(doc, password)) {
return {
key,
_id: doc.user_id,
user_uid: doc.user_uid,
expires: doc.expires,
roles: doc.roles,
provider: doc.provider
};
}
else {
throw session_hashing_1.SessionHashing.invalidErr;
}
}
else {
this.dbAuth.removeKeys(key);
throw session_hashing_1.SessionHashing.invalidErr;
}
}
catch {
throw session_hashing_1.SessionHashing.invalidErr;
}
}
/**
* Associates a new database with the user's account. Will also authenticate
* all existing sessions with the new database. If the optional fields are not
* specified, they will be taken from `userDBs.model.{dbName}` or
* `userDBs.model._default` in your config.
* @param login the `key`, `email` or `_id` (user_uid) of the user
* @param dbName the name of the database. For a shared db, this is the actual
* path. For a private db userDBs.privatePrefix will be prepended, and
* `${user_uid}` appended.
* @param type 'private' (default) or 'shared'
* @param designDocs the name of the designDoc (if any) that will be seeded.
*/
addUserDB(login, dbName, type = 'private', designDocs, partitioned) {
let userDoc;
const dbConfig = this.dbAuth.getDBConfig(dbName, type);
dbConfig.designDocs = designDocs || dbConfig.designDocs || '';
dbConfig.partitioned = partitioned || dbConfig.partitioned || false;
return this.getUser(login)
.then(result => {
if (!result) {
return Promise.reject({ status: 404, error: 'User not found' });
}
userDoc = result;
return this.dbAuth.addUserDB(userDoc, dbName, dbConfig.designDocs, dbConfig.type, dbConfig.adminRoles, dbConfig.memberRoles, dbConfig.partitioned);
})
.then(finalDBName => {
if (!userDoc.personalDBs) {
userDoc.personalDBs = {};
}
delete dbConfig.designDocs;
delete dbConfig.adminRoles;
delete dbConfig.memberRoles;
userDoc.personalDBs[finalDBName] = dbConfig;
this.emitter.emit('user-db-added', userDoc.key, dbName);
return this.userDB.insert(userDoc);
});
}
/**
* Adds the private and authorises access to the shared userDBs
*/
addUserDBs(userDoc) {
if (!this.config.userDBs?.defaultDBs) {
return Promise.resolve(userDoc);
}
const promises = [];
userDoc.personalDBs = {};
const processUserDBs = (dbList, type) => {
dbList.forEach(userDBName => {
const dbConfig = this.dbAuth.getDBConfig(userDBName);
promises.push(this.dbAuth
.addUserDB(userDoc, userDBName, dbConfig.designDocs, type, dbConfig.adminRoles, dbConfig.memberRoles, dbConfig.partitioned)
.then(finalDBName => {
delete dbConfig.adminRoles;
delete dbConfig.memberRoles;
delete dbConfig.designDocs;
dbConfig.type = type;
userDoc.personalDBs[finalDBName] = dbConfig;
}));
});
};
// Just in case defaultDBs is not specified
let defaultPrivateDBs = this.