Skip to content

Commit

Permalink
Finish alert for new device login detection
Browse files Browse the repository at this point in the history
  • Loading branch information
dangtony98 committed Feb 21, 2023
1 parent 7cb6aee commit 2d6d329
Show file tree
Hide file tree
Showing 8 changed files with 170 additions and 48 deletions.
8 changes: 8 additions & 0 deletions backend/src/controllers/v1/authController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
50 changes: 46 additions & 4 deletions backend/src/controllers/v2/authController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() });
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -231,7 +256,6 @@ export const sendMfaToken = async (req: Request, res: Response) => {
code
}
});

} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
Expand Down Expand Up @@ -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() });

Expand Down Expand Up @@ -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);
}

49 changes: 47 additions & 2 deletions backend/src/helpers/user.ts
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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 };
16 changes: 11 additions & 5 deletions backend/src/models/user.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<IUser>(
Expand Down Expand Up @@ -86,8 +89,11 @@ const userSchema = new Schema<IUser>(
mfaMethods: [{
type: String
}],
seenIps: {
type: [String],
devices: {
type: [{
ip: String,
userAgent: String
}],
default: []
}
},
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/v2/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion backend/src/templates/emailVerification.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title></title>
<title>Code</title>
</head>

<body>
Expand Down
19 changes: 19 additions & 0 deletions backend/src/templates/newDevice.handlebars
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>

<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Successful login for {{email}} from new device</title>
</head>

<body>
<h2>Infisical</h2>
<p>We're verifying a recent login for {{email}}:</p>
<p><strong>Timestamp</strong>: {{timestamp}}</p>
<p><strong>IP address</strong>: {{ip}}</p>
<p><strong>User agent</strong>: {{userAgent}}</p>
<p>If you believe that this login is suspicious, please contact Infisical or reset your password immediately.</p>
</body>

</html>
72 changes: 37 additions & 35 deletions docs/self-hosting/configuration/envars.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. `[email protected]`) | `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. `[email protected]`) | `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. `[email protected]`) | `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. `[email protected]`) | `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` |

0 comments on commit 2d6d329

Please sign in to comment.