From 143fccf17907d2ccf39dcac5772702b284638b5a Mon Sep 17 00:00:00 2001 From: garrettmills Date: Mon, 13 Jul 2020 09:35:11 -0500 Subject: [PATCH] Expand activity tracking and add PasswordResetAlert job --- TODO.text => TODO.txt | 3 +- app/controllers/api/v1/Auth.controller.js | 5 ++ app/controllers/api/v1/Password.controller.js | 4 +- app/controllers/api/v1/Reflect.controller.js | 3 +- app/jobs/PasswordResetAlert.job.js | 36 +++++++++ .../middleware/api/Permission.middleware.js | 20 ++++- app/services/activity.service.js | 80 ++++++++++++++++--- 7 files changed, 136 insertions(+), 15 deletions(-) rename TODO.text => TODO.txt (70%) create mode 100644 app/jobs/PasswordResetAlert.job.js diff --git a/TODO.text b/TODO.txt similarity index 70% rename from TODO.text rename to TODO.txt index e33cb7b..14e05da 100644 --- a/TODO.text +++ b/TODO.txt @@ -2,4 +2,5 @@ - Localize all the things - show app documentation somewhere besides final page of setup - Logins as jobs? -- OpenID Connect - oidc-provider \ No newline at end of file +- OpenID Connect - oidc-provider +- system banner messages UI \ No newline at end of file diff --git a/app/controllers/api/v1/Auth.controller.js b/app/controllers/api/v1/Auth.controller.js index 2094c80..23edb8b 100644 --- a/app/controllers/api/v1/Auth.controller.js +++ b/app/controllers/api/v1/Auth.controller.js @@ -638,6 +638,7 @@ class AuthController extends Controller { const token = req.user.mfa_token const codes = await token.generate_recovery() await req.user.save() + await this.activity.mfa_recovery_created({ req }) return res.api({ codes, }) @@ -704,6 +705,8 @@ class AuthController extends Controller { req.user.mfa_enable_date = new Date req.user.save() + await this.activity.mfa_enable({ req }) + // invalidate existing tokens and other logins const flitter = await this.auth.get_provider('flitter') await flitter.logout(req) @@ -724,6 +727,8 @@ class AuthController extends Controller { req.user.app_passwords = [] await req.user.save() + await this.activity.mfa_disable({ req }) + // invalidate existing login tokens and logins const flitter = await this.auth.get_provider('flitter') await flitter.logout(req) diff --git a/app/controllers/api/v1/Password.controller.js b/app/controllers/api/v1/Password.controller.js index 4800844..ba3c29c 100644 --- a/app/controllers/api/v1/Password.controller.js +++ b/app/controllers/api/v1/Password.controller.js @@ -3,7 +3,7 @@ const zxcvbn = require('zxcvbn') class PasswordController extends Controller { static get services() { - return [...super.services, 'auth', 'jobs', 'models'] + return [...super.services, 'auth', 'jobs', 'models', 'activity'] } async get_resets(req, res, next) { @@ -35,6 +35,7 @@ class PasswordController extends Controller { const { password, record } = await req.user.app_password(req.body.name) await req.user.save() + await this.activity.app_password_created({ req, name: req.body.name }) return res.api({ password, @@ -86,6 +87,7 @@ class PasswordController extends Controller { // Create the password reset const reset = await req.user.reset_password(req.body.password) await req.user.save() + await this.activity.password_reset({ req, ip: req.ip }) if ( req.trap.has_trap() && req.trap.get_trap() === 'password_reset' ) await req.trap.end() // invalidate existing tokens and other logins diff --git a/app/controllers/api/v1/Reflect.controller.js b/app/controllers/api/v1/Reflect.controller.js index 89eb789..bc44e6d 100644 --- a/app/controllers/api/v1/Reflect.controller.js +++ b/app/controllers/api/v1/Reflect.controller.js @@ -3,7 +3,7 @@ const uuid = require('uuid/v4') class ReflectController extends Controller { static get services() { - return [...super.services, 'routers', 'models'] + return [...super.services, 'routers', 'models', 'activity'] } async get_tokens(req, res, next) { @@ -81,6 +81,7 @@ class ReflectController extends Controller { }) await token.save() + await this.activity.api_token_created({ req, oauth_client_id: client.uuid }) return res.api({ id: token.id, token: token.accessToken, diff --git a/app/jobs/PasswordResetAlert.job.js b/app/jobs/PasswordResetAlert.job.js new file mode 100644 index 0000000..3f3de82 --- /dev/null +++ b/app/jobs/PasswordResetAlert.job.js @@ -0,0 +1,36 @@ +const { Job } = require('flitter-jobs') + +class PasswordResetAlertJob extends Job { + static get services() { + return [...super.services, 'models', 'jobs', 'output', 'configs'] + } + + async execute(job) { + const { data } = job + const { user_id, ip } = data + + try { + const User = this.models.get('auth:User') + const user = await User.findById(user_id) + if ( !user ) throw new Error('Unable to find user with ID: '+user_id) + + this.output.info('Sending password reset alert to user.') + + await this.jobs.queue('mailer').add('EMail', { + to: user.email, + subject: `Security Alert | ${this.configs.get('app.name')}`, + email_params: { + header_text: 'Your Password Was Reset', + body_paragraphs: [ + `The password to your ${this.configs.get('app.name')} account (${user.uid}) was recently reset from the IP ${ip}.`, + 'If this was you, please disregard this email. Otherwise, please contact your administrator for assistance recovering your account.', + ], + }, + }) + } catch (e) { + this.output.error(e) + } + } +} + +module.exports = exports = PasswordResetAlertJob diff --git a/app/routing/middleware/api/Permission.middleware.js b/app/routing/middleware/api/Permission.middleware.js index bc0f028..1deb971 100644 --- a/app/routing/middleware/api/Permission.middleware.js +++ b/app/routing/middleware/api/Permission.middleware.js @@ -2,7 +2,7 @@ const { Middleware } = require('libflitter') class PermissionMiddleware extends Middleware { static get services() { - return [...super.services, 'models'] + return [...super.services, 'models', 'activity'] } async test(req, res, next, { check }) { @@ -11,20 +11,34 @@ class PermissionMiddleware extends Middleware { // If the request was authorized using an OAuth2 bearer token, // make sure the associated client has permission to access this endpoint. if ( req?.oauth?.client ) { - if ( !req.oauth.client.can(check) ) + if ( !req.oauth.client.can(check) ) { + const reason = 'oauth-permission-fail' + await this.activity.api_access_denial({ + req, + reason, + check, + oauth_client_id: req.oauth.client.id, + }) + return res.status(401) .message('Insufficient permissions (OAuth2 Client).') .api() + } } const policy_denied = await Policy.check_user_denied(req.user, check) const policy_access = await Policy.check_user_access(req.user, check) // Make sure the user has permission - if ( policy_denied || (!req.user.can(check) && !policy_access) ) + if ( policy_denied || (!req.user.can(check) && !policy_access) ) { + // Record the failed API access + const reason = policy_denied ? 'iam-denial' : (!req.user.can(check) ? 'user-permission-fail' : 'iam-not-granted') + await this.activity.api_access_denial({ req, reason, check }) + return res.status(401) .message('Insufficient permissions.') .api() + } return next() } diff --git a/app/services/activity.service.js b/app/services/activity.service.js index 7ec66ed..4f59b19 100644 --- a/app/services/activity.service.js +++ b/app/services/activity.service.js @@ -8,15 +8,11 @@ class ActivityService extends Service { } async login(req) { - const Activity = this.model() - const activity = new Activity({ - user_id: req.session.auth.user_id, - session_id: req.session.id, - action: 'login', - metadata: { - ip: req.ip - } - }) + const activity = this.from_req(req) + activity.action = 'login' + activity.metadata = { + ip: req.ip + } // If this is a new IP login, send an e-mail alert const foreign_ip = await this.foreign_login_ip(req.session.auth.user_id, req.ip) @@ -30,6 +26,64 @@ class ActivityService extends Service { await activity.save() } + async api_access_denial({ req, reason, check, oauth_client_id = null }) { + const activity = this.from_req(req) + activity.action = 'api-access-denial' + activity.metadata = { + scope: check, + reason, + oauth_client_id, + } + + await activity.save() + } + + async mfa_enable({ req }) { + const activity = this.from_req(req) + activity.action = 'mfa-enable' + await activity.save() + } + + async mfa_disable({ req }) { + const activity = this.from_req(req) + activity.action = 'mfa-disable' + await activity.save() + } + + async mfa_recovery_created({ req }) { + const activity = this.from_req(req) + activity.action = 'mfa-recovery-created' + await activity.save() + } + + async app_password_created({ req, name }) { + const activity = this.from_req(req) + activity.action = 'app-password-created' + activity.metadata = { name } + await activity.save() + } + + async password_reset({ req, ip }) { + const activity = this.from_req(req) + activity.action = 'password-reset' + activity.metadata = { ip } + await activity.save() + + // Send an alert to the user + await this.jobs.queue('notifications').add('PasswordResetAlert', { + ip, user_id: req.session.auth.user_id, + }) + } + + async api_token_created({ req, oauth_client_id }) { + const activity = this.from_req(req) + activity.action = 'api-token-created' + activity.metadata = { + ip: req.ip, + oauth_client_id, + } + } + async foreign_login_ip(user_id, ip) { const Activity = this.model() const existing_ip = await Activity.findOne({ @@ -40,6 +94,14 @@ class ActivityService extends Service { return !existing_ip } + + from_req(req) { + const Activity = this.model() + return new Activity({ + user_id: req.session.auth.user_id, + session_id: req.session.id, + }) + } } module.exports = exports = ActivityService