diff --git a/TODO.text b/TODO.text index dfa3876..e33cb7b 100644 --- a/TODO.text +++ b/TODO.text @@ -2,5 +2,4 @@ - Localize all the things - show app documentation somewhere besides final page of setup - Logins as jobs? -- OpenID Connect - oidc-provider -- Login from new IP notifications \ No newline at end of file +- OpenID Connect - oidc-provider \ 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 418b955..2094c80 100644 --- a/app/controllers/api/v1/Auth.controller.js +++ b/app/controllers/api/v1/Auth.controller.js @@ -4,7 +4,7 @@ const email_validator = require('email-validator') class AuthController extends Controller { static get services() { - return [...super.services, 'models', 'auth', 'MFA', 'output', 'configs', 'utility'] + return [...super.services, 'models', 'auth', 'MFA', 'output', 'configs', 'utility', 'activity'] } async get_auth_user(req, res, next) { @@ -168,7 +168,6 @@ class AuthController extends Controller { return res.api(data) } - async get_roles(req, res, next) { const role_config = this.configs.get('auth.roles') const data = [] @@ -603,6 +602,9 @@ class AuthController extends Controller { } } + // Create a login tracking activity + await this.activity.login(req) + return res.api({ success: true, session_created: !!req.body.create_session, diff --git a/app/jobs/ForeignIPLoginAlert.job.js b/app/jobs/ForeignIPLoginAlert.job.js new file mode 100644 index 0000000..3e7f7a9 --- /dev/null +++ b/app/jobs/ForeignIPLoginAlert.job.js @@ -0,0 +1,37 @@ +const { Job } = require('flitter-jobs') + +class ForeignIPLoginAlertJob 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 foreign IP login 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: 'Login From New IP', + body_paragraphs: [ + `We've detected a login to your ${this.configs.get('app.name')} account from a new IP address (${ip}).`, + 'If this was you, no further action is required. If this was not you, please log into your account and reset your password.', + 'Also, consider enabling multi-factor authentication to protect your account.', + ], + button_text: 'Account Settings', + button_link: `${this.configs.get('app.url')}dash/profile`, + } + }) + } catch (e) { + this.output.error(e) + } + } +} + +module.exports = exports = ForeignIPLoginAlertJob diff --git a/app/models/Activity.model.js b/app/models/Activity.model.js new file mode 100644 index 0000000..11f542f --- /dev/null +++ b/app/models/Activity.model.js @@ -0,0 +1,15 @@ +const { Model } = require('flitter-orm') + +class ActivityModel extends Model { + static get schema() { + return { + user_id: String, + session_id: String, + action: String, + timestamp: { type: Date, default: () => new Date }, + metadata: Object, + } + } +} + +module.exports = exports = ActivityModel diff --git a/app/services/activity.service.js b/app/services/activity.service.js new file mode 100644 index 0000000..7ec66ed --- /dev/null +++ b/app/services/activity.service.js @@ -0,0 +1,45 @@ +const { Service } = require('flitter-di') + +class ActivityService extends Service { + static get services() { return ['models', 'jobs'] } + + model() { + return this.models.get('Activity') + } + + 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 + } + }) + + // 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) + if ( foreign_ip ) { + await this.jobs.queue('notifications').add('ForeignIPLoginAlert', { + ip: req.ip, + user_id: req.session.auth.user_id, + }) + } + + await activity.save() + } + + async foreign_login_ip(user_id, ip) { + const Activity = this.model() + const existing_ip = await Activity.findOne({ + user_id, + action: 'login', + 'metadata.ip': ip, + }) + + return !existing_ip + } +} + +module.exports = exports = ActivityService diff --git a/app/unit/SettingsUnit.js b/app/unit/SettingsUnit.js index 78ae658..dca618c 100644 --- a/app/unit/SettingsUnit.js +++ b/app/unit/SettingsUnit.js @@ -10,6 +10,8 @@ class SettingsUnit extends Unit { } async go(app) { + app.express.set('trust proxy', true) + const Setting = this.models.get('Setting') const default_settings = this.configs.get('setting.settings') for ( const key in default_settings ) { diff --git a/config/jobs.config.js b/config/jobs.config.js index d9daca1..1a7de77 100644 --- a/config/jobs.config.js +++ b/config/jobs.config.js @@ -14,6 +14,7 @@ const jobs_config = { queues: [ 'mailer', 'password_resets', + 'notifications', ], // Mapping of worker name => worker config @@ -22,7 +23,7 @@ const jobs_config = { // The name of the worker is "main" main: { // This worker will process these queues - queues: ['mailer', 'password_resets'], + queues: ['mailer', 'password_resets', 'notifications'], }, // You can have many workers, and multiple workers can