From 2d6d32923d7d3aff3a54bbdbceee3884eed96ff6 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 21 Feb 2023 18:01:26 +0700 Subject: [PATCH] Finish alert for new device login detection --- backend/src/controllers/v1/authController.ts | 8 +++ backend/src/controllers/v2/authController.ts | 50 +++++++++++-- backend/src/helpers/user.ts | 49 ++++++++++++- backend/src/models/user.ts | 16 +++-- backend/src/routes/v2/users.ts | 2 +- .../templates/emailVerification.handlebars | 2 +- backend/src/templates/newDevice.handlebars | 19 +++++ docs/self-hosting/configuration/envars.mdx | 72 ++++++++++--------- 8 files changed, 170 insertions(+), 48 deletions(-) create mode 100644 backend/src/templates/newDevice.handlebars diff --git a/backend/src/controllers/v1/authController.ts b/backend/src/controllers/v1/authController.ts index 80b450c91..e329b15c7 100644 --- a/backend/src/controllers/v1/authController.ts +++ b/backend/src/controllers/v1/authController.ts @@ -6,6 +6,7 @@ import * as bigintConversion from 'bigint-conversion'; const jsrp = require('jsrp'); import { User, LoginSRPDetail } from '../../models'; import { createToken, issueAuthTokens, clearTokens } from '../../helpers/auth'; +import { checkUserDevice } from '../../helpers/user'; import { ACTION_LOGIN, ACTION_LOGOUT @@ -111,6 +112,13 @@ export const login2 = async (req: Request, res: Response) => { // compare server and client shared keys if (server.checkClientProof(clientProof)) { // issue tokens + + await checkUserDevice({ + user, + ip: req.ip, + userAgent: req.headers['user-agent'] ?? '' + }); + const tokens = await issueAuthTokens({ userId: user._id.toString() }); // store (refresh) token in httpOnly cookie diff --git a/backend/src/controllers/v2/authController.ts b/backend/src/controllers/v2/authController.ts index 95c359dd4..95a1613d2 100644 --- a/backend/src/controllers/v2/authController.ts +++ b/backend/src/controllers/v2/authController.ts @@ -6,17 +6,21 @@ import * as bigintConversion from 'bigint-conversion'; const jsrp = require('jsrp'); import { User, LoginSRPDetail } from '../../models'; import { issueAuthTokens, createToken } from '../../helpers/auth'; +import { checkUserDevice } from '../../helpers/user'; import { sendMail } from '../../helpers/nodemailer'; import { TokenService } from '../../services'; +import { EELogService } from '../../ee/services'; import { NODE_ENV, JWT_MFA_LIFETIME, JWT_MFA_SECRET } from '../../config'; -import { BadRequestError } from '../../utils/errors'; +import { BadRequestError, InternalServerError } from '../../utils/errors'; import { - TOKEN_EMAIL_MFA + TOKEN_EMAIL_MFA, + ACTION_LOGIN } from '../../variables'; +import { getChannelFromUserAgent } from '../../utils/posthog'; // TODO: move this declare module 'jsonwebtoken' { export interface UserIDJwtPayload extends jwt.JwtPayload { @@ -85,6 +89,9 @@ export const login1 = async (req: Request, res: Response) => { */ export const login2 = async (req: Request, res: Response) => { try { + + if (!req.headers['user-agent']) throw InternalServerError({ message: 'User-Agent header is required' }); + const { email, clientProof } = req.body; const user = await User.findOne({ email @@ -143,6 +150,12 @@ export const login2 = async (req: Request, res: Response) => { token }); } + + await checkUserDevice({ + user, + ip: req.ip, + userAgent: req.headers['user-agent'] ?? '' + }); // issue tokens const tokens = await issueAuthTokens({ userId: user._id.toString() }); @@ -155,7 +168,7 @@ export const login2 = async (req: Request, res: Response) => { secure: NODE_ENV === 'production' ? true : false }); - // case: user does not have MFA enabled + // case: user does not have MFA enablgged // return (access) token in response interface ResponseData { @@ -190,6 +203,18 @@ export const login2 = async (req: Request, res: Response) => { response.protectedKeyIV = user.protectedKeyIV response.protectedKeyTag = user.protectedKeyTag; } + + const loginAction = await EELogService.createAction({ + name: ACTION_LOGIN, + userId: user._id + }); + + loginAction && await EELogService.createLog({ + userId: user._id, + actions: [loginAction], + channel: getChannelFromUserAgent(req.headers['user-agent']), + ipAddress: req.ip + }); return res.status(200).send(response); } @@ -231,7 +256,6 @@ export const sendMfaToken = async (req: Request, res: Response) => { code } }); - } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -266,6 +290,12 @@ export const verifyMfaToken = async (req: Request, res: Response) => { if (!user) throw new Error('Failed to find user'); + await checkUserDevice({ + user, + ip: req.ip, + userAgent: req.headers['user-agent'] ?? '' + }); + // issue tokens const tokens = await issueAuthTokens({ userId: user._id.toString() }); @@ -303,7 +333,19 @@ export const verifyMfaToken = async (req: Request, res: Response) => { resObj.protectedKeyIV = user.protectedKeyIV; resObj.protectedKeyTag = user.protectedKeyTag; } + + const loginAction = await EELogService.createAction({ + name: ACTION_LOGIN, + userId: user._id + }); + loginAction && await EELogService.createLog({ + userId: user._id, + actions: [loginAction], + channel: getChannelFromUserAgent(req.headers['user-agent']), + ipAddress: req.ip + }); + return res.status(200).send(resObj); } diff --git a/backend/src/helpers/user.ts b/backend/src/helpers/user.ts index ae0845bc6..932a4bd81 100644 --- a/backend/src/helpers/user.ts +++ b/backend/src/helpers/user.ts @@ -1,5 +1,6 @@ import * as Sentry from '@sentry/node'; -import { User } from '../models'; +import { IUser, User } from '../models'; +import { sendMail } from './nodemailer'; /** * Initialize a user under email [email] @@ -101,4 +102,48 @@ const completeAccount = async ({ return user; }; -export { setupAccount, completeAccount }; +/** + * Check if device with ip [ip] and user-agent [userAgent] has been seen for user [user]. + * If the device is unseen, then notify the user of the new device + * @param {Object} obj + * @param {String} obj.ip - login ip address + * @param {String} obj.userAgent - login user-agent + */ +const checkUserDevice = async ({ + user, + ip, + userAgent +}: { + user: IUser; + ip: string; + userAgent: string; +}) => { + const isDeviceSeen = user.devices.some((device) => device.ip === ip && device.userAgent === userAgent); + + if (!isDeviceSeen) { + // case: unseen login ip detected for user + // -> notify user about the sign-in from new ip + + user.devices = user.devices.concat([{ + ip: String(ip), + userAgent + }]); + + await user.save(); + + // send MFA code [code] to [email] + await sendMail({ + template: 'newDevice.handlebars', + subjectLine: `Successful login from new device`, + recipients: [user.email], + substitutions: { + email: user.email, + timestamp: new Date().toString(), + ip, + userAgent + } + }); + } +} + +export { setupAccount, completeAccount, checkUserDevice }; diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index 6f153c7fc..545c5256d 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -1,6 +1,6 @@ -import { Schema, model, Types } from 'mongoose'; +import { Schema, model, Types, Document } from 'mongoose'; -export interface IUser { +export interface IUser extends Document { _id: Types.ObjectId; email: string; firstName?: string; @@ -18,7 +18,10 @@ export interface IUser { refreshVersion?: number; isMfaEnabled: boolean; mfaMethods: boolean; - seenIps: [string]; // TODO 1: email for unseen IPs, TODO 2: move to a central alerting system + devices: { + ip: string; + userAgent: string; + }[]; } const userSchema = new Schema( @@ -86,8 +89,11 @@ const userSchema = new Schema( mfaMethods: [{ type: String }], - seenIps: { - type: [String], + devices: { + type: [{ + ip: String, + userAgent: String + }], default: [] } }, diff --git a/backend/src/routes/v2/users.ts b/backend/src/routes/v2/users.ts index e93f15ae8..bdf0978e1 100644 --- a/backend/src/routes/v2/users.ts +++ b/backend/src/routes/v2/users.ts @@ -4,7 +4,7 @@ import { requireAuth, validateRequest } from '../../middleware'; -import { body, param } from 'express-validator'; +import { body } from 'express-validator'; import { usersController } from '../../controllers/v2'; router.get( diff --git a/backend/src/templates/emailVerification.handlebars b/backend/src/templates/emailVerification.handlebars index ac8abb087..fc738d202 100644 --- a/backend/src/templates/emailVerification.handlebars +++ b/backend/src/templates/emailVerification.handlebars @@ -4,7 +4,7 @@ - + Code diff --git a/backend/src/templates/newDevice.handlebars b/backend/src/templates/newDevice.handlebars new file mode 100644 index 000000000..654bb1ba3 --- /dev/null +++ b/backend/src/templates/newDevice.handlebars @@ -0,0 +1,19 @@ + + + + + + + Successful login for {{email}} from new device + + + +

Infisical

+

We're verifying a recent login for {{email}}:

+

Timestamp: {{timestamp}}

+

IP address: {{ip}}

+

User agent: {{userAgent}}

+

If you believe that this login is suspicious, please contact Infisical or reset your password immediately.

+ + + \ No newline at end of file diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 804df78c2..342fa9e96 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -5,38 +5,40 @@ description: "How to configure your environment variables when self-hosting Infi Configuring Infisical requires setting some environment variables. There is a file called [`.env.example`](https://github.com/Infisical/infisical/blob/main/.env.example) at the root directory of our main repo that you can use to create a `.env` file before you start the server. -| Variable | Description | Default Value | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------- | -| `ENCRYPTION_KEY` | ❗️ Strong hex encryption key | `None` | -| `JWT_SIGNUP_SECRET` | ❗️ JWT token secret | `None` | -| `JWT_REFRESH_SECRET` | ❗️ JWT token secret | `None` | -| `JWT_AUTH_SECRET` | ❗️ JWT token secret | `None` | -| `JWT_SERVICE_SECRET` | ❗️ JWT token secret | `None` | -| `JWT_SIGNUP_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `15m` | -| `JWT_REFRESH_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `90d` | -| `JWT_AUTH_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `10d` | -| `EMAIL_TOKEN_LIFETIME` | Email OTP/magic-link lifetime expressed in seconds | `86400` | -| `MONGO_URL` | ❗️ MongoDB instance connection string either to container instance or MongoDB Cloud | `None` | -| `MONGO_USERNAME` | MongoDB username if using container | `None` | -| `MONGO_PASSWORD` | MongoDB password if using container | `None` | -| `SITE_URL` | ❗️ Site URL - should be an absolute URL including the protocol (e.g. `https://app.infisical.com`) | `None` | -| `SMTP_HOST` | ❗️ Hostname to connect to for establishing SMTP connections | `None` | -| `SMTP_USERNAME` | ❗️ Credential to connect to host (e.g. `team@infisical.com`) | `None` | -| `SMTP_PASSWORD` | ❗️ Credential to connect to host | `None` | -| `SMTP_PORT` | Port to connect to for establishing SMTP connections | `587` | -| `SMTP_SECURE` | If true, use TLS when connecting to host. If false, TLS will be used if STARTTLS is supported | `false` | -| `SMTP_FROM_ADDRESS` | ❗️ Email address to be used for sending emails (e.g. `team@infisical.com`) | `None` | -| `SMTP_FROM_NAME` | Name label to be used in From field (e.g. `Team`) | `Infisical` | -| `TELEMETRY_ENABLED` | `true` or `false`. [More](../overview). | `true` | -| `LICENSE_KEY` | License key if using Infisical Enterprise Edition | `true` | -| `CLIENT_ID_HEROKU` | OAuth2 client ID for Heroku integration | `None` | -| `CLIENT_ID_VERCEL` | OAuth2 client ID for Vercel integration | `None` | -| `CLIENT_ID_NETLIFY` | OAuth2 client ID for Netlify integration | `None` | -| `CLIENT_ID_GITHUB` | OAuth2 client ID for GitHub integration | `None` | -| `CLIENT_SECRET_HEROKU` | OAuth2 client secret for Heroku integration | `None` | -| `CLIENT_SECRET_VERCEL` | OAuth2 client secret for Vercel integration | `None` | -| `CLIENT_SECRET_NETLIFY` | OAuth2 client secret for Netlify integration | `None` | -| `CLIENT_SECRET_GITHUB` | OAuth2 client secret for GitHub integration | `None` | -| `CLIENT_SLUG_VERCEL` | OAuth2 slug for Netlify integration | `None` | -| `SENTRY_DSN` | DSN for error-monitoring with Sentry | `None` | -| `INVITE_ONLY_SIGNUP` | If true, users can only sign up if they are invited | `false` | +| Variable | Description | Default Value | +| ----------------------- | ----------------------------------------------------------------------------------------------------------- | ------------- | +| `ENCRYPTION_KEY` | ❗️ Strong hex encryption key | `None` | +| `JWT_SIGNUP_SECRET` | ❗️ JWT token secret | `None` | +| `JWT_REFRESH_SECRET` | ❗️ JWT token secret | `None` | +| `JWT_AUTH_SECRET` | ❗️ JWT token secret | `None` | +| `JWT_MFA_SECRET` | ❗️ JWT token secret | `None` | +| `JWT_SERVICE_SECRET` | ❗️ JWT token secret | `None` | +| `JWT_SIGNUP_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `15m` | +| `JWT_REFRESH_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `90d` | +| `JWT_AUTH_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `10d` | +| `JWT_MFA_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `5m` | +| `EMAIL_TOKEN_LIFETIME` | Email OTP/magic-link lifetime expressed in seconds | `86400` | +| `MONGO_URL` | ❗️ MongoDB instance connection string either to container instance or MongoDB Cloud | `None` | +| `MONGO_USERNAME` | MongoDB username if using container | `None` | +| `MONGO_PASSWORD` | MongoDB password if using container | `None` | +| `SITE_URL` | ❗️ Site URL - should be an absolute URL including the protocol (e.g. `https://app.infisical.com`) | `None` | +| `SMTP_HOST` | ❗️ Hostname to connect to for establishing SMTP connections | `None` | +| `SMTP_USERNAME` | ❗️ Credential to connect to host (e.g. `team@infisical.com`) | `None` | +| `SMTP_PASSWORD` | ❗️ Credential to connect to host | `None` | +| `SMTP_PORT` | Port to connect to for establishing SMTP connections | `587` | +| `SMTP_SECURE` | If true, use TLS when connecting to host. If false, TLS will be used if STARTTLS is supported | `false` | +| `SMTP_FROM_ADDRESS` | ❗️ Email address to be used for sending emails (e.g. `team@infisical.com`) | `None` | +| `SMTP_FROM_NAME` | Name label to be used in From field (e.g. `Team`) | `Infisical` | +| `TELEMETRY_ENABLED` | `true` or `false`. [More](../overview). | `true` | +| `LICENSE_KEY` | License key if using Infisical Enterprise Edition | `true` | +| `CLIENT_ID_HEROKU` | OAuth2 client ID for Heroku integration | `None` | +| `CLIENT_ID_VERCEL` | OAuth2 client ID for Vercel integration | `None` | +| `CLIENT_ID_NETLIFY` | OAuth2 client ID for Netlify integration | `None` | +| `CLIENT_ID_GITHUB` | OAuth2 client ID for GitHub integration | `None` | +| `CLIENT_SECRET_HEROKU` | OAuth2 client secret for Heroku integration | `None` | +| `CLIENT_SECRET_VERCEL` | OAuth2 client secret for Vercel integration | `None` | +| `CLIENT_SECRET_NETLIFY` | OAuth2 client secret for Netlify integration | `None` | +| `CLIENT_SECRET_GITHUB` | OAuth2 client secret for GitHub integration | `None` | +| `CLIENT_SLUG_VERCEL` | OAuth2 slug for Netlify integration | `None` | +| `SENTRY_DSN` | DSN for error-monitoring with Sentry | `None` | +| `INVITE_ONLY_SIGNUP` | If true, users can only sign up if they are invited | `false` |