const passport = require('passport'); const {set, get} = require('lodash'); const UsersService = require('./users'); const SettingsService = require('./settings'); const TokensService = require('./tokens'); const fetch = require('node-fetch'); const FormData = require('form-data'); const LocalStrategy = require('passport-local').Strategy; const errors = require('../errors'); const uuid = require('uuid'); const debug = require('debug')('talk:services:passport'); const bowser = require('bowser'); const ms = require('ms'); // Create a redis client to use for authentication. const {createClientFactory} = require('./redis'); const client = createClientFactory(); const { JWT_ISSUER, JWT_EXPIRY, JWT_AUDIENCE, JWT_ALG, RECAPTCHA_SECRET, RECAPTCHA_ENABLED, JWT_SIGNING_COOKIE_NAME, JWT_COOKIE_NAMES, JWT_CLEAR_COOKIE_LOGOUT, JWT_USER_ID_CLAIM, } = require('../config'); const { jwt } = require('../secrets'); // GenerateToken will sign a token to include all the authorization information // needed for the front end. const GenerateToken = (user) => { const claims = {}; // Set the user id. set(claims, JWT_USER_ID_CLAIM, user.id); return jwt.sign(claims, { jwtid: uuid.v4(), expiresIn: JWT_EXPIRY, issuer: JWT_ISSUER, audience: JWT_AUDIENCE, algorithm: JWT_ALG }); }; // SetTokenForSafari sends the token in a cookie for Safari clients. const SetTokenForSafari = (req, res, token) => { const browser = bowser._detect(req.headers['user-agent']); if (browser.ios || browser.safari) { debug('browser was safari/ios, setting a cookie'); res.cookie(JWT_SIGNING_COOKIE_NAME, token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', expires: new Date(Date.now() + ms(JWT_EXPIRY)) }); } else { debug('browser wasn\'t safari/ios, didn\'t set a cookie'); } }; // HandleGenerateCredentials validates that an authentication scheme did indeed // return a user, if it did, then sign and return the user and token to be used // by the frontend to display and update the UI. const HandleGenerateCredentials = (req, res, next) => (err, user) => { if (err) { return next(err); } if (!user) { return next(errors.ErrNotAuthorized); } // Generate the token to re-issue to the frontend. const token = GenerateToken(user); SetTokenForSafari(req, res, token); // Send back the details! res.json({user, token}); }; /** * Returns the response to the login attempt via a popup callback with some JS. */ const HandleAuthPopupCallback = (req, res, next) => (err, user) => { if (err) { return res.render('auth-callback', {auth: JSON.stringify({err, data: null})}); } if (!user) { return res.render('auth-callback', {auth: JSON.stringify({err: errors.ErrNotAuthorized, data: null})}); } // Generate the token to re-issue to the frontend. const token = GenerateToken(user); SetTokenForSafari(req, res, token); // We logged in the user! Let's send back the user data. res.render('auth-callback', {auth: JSON.stringify({err: null, data: {user, token}})}); }; /** * Validates that a user is allowed to login. * @param {User} user the user to be validated * @param {Function} done the callback for the validation */ async function ValidateUserLogin(loginProfile, user, done) { if (!user) { return done(new Error('user not found')); } if (user.disabled) { return done(new errors.ErrAuthentication('Account disabled')); } // If the user isn't a local user (i.e., a social user). if (loginProfile.provider !== 'local') { return done(null, user); } // The user is a local user, check if we need email confirmation. const {requireEmailConfirmation = false} = await SettingsService.retrieve(); // If we have the requirement of checking that emails for users are // verified, then we need to check the email address to ensure that it has // been verified. if (requireEmailConfirmation) { // Get the profile representing the local account. let profile = user.profiles.find((profile) => profile.id === loginProfile.id); // This should never get to this point, if it does, don't let this past. if (!profile) { throw new Error('ID indicated by loginProfile is not on user object'); } // If the profile doesn't have a metadata field, or it does not have a // confirmed_at field, or that field is null, then send them back. if (!profile.metadata || !profile.metadata.confirmed_at || profile.metadata.confirmed_at === null) { return done(errors.ErrNotVerified); } } return done(null, user); } //============================================================================== // JWT STRATEGY //============================================================================== /** * Revoke the token on the request. */ const HandleLogout = async (req, res, next) => { const {jwt} = req; const now = new Date(); const expiry = (jwt.exp - now.getTime() / 1000).toFixed(0); try { await client().set(`jtir[${jwt.jti}]`, now.toISOString(), 'EX', expiry); } catch (err) { return next(err); } // Only clear the cookie on logout if enabled. if (JWT_CLEAR_COOKIE_LOGOUT) { debug('clearing the login cookie'); res.clearCookie(JWT_SIGNING_COOKIE_NAME); } res.status(204).end(); }; const checkGeneralTokenBlacklist = (jwt) => client().get(`jtir[${jwt.jti}]`) .then((expiry) => { if (expiry != null) { throw new errors.ErrAuthentication('token was revoked'); } }); /** * Check if the given token is already blacklisted, throw an error if it is. */ const CheckBlacklisted = async (jwt) => { // Check to see if this is a PAT. if (jwt.pat) { return TokensService.validate(get(jwt, JWT_USER_ID_CLAIM), jwt.jti); } // It wasn't a PAT! Check to see if it is valid anyways. await checkGeneralTokenBlacklist(jwt); return null; }; const JwtStrategy = require('passport-jwt').Strategy; const ExtractJwt = require('passport-jwt').ExtractJwt; let cookieExtractor = (req) => { if (req && req.cookies) { // Walk over all the cookie names in JWT_COOKIE_NAMES. for (const cookieName of JWT_COOKIE_NAMES) { // Check to see if that cookie is set. if (cookieName in req.cookies && req.cookies[cookieName] !== null && req.cookies[cookieName].length > 0) { return req.cookies[cookieName]; } } } return null; }; // Override the JwtVerifier method on the JwtStrategy so we can pack the // original token into the payload. JwtStrategy.JwtVerifier = (token, secretOrKey, options, callback) => { return jwt.verify(token, options, (err, jwt) => { if (err) { return callback(err); } // Attach the original token onto the payload. return callback(false, {token, jwt}); }); }; // Extract the JWT from the 'Authorization' header with the 'Bearer' scheme. passport.use(new JwtStrategy({ // Prepare the extractor from the header. jwtFromRequest: ExtractJwt.fromExtractors([ cookieExtractor, ExtractJwt.fromUrlQueryParameter('access_token'), ExtractJwt.fromAuthHeaderWithScheme('Bearer') ]), // Use the secret passed in which is loaded from the environment. This can be // a certificate (loaded) or a HMAC key. secretOrKey: jwt, // Verify the issuer. issuer: JWT_ISSUER, // Verify the audience. audience: JWT_AUDIENCE, // Enable only the HS256 algorithm. algorithms: [JWT_ALG], // Pass the request object back to the callback so we can attach the JWT to // it. passReqToCallback: true }, async (req, {token, jwt}, done) => { // Load the user from the environment, because we just got a user from the // header. try { // Check to see if the token has been revoked let user = await CheckBlacklisted(jwt); if (user === null) { // Try to get the user from the database or crack it from the token and // plugin integrations. user = await UsersService.findOrCreateByIDToken(get(jwt, JWT_USER_ID_CLAIM), {token, jwt}); } // Attach the JWT to the request. req.jwt = jwt; return done(null, user); } catch(e) { return done(e); } })); //============================================================================== // LOCAL STRATEGY //============================================================================== /** * This looks at the request headers to see if there is a recaptcha response on * the input request. */ const CheckIfRecaptcha = (req) => { let response = req.get('X-Recaptcha-Response'); if (response && response.length > 0) { return true; } return false; }; /** * This checks the user to see if the current email profile needs to get checked * for recaptcha compliance before being allowed to login. */ const CheckIfNeedsRecaptcha = (user, email) => { // Get the profile representing the local account. let profile = user.profiles.find((profile) => profile.id === email); // This should never get to this point, if it does, don't let this past. if (!profile) { throw new Error('ID indicated by loginProfile is not on user object'); } if (profile.metadata && profile.metadata.recaptcha_required) { return true; } return false; }; /** * This sends the request details down Google to check to see if the response is * genuine or not. * @return {Promise} resolves with the success status of the recaptcha */ const CheckRecaptcha = async (req) => { // Ask Google to verify the recaptcha response: https://developers.google.com/recaptcha/docs/verify const form = new FormData(); form.append('secret', RECAPTCHA_SECRET); form.append('response', req.get('X-Recaptcha-Response')); form.append('remoteip', req.ip); // Perform the request. let res = await fetch('https://www.google.com/recaptcha/api/siteverify', { method: 'POST', body: form, headers: form.getHeaders() }); // Parse the JSON response. let json = await res.json(); return json.success; }; /** * This records a login attempt failure as well as optionally flags an account * for requiring a recaptcha in the future outside the temporary window. * @return {Promise} resolves with nothing if rate limit not exeeded, errors if * there is a rate limit error */ const HandleFailedAttempt = async (email, userNeedsRecaptcha) => { try { await UsersService.recordLoginAttempt(email); } catch (err) { if (err === errors.ErrLoginAttemptMaximumExceeded && !userNeedsRecaptcha && RECAPTCHA_ENABLED) { debug(`flagging user email=${email}`); await UsersService.flagForRecaptchaRequirement(email, true); } throw err; } }; passport.use(new LocalStrategy({ usernameField: 'email', passwordField: 'password', passReqToCallback: true }, async (req, email, password, done) => { // Normalize email email = email.toLowerCase(); // We need to check if this request has a recaptcha on it at all, if it does, // we must verify it first. If verification fails, we fail the request early. // We can only do this obviously when recaptcha is enabled. let hasRecaptcha = CheckIfRecaptcha(req); let recaptchaPassed = false; if (RECAPTCHA_ENABLED && hasRecaptcha) { try { // Check to see if this recaptcha passed. recaptchaPassed = await CheckRecaptcha(req); } catch (err) { return done(err); } if (!recaptchaPassed) { try { await HandleFailedAttempt(email); } catch (err) { return done(err); } return done(null, false, {message: 'Incorrect recaptcha'}); } } debug(`hasRecaptcha=${hasRecaptcha}, recaptchaPassed=${recaptchaPassed}`); // If the request didn't have a recaptcha, check to see if we did need one by // checking the rate limit against failed attempts on this email // address/login. if (!hasRecaptcha) { try { await UsersService.checkLoginAttempts(email); } catch (err) { if (err === errors.ErrLoginAttemptMaximumExceeded) { // This says, we didn't have a recaptcha, yet we needed one.. Reject // here. try { await HandleFailedAttempt(email); } catch (err) { return done(err); } return done(null, false, {message: 'Incorrect recaptcha'}); } // Some other unexpected error occured. return done(err); } } // Let's find the user for which this login is connected to. let user; try { user = await UsersService.findLocalUser(email); } catch (err) { return done(err); } debug(`user=${user != null}`); // If the user doesn't exist, then mark this as a failed attempt at logging in // this non-existant user and continue. if (!user) { try { await HandleFailedAttempt(email); } catch (err) { return done(err); } return done(null, false, {message: 'Incorrect email/password combination'}); } // Let's check if the user indeed needed recaptcha in order to authenticate. // We can only do this obviously when recaptcha is enabled. let userNeedsRecaptcha = false; if (RECAPTCHA_ENABLED && user) { userNeedsRecaptcha = CheckIfNeedsRecaptcha(user, email); } debug(`userNeedsRecaptcha=${userNeedsRecaptcha}`); // Let's check now if their password is correct. let userPasswordCorrect; try { userPasswordCorrect = await user.verifyPassword(password); } catch (err) { return done(err); } debug(`userPasswordCorrect=${userPasswordCorrect}`); // If their password wasn't correct, mark their attempt as failed and // continue. if (!userPasswordCorrect) { try { await HandleFailedAttempt(email, userNeedsRecaptcha); } catch (err) { return done(err); } return done(null, false, {message: 'Incorrect email/password combination'}); } // If the user needed a recaptcha, yet we have gotten this far, this indicates // that the password was correct, so let's unflag their account for logins. We // can only do this obviously when recaptcha is enabled. The account wouldn't // have been flagged otherwise. if (RECAPTCHA_ENABLED && userNeedsRecaptcha) { try { await UsersService.flagForRecaptchaRequirement(email, false); } catch (err) { return done(err); } } // Define the loginProfile being used to perform an additional // verificaiton. let loginProfile = {id: email, provider: 'local'}; // Perform final steps to login the user. return ValidateUserLogin(loginProfile, user, done); })); module.exports = { passport, ValidateUserLogin, HandleFailedAttempt, HandleAuthPopupCallback, HandleGenerateCredentials, HandleLogout, CheckBlacklisted, CheckRecaptcha, };