Expand activity tracking and add PasswordResetAlert job

This commit is contained in:
garrettmills 2020-07-13 09:35:11 -05:00
parent d29e6f057a
commit 143fccf179
No known key found for this signature in database
GPG Key ID: 6ACD58D6ADACFC6E
7 changed files with 136 additions and 15 deletions

View File

@ -3,3 +3,4 @@
- show app documentation somewhere besides final page of setup
- Logins as jobs?
- OpenID Connect - oidc-provider
- system banner messages UI

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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()
}

View File

@ -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: {
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