UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

756 lines (728 loc) • 31.6 kB
/** * Routes related to session handling, login/out etc * * Handles adding `request.sid` and `request.session` to each request if there * is a valid session. * * - `/account/login` * - `/account/logout` * * Provides preHandler functions to secure routes * * - `forge.verifySession` * - `forge.verifyAdmin` * * @namespace session * @memberof forge.routes */ const fp = require('fastify-plugin') const { completeUserSignup } = require('../../lib/userTeam') // This defines how long the session cookie is valid for. This should match // the max session age defined in `forge/db/controllers/Session.DEFAULT_WEB_SESSION_EXPIRY // albeit in secs not millisecs due to cookie maxAge requirements // this can be overridden by `sessions.maxDuration` in config yml file const SESSION_MAX_AGE = 60 * 60 * 24 * 7 // 1 week in seconds // Options to apply to our session cookie const SESSION_COOKIE_OPTIONS = { httpOnly: true, path: '/', signed: true, maxAge: SESSION_MAX_AGE } // create jsdoc typedef for UserController /** * @typedef {import('../../db/controllers/User')} UserController */ module.exports = fp(init, { name: 'app.routes.auth' }) /** * Initialize the auth plugin * @param {import('forge/forge').ForgeApplication} app * @param {Object} opts * @param {Function} done */ async function init (app, opts) { await app.register(require('./oauth'), { logLevel: app.config.logging.http }) await app.register(require('./permissions')) /** * preHandler function that ensures the current request comes from an active * session or token * * * It sets `request.session` to the active session object. * @name verifySession * @static * @memberof forge */ async function verifySession (request, reply) { if (request.sid) { request.session = await app.db.controllers.Session.getOrExpire(request.sid) if (request.session && request.session.User) { const emailVerified = !app.postoffice.enabled() || request.session.User.email_verified || request.routeOptions.config.allowUnverifiedEmail const passwordNotExpired = !request.session.User.password_expired || request.routeOptions.config.allowExpiredPassword const suspended = request.session.User.suspended // If the user has mfa_enabled, but the session isn't marked as mfa_verified then // the user has not completed logging in so the session isn't valid const mfaMissing = request.session.User.mfa_enabled && !request.session.mfa_verified if (emailVerified && passwordNotExpired && !suspended && !mfaMissing) { return } if (request.routeOptions.config.allowAnonymous) { return } await request.session.destroy() reply.clearCookie('sid') } } else if (request.headers && request.headers.authorization) { const parts = request.headers.authorization.split(' ') if (parts.length === 2) { const scheme = parts[0] const token = parts[1] if (scheme !== 'Bearer') { reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) return } const accessToken = await app.db.controllers.AccessToken.getOrExpire(token) if (accessToken) { request.session = { ownerId: accessToken.ownerId, ownerType: accessToken.ownerType, scope: accessToken.scope } if (accessToken.ownerType === 'team' && request.session.scope?.includes('device:provision')) { request.session.provisioning = await app.db.views.AccessToken.provisioningTokenSummary(accessToken) } if (accessToken.ownerType === 'device' && request.session.scope?.includes('device:otc')) { request.session.provisioning = { otcSetup: true, deviceId: +accessToken.ownerId // convert to number } // delete one time code immediately (spent) await accessToken.destroy() } if (accessToken.ownerType === 'user') { request.session.User = await app.db.models.User.findOne({ where: { id: parseInt(accessToken.ownerId) } }) // Unlike a cookie based session, we'll allow user tokens to continue // working if password has expired or email isn't verified // TODO: validate this choice if (request.session.User.suspended) { reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) return } if (accessToken.name) { // Temp hack to give token full user scope delete request.session.scope } } if (accessToken.ownerType === 'broker') { request.session.Broker = await app.db.models.BrokerCredentials.findOne({ where: { id: parseInt(accessToken.ownerId) }, include: [{ model: app.db.models.Team }] }) if (!request.session.Broker) { reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) return } } return } reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) return } else { reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) return } } if (request.routeOptions.config.allowAnonymous) { return } reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) } app.decorate('verifySession', verifySession) /** * preHandler function that ensures the current request comes from * an admin user. * * @name verifyAdmin * @static * @memberof forge */ app.decorate('verifyAdmin', async (request, reply) => { if (request.session?.User?.admin) { return } reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) return new Error() }) app.decorateRequest('session', null) app.decorateRequest('sid', null) // Extract the session cookie and attach as request.sid app.addHook('onRequest', async (request, reply) => { if (request.cookies.sid) { const sid = reply.unsignCookie(request.cookies.sid) if (sid.valid) { request.sid = sid.value } else { reply.clearCookie('sid') } } }) app.decorate('createSessionCookie', async function (username) { const session = await app.db.controllers.Session.createUserSession(username) if (session) { const cookieOptions = { ...SESSION_COOKIE_OPTIONS } cookieOptions.maxAge = app.config.sessions?.maxDuration || SESSION_MAX_AGE if (/^https:/.test(app.config.base_url)) { // If base_url starts https then we can safely set the secure flag // on the cookie cookieOptions.secure = true } return { session, cookieOptions } } else { return null } }) /** * Login a user. * * Requires a body containing: * * ```json * { * "username": "username", * "password": "password" * } * ``` * @name /account/login * @static * @memberof forge.routes.session */ app.post('/account/login', { config: { rateLimit: app.config.rate_limits ? { max: 5, timeWindow: 30000, keyGenerator: app.config.rate_limits.keyGenerator, hard: true } : false }, schema: { summary: 'Log in to the platform', description: 'Log in to the platform. If SSO is enabled for this user, the response will prompt the user to retry via the SSO login mechanism.', tags: ['Authentication', 'X-HIDDEN'], body: { type: 'object', required: ['username'], properties: { username: { type: 'string' }, password: { type: 'string' } } } }, logLevel: app.config.logging.http }, async (request, reply) => { if (app.config.features.enabled('sso')) { if (await app.sso.handleLoginRequest(request, reply)) { // The request has been handled at the SSO layer. Do nothing else return } } if (!request.body.password) { reply.code(401).send({ code: 'password_required', error: 'Password required' }) } else { const userInfo = app.auditLog.formatters.userObject(request.body) const result = await app.db.controllers.User.authenticateCredentials(request.body.username, request.body.password) if (result) { const sessionInfo = await app.createSessionCookie(request.body.username) if (sessionInfo) { userInfo.id = sessionInfo.session.UserId // TODO: add more info to userInfo for user logging in // userInfo.email = session.User?.email // userInfo.name = session.User?.name reply.setCookie('sid', sessionInfo.session.sid, sessionInfo.cookieOptions) if (sessionInfo.session.User.mfa_enabled && !sessionInfo.mfa_verified) { reply.code(403).send({ code: 'mfa_required', error: 'MFA required' }) return } await app.auditLog.User.account.login(userInfo, null) reply.send() return } else { const resp = { code: 'user_suspended', error: 'User Suspended' } await app.auditLog.User.account.login(userInfo, resp, userInfo) reply.code(403).send(resp) return } } const resp = { code: 'unauthorized', error: 'unauthorized' } await app.auditLog.User.account.login(userInfo, resp, userInfo) reply.code(401).send(resp) } }) app.post('/account/login/token', { config: { rateLimit: app.config.rate_limits // rate limit this route regardless of global/per-route mode (if enabled) }, schema: { summary: 'Verify a users MFA token', tags: ['Authentication', 'X-HIDDEN'], body: { type: 'object', required: ['token'], properties: { token: { type: 'string' } } } }, logLevel: app.config.logging.http }, async (request, reply) => { // We expect there to be a session at this point - but without a verified mfa token if (request.sid) { request.session = await app.db.controllers.Session.getOrExpire(request.sid) if (request.session) { if (await app.db.controllers.User.verifyMFAToken(request.session.User, request.body.token)) { request.session.mfa_verified = true await request.session.save() reply.send() return } await request.session.destroy() } reply.clearCookie('sid') } reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) }) /** * @name /account/logout * @static * @memberof forge.routes.session */ app.post('/account/logout', { config: { rateLimit: false // never rate limit this route }, schema: { tags: ['Authentication', 'X-HIDDEN'] } }, async (request, reply) => { let userInfo = null if (request.sid) { // logout:nodered(step-1) const thisSession = await app.db.models.Session.findOne({ where: { sid: request.sid }, include: app.db.models.User }) userInfo = app.auditLog.formatters.userObject(thisSession?.User) userInfo.id = thisSession?.UserId if (userInfo.id != null) { const user = await app.db.models.User.byId(userInfo.id) userInfo = app.auditLog.formatters.userObject(user) await app.db.controllers.User.logout(user) } await app.db.controllers.Session.deleteSession(request.sid) } reply.clearCookie('sid') if (userInfo?.id || userInfo?.name || userInfo?.username) { await app.auditLog.User.account.logout(userInfo) } reply.send({ status: 'okay' }) }) /** * @name /account/register * @static * @memberof forge.routes.session */ app.post('/account/register', { config: { rateLimit: app.config.rate_limits ? { max: 5, timeWindow: 30000, keyGenerator: app.config.rate_limits.keyGenerator, hard: true } : false }, schema: { tags: ['Authentication', 'X-HIDDEN'], body: { type: 'object', required: ['username', 'password', 'name', 'email'], properties: { username: { type: 'string' }, password: { type: 'string' }, name: { type: 'string' }, email: { type: 'string' }, code: { type: 'string' } } } }, logLevel: app.config.logging.http }, async (request, reply) => { const userInfo = app.auditLog.formatters.userObject(request.body) if (!app.settings.get('user:signup') && !app.settings.get('team:user:invite:external')) { const resp = { code: 'user_registration_unavailable', error: 'user registration not enabled' } await app.auditLog.User.account.register(userInfo, resp, userInfo) reply.code(400).send(resp) return } if (!app.settings.get('user:signup') && app.settings.get('team:user:invite:external')) { // External invites are allowed - so check there is an invite for this email const invite = await app.db.models.Invitation.forExternalEmail(request.body.email) if (!invite || invite.length === 0) { // reusing error message so as not to leak invited users const resp = { code: 'user_registration_unavailable', error: 'user registration not enabled' } await app.auditLog.User.account.register(userInfo, resp, userInfo) reply.code(400).send(resp) return } else { app.log.info(`Invited user found ${request.body.email}`) } } if (!app.postoffice.enabled()) { const resp = { code: 'user_registration_unavailable', error: 'user registration not enabled - email not configured' } await app.auditLog.User.account.register(userInfo, resp, userInfo) reply.code(400).send(resp) return } if (/^(admin|root)$/.test(request.body.username) || !/^[a-z0-9-_]+$/i.test(request.body.username)) { const resp = { code: 'invalid_username', error: 'invalid username' } await app.auditLog.User.account.register(userInfo, resp, userInfo) reply.code(400).send(resp) return } if (app.settings.get('user:tcs-required') && !request.body.tcs_accepted) { const resp = { code: 'tcs_missing', error: 'terms and conditions not accepted' } await app.auditLog.User.account.register(userInfo, resp, userInfo) reply.code(400).send(resp) return } const userProperties = { username: request.body.username, name: request.body.name, email: request.body.email, email_verified: false, password: request.body.password, admin: false, tcs_accepted: new Date() } let requireEmailVerification = true if (app.config.features.enabled('sso') && request.body.email) { if (await app.sso.isSSOEnabledForEmail(request.body.email)) { // This user is signing up with an SSO enabled email domain // 1. validate they are not trying to use a plus-address (name+extra@domain) if (/^.*\+.*@[^@]+$/.test(request.body.email)) { const resp = { code: 'invalid_sso_email', error: 'SSO is enabled for this email domain. You must register using the email that matches exactly what your SSO provider identifies you as.' } await app.auditLog.User.account.register(userInfo, resp, userInfo) reply.code(400).send(resp) return } requireEmailVerification = false userProperties.sso_enabled = true } } try { const newUser = await app.db.models.User.create(userProperties) userInfo.id = newUser.id if (requireEmailVerification) { const verificationToken = await app.db.controllers.User.generateEmailVerificationToken(newUser) await app.postoffice.send( newUser, 'VerifyEmail', { token: verificationToken } ) } if (app.billing && request.body.code) { await app.billing.setUserBillingCode(newUser, request.body.code) } await app.auditLog.User.account.register(userInfo, null, userInfo) if (userProperties.sso_enabled) { const pendingInvitations = await app.db.models.Invitation.forExternalEmail(newUser.email) for (let i = 0; i < pendingInvitations.length; i++) { const invite = pendingInvitations[i] // For now we'll auto-accept any invites for this user // See https://github.com/FlowFuse/flowfuse/issues/275#issuecomment-1040113991 await app.db.controllers.Invitation.acceptInvitation(invite, newUser) // // If we go back to having the user be able to accept invites // // as a secondary step, the following code will convert the external // // invite into an internal one. // invite.external = false // invite.inviteeId = verifiedUser.id // await invite.save() } } else { // Log them in const sessionInfo = await app.createSessionCookie(newUser.username) if (sessionInfo) { reply.setCookie('sid', sessionInfo.session.sid, sessionInfo.cookieOptions) } } reply.send(await app.db.views.User.userProfile(newUser)) } catch (err) { let responseMessage let responseCode = 'unexpected_error' if (/user_username_lower_unique|Users_username_key/.test(err.parent?.toString())) { responseMessage = 'Username or email not available' responseCode = 'invalid_request' } else if (/user_email_lower_unique|Users_email_key/.test(err.parent?.toString())) { responseMessage = 'Username or email not available' responseCode = 'invalid_request' } else if (err.errors) { responseMessage = err.errors.map(err => err.message).join(',') } else { responseMessage = err.toString() } const resp = { code: responseCode, error: responseMessage } await app.auditLog.User.account.register(userInfo, resp, userInfo) reply.code(400).send(resp) } }) /** * Perform email verification */ app.post('/account/verify/token', { config: { rateLimit: app.config.rate_limits ? { max: 2, timeWindow: 60000, keyGenerator: app.config.rate_limits.keyGenerator, hard: true } : false }, schema: { tags: ['Authentication', 'X-HIDDEN'] } }, async (request, reply) => { let sessionUser if (request.sid) { request.session = await app.db.controllers.Session.getOrExpire(request.sid) sessionUser = request.session?.User } let verifiedUser try { verifiedUser = await app.db.controllers.User.verifyEmailToken(sessionUser, request.body.token) await app.auditLog.User.account.verify.verifyToken(verifiedUser, null) } catch (err) { const resp = { code: 'invalid_request', error: err.toString() } await app.auditLog.User.account.verify.verifyToken(request.session?.User, resp) reply.code(400).send(resp) return } try { await completeUserSignup(app, verifiedUser) } catch (err) { // At this point, the user is verified. So we need to respond with success. // However, an error was hit whilst completing their post-signup tasks. await app.auditLog.User.account.verify.verifyToken(sessionUser, { error: err.toString() }) app.log.error(`/account/verify/token error - ${err.stack}`) } reply.send({ status: 'okay' }) }) /** * Generate verification email */ app.post('/account/verify', { preHandler: function (request, reply, done) { // eslint-disable-next-line promise/no-callback-in-promise app.verifySession(request, reply).then(() => done()).catch(done) }, config: { rateLimit: app.config.rate_limits ? { max: 2, timeWindow: 60000, keyGenerator: app.config.rate_limits.keyGenerator, hard: true } : false, allowUnverifiedEmail: true }, schema: { tags: ['Authentication', 'X-HIDDEN'] } }, async (request, reply) => { /** @type {UserController} */ const userController = app.db.controllers.User if (!app.postoffice.enabled()) { const resp = { code: 'invalid_request', error: 'email not configured' } await app.auditLog.User.account.verify.requestToken(request.session?.User, resp) reply.code(400).send(resp) return } if (!request.session.User.email_verified) { const verificationToken = await userController.generateEmailVerificationToken(request.session.User) await app.postoffice.send( request.session.User, 'VerifyEmail', { token: verificationToken } ) await app.auditLog.User.account.verify.requestToken(request.session.User, null) reply.send({ status: 'okay' }) } else { const resp = { code: 'invalid_request', error: 'email already verified' } await app.auditLog.User.account.verify.requestToken(request.session?.User, resp) reply.code(400).send(resp) } }) /** * Perform pending email change */ app.post('/account/email_change/:token', { schema: { tags: ['Authentication', 'X-HIDDEN'] } }, async (request, reply) => { try { /** @type {UserController} */ const userController = app.db.controllers.User let sessionUser if (request.sid) { request.session = await app.db.controllers.Session.getOrExpire(request.sid) sessionUser = request.session?.User } let verifiedUser try { const originalEmail = sessionUser.email // update the users email address verifiedUser = await userController.applyPendingEmailChange(sessionUser, request.params.token) // send the email changed confirmation email const recipient = { name: verifiedUser.name, email: originalEmail, id: verifiedUser.id, hashid: verifiedUser.hashid } await userController.sendEmailChangedEmail(recipient, originalEmail, verifiedUser.email) } catch (err) { const resp = { code: 'invalid_request', error: err.toString() } await app.auditLog.User.account.changeEmailConfirmed(request.session?.User, resp) reply.code(400).send(resp) return } await app.auditLog.User.account.changeEmailConfirmed(request.session?.User || verifiedUser, null) reply.send({ status: 'okay' }) } catch (err) { app.log.error(`/account/verify/token error - ${err.toString()}`) const resp = { code: 'unexpected_error', error: err.toString() } await app.auditLog.User.account.changeEmailConfirmed(request.session?.User, resp) reply.code(400).send(resp) } }) app.post('/account/forgot_password', { config: { rateLimit: app.config.rate_limits ? { max: 2, timeWindow: 60000, keyGenerator: app.config.rate_limits.keyGenerator, hard: true } : false }, schema: { tags: ['Authentication', 'X-HIDDEN'], body: { type: 'object', required: ['email'], properties: { email: { type: 'string' } } } }, logLevel: app.config.logging.http }, async (request, reply) => { const userInfo = app.auditLog.formatters.userObject(request.session?.User || request.body) if (!app.settings.get('user:reset-password')) { const resp = { code: 'password_reset_unavailable', error: 'password reset not enabled' } await app.auditLog.User.account.forgotPassword(userInfo, resp, userInfo) reply.code(400).send(resp) return } const user = await app.db.models.User.byEmail(request.body.email) if (user) { if (app.postoffice.enabled()) { const token = await app.db.controllers.AccessToken.createTokenForPasswordReset(user) app.postoffice.send( user, 'PasswordReset', { resetLink: `${app.config.base_url}/account/change-password/${token.token}` } ) const info = `Password reset request for ${user.hashid}` app.log.info(info) await app.auditLog.User.account.forgotPassword(userInfo, null, userInfo) } else { const resp = { code: 'password_reset_unavailable', error: 'Email not enabled - cannot reset password' } await app.auditLog.User.account.forgotPassword(userInfo, resp, userInfo) reply.code(400).send({ status: 'error', message: resp.error, ...resp }) return } } reply.code(200).send({}) }) app.post('/account/reset_password/:token', { config: { rateLimit: app.config.rate_limits // rate limit this route regardless of global/per-route mode (if enabled) }, schema: { tags: ['Authentication', 'X-HIDDEN'], body: { type: 'object', required: ['password'], properties: { password: { type: 'string' } } } }, logLevel: app.config.logging.http }, async (request, reply) => { let userInfo = app.auditLog.formatters.userObject(request.session?.User) if (!app.settings.get('user:reset-password')) { const resp = { code: 'password_reset_unavailable', error: 'password reset not enabled' } await app.auditLog.User.account.resetPassword(userInfo, resp, userInfo) reply.code(400).send(resp) return } // We swallow all errors in this handler and always return 200 // This ensures we don't leak any information about valid/invalid requests const token = await app.db.controllers.AccessToken.getOrExpirePasswordResetToken(request.params.token) let success = false if (token) { userInfo.hashid = token.ownerId // This is a valid password reset token const user = await app.db.models.User.byId(token.ownerId) if (user) { userInfo = user try { await app.db.controllers.User.resetPassword(user, request.body.password) // Clear any existing sessions to force a re-login await app.db.controllers.Session.deleteAllUserSessions(user) await app.db.controllers.AccessToken.deleteAllUserPasswordResetTokens(user) success = true } catch (err) { } } // We've used the one attempt to use this token - remove it await token.destroy() } if (success) { await app.auditLog.User.account.resetPassword(request.session?.User || userInfo, null, userInfo) reply.code(200).send({}) } else { const resp = { code: 'password_reset_failed', error: 'Password reset failed' } await app.auditLog.User.account.resetPassword(request.session?.User || userInfo, resp, userInfo) reply.code(400).send({ status: 'error', message: resp.error, ...resp }) } }) }