mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Finish alert for new device login detection
This commit is contained in:
@@ -6,6 +6,7 @@ import * as bigintConversion from 'bigint-conversion';
|
|||||||
const jsrp = require('jsrp');
|
const jsrp = require('jsrp');
|
||||||
import { User, LoginSRPDetail } from '../../models';
|
import { User, LoginSRPDetail } from '../../models';
|
||||||
import { createToken, issueAuthTokens, clearTokens } from '../../helpers/auth';
|
import { createToken, issueAuthTokens, clearTokens } from '../../helpers/auth';
|
||||||
|
import { checkUserDevice } from '../../helpers/user';
|
||||||
import {
|
import {
|
||||||
ACTION_LOGIN,
|
ACTION_LOGIN,
|
||||||
ACTION_LOGOUT
|
ACTION_LOGOUT
|
||||||
@@ -111,6 +112,13 @@ export const login2 = async (req: Request, res: Response) => {
|
|||||||
// compare server and client shared keys
|
// compare server and client shared keys
|
||||||
if (server.checkClientProof(clientProof)) {
|
if (server.checkClientProof(clientProof)) {
|
||||||
// issue tokens
|
// issue tokens
|
||||||
|
|
||||||
|
await checkUserDevice({
|
||||||
|
user,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'] ?? ''
|
||||||
|
});
|
||||||
|
|
||||||
const tokens = await issueAuthTokens({ userId: user._id.toString() });
|
const tokens = await issueAuthTokens({ userId: user._id.toString() });
|
||||||
|
|
||||||
// store (refresh) token in httpOnly cookie
|
// store (refresh) token in httpOnly cookie
|
||||||
|
|||||||
@@ -6,17 +6,21 @@ import * as bigintConversion from 'bigint-conversion';
|
|||||||
const jsrp = require('jsrp');
|
const jsrp = require('jsrp');
|
||||||
import { User, LoginSRPDetail } from '../../models';
|
import { User, LoginSRPDetail } from '../../models';
|
||||||
import { issueAuthTokens, createToken } from '../../helpers/auth';
|
import { issueAuthTokens, createToken } from '../../helpers/auth';
|
||||||
|
import { checkUserDevice } from '../../helpers/user';
|
||||||
import { sendMail } from '../../helpers/nodemailer';
|
import { sendMail } from '../../helpers/nodemailer';
|
||||||
import { TokenService } from '../../services';
|
import { TokenService } from '../../services';
|
||||||
|
import { EELogService } from '../../ee/services';
|
||||||
import {
|
import {
|
||||||
NODE_ENV,
|
NODE_ENV,
|
||||||
JWT_MFA_LIFETIME,
|
JWT_MFA_LIFETIME,
|
||||||
JWT_MFA_SECRET
|
JWT_MFA_SECRET
|
||||||
} from '../../config';
|
} from '../../config';
|
||||||
import { BadRequestError } from '../../utils/errors';
|
import { BadRequestError, InternalServerError } from '../../utils/errors';
|
||||||
import {
|
import {
|
||||||
TOKEN_EMAIL_MFA
|
TOKEN_EMAIL_MFA,
|
||||||
|
ACTION_LOGIN
|
||||||
} from '../../variables';
|
} from '../../variables';
|
||||||
|
import { getChannelFromUserAgent } from '../../utils/posthog'; // TODO: move this
|
||||||
|
|
||||||
declare module 'jsonwebtoken' {
|
declare module 'jsonwebtoken' {
|
||||||
export interface UserIDJwtPayload extends jwt.JwtPayload {
|
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) => {
|
export const login2 = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
if (!req.headers['user-agent']) throw InternalServerError({ message: 'User-Agent header is required' });
|
||||||
|
|
||||||
const { email, clientProof } = req.body;
|
const { email, clientProof } = req.body;
|
||||||
const user = await User.findOne({
|
const user = await User.findOne({
|
||||||
email
|
email
|
||||||
@@ -143,6 +150,12 @@ export const login2 = async (req: Request, res: Response) => {
|
|||||||
token
|
token
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await checkUserDevice({
|
||||||
|
user,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'] ?? ''
|
||||||
|
});
|
||||||
|
|
||||||
// issue tokens
|
// issue tokens
|
||||||
const tokens = await issueAuthTokens({ userId: user._id.toString() });
|
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
|
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
|
// return (access) token in response
|
||||||
|
|
||||||
interface ResponseData {
|
interface ResponseData {
|
||||||
@@ -190,6 +203,18 @@ export const login2 = async (req: Request, res: Response) => {
|
|||||||
response.protectedKeyIV = user.protectedKeyIV
|
response.protectedKeyIV = user.protectedKeyIV
|
||||||
response.protectedKeyTag = user.protectedKeyTag;
|
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);
|
return res.status(200).send(response);
|
||||||
}
|
}
|
||||||
@@ -231,7 +256,6 @@ export const sendMfaToken = async (req: Request, res: Response) => {
|
|||||||
code
|
code
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Sentry.setUser(null);
|
Sentry.setUser(null);
|
||||||
Sentry.captureException(err);
|
Sentry.captureException(err);
|
||||||
@@ -266,6 +290,12 @@ export const verifyMfaToken = async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
if (!user) throw new Error('Failed to find user');
|
if (!user) throw new Error('Failed to find user');
|
||||||
|
|
||||||
|
await checkUserDevice({
|
||||||
|
user,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'] ?? ''
|
||||||
|
});
|
||||||
|
|
||||||
// issue tokens
|
// issue tokens
|
||||||
const tokens = await issueAuthTokens({ userId: user._id.toString() });
|
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.protectedKeyIV = user.protectedKeyIV;
|
||||||
resObj.protectedKeyTag = user.protectedKeyTag;
|
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);
|
return res.status(200).send(resObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as Sentry from '@sentry/node';
|
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]
|
* Initialize a user under email [email]
|
||||||
@@ -101,4 +102,48 @@ const completeAccount = async ({
|
|||||||
return user;
|
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 };
|
||||||
|
|||||||
@@ -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;
|
_id: Types.ObjectId;
|
||||||
email: string;
|
email: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
@@ -18,7 +18,10 @@ export interface IUser {
|
|||||||
refreshVersion?: number;
|
refreshVersion?: number;
|
||||||
isMfaEnabled: boolean;
|
isMfaEnabled: boolean;
|
||||||
mfaMethods: 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>(
|
const userSchema = new Schema<IUser>(
|
||||||
@@ -86,8 +89,11 @@ const userSchema = new Schema<IUser>(
|
|||||||
mfaMethods: [{
|
mfaMethods: [{
|
||||||
type: String
|
type: String
|
||||||
}],
|
}],
|
||||||
seenIps: {
|
devices: {
|
||||||
type: [String],
|
type: [{
|
||||||
|
ip: String,
|
||||||
|
userAgent: String
|
||||||
|
}],
|
||||||
default: []
|
default: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
requireAuth,
|
requireAuth,
|
||||||
validateRequest
|
validateRequest
|
||||||
} from '../../middleware';
|
} from '../../middleware';
|
||||||
import { body, param } from 'express-validator';
|
import { body } from 'express-validator';
|
||||||
import { usersController } from '../../controllers/v2';
|
import { usersController } from '../../controllers/v2';
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||||
<title></title>
|
<title>Code</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
19
backend/src/templates/newDevice.handlebars
Normal file
19
backend/src/templates/newDevice.handlebars
Normal file
@@ -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>
|
||||||
@@ -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.
|
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 |
|
| Variable | Description | Default Value |
|
||||||
| ---------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------- |
|
| ----------------------- | ----------------------------------------------------------------------------------------------------------- | ------------- |
|
||||||
| `ENCRYPTION_KEY` | ❗️ Strong hex encryption key | `None` |
|
| `ENCRYPTION_KEY` | ❗️ Strong hex encryption key | `None` |
|
||||||
| `JWT_SIGNUP_SECRET` | ❗️ JWT token secret | `None` |
|
| `JWT_SIGNUP_SECRET` | ❗️ JWT token secret | `None` |
|
||||||
| `JWT_REFRESH_SECRET` | ❗️ JWT token secret | `None` |
|
| `JWT_REFRESH_SECRET` | ❗️ JWT token secret | `None` |
|
||||||
| `JWT_AUTH_SECRET` | ❗️ JWT token secret | `None` |
|
| `JWT_AUTH_SECRET` | ❗️ JWT token secret | `None` |
|
||||||
| `JWT_SERVICE_SECRET` | ❗️ JWT token secret | `None` |
|
| `JWT_MFA_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_SERVICE_SECRET` | ❗️ JWT token secret | `None` |
|
||||||
| `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_SIGNUP_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `15m` |
|
||||||
| `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_REFRESH_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `90d` |
|
||||||
| `EMAIL_TOKEN_LIFETIME` | Email OTP/magic-link lifetime expressed in seconds | `86400` |
|
| `JWT_AUTH_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `10d` |
|
||||||
| `MONGO_URL` | ❗️ MongoDB instance connection string either to container instance or MongoDB Cloud | `None` |
|
| `JWT_MFA_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `5m` |
|
||||||
| `MONGO_USERNAME` | MongoDB username if using container | `None` |
|
| `EMAIL_TOKEN_LIFETIME` | Email OTP/magic-link lifetime expressed in seconds | `86400` |
|
||||||
| `MONGO_PASSWORD` | MongoDB password if using container | `None` |
|
| `MONGO_URL` | ❗️ MongoDB instance connection string either to container instance or MongoDB Cloud | `None` |
|
||||||
| `SITE_URL` | ❗️ Site URL - should be an absolute URL including the protocol (e.g. `https://app.infisical.com`) | `None` |
|
| `MONGO_USERNAME` | MongoDB username if using container | `None` |
|
||||||
| `SMTP_HOST` | ❗️ Hostname to connect to for establishing SMTP connections | `None` |
|
| `MONGO_PASSWORD` | MongoDB password if using container | `None` |
|
||||||
| `SMTP_USERNAME` | ❗️ Credential to connect to host (e.g. `team@infisical.com`) | `None` |
|
| `SITE_URL` | ❗️ Site URL - should be an absolute URL including the protocol (e.g. `https://app.infisical.com`) | `None` |
|
||||||
| `SMTP_PASSWORD` | ❗️ Credential to connect to host | `None` |
|
| `SMTP_HOST` | ❗️ Hostname to connect to for establishing SMTP connections | `None` |
|
||||||
| `SMTP_PORT` | Port to connect to for establishing SMTP connections | `587` |
|
| `SMTP_USERNAME` | ❗️ Credential to connect to host (e.g. `team@infisical.com`) | `None` |
|
||||||
| `SMTP_SECURE` | If true, use TLS when connecting to host. If false, TLS will be used if STARTTLS is supported | `false` |
|
| `SMTP_PASSWORD` | ❗️ Credential to connect to host | `None` |
|
||||||
| `SMTP_FROM_ADDRESS` | ❗️ Email address to be used for sending emails (e.g. `team@infisical.com`) | `None` |
|
| `SMTP_PORT` | Port to connect to for establishing SMTP connections | `587` |
|
||||||
| `SMTP_FROM_NAME` | Name label to be used in From field (e.g. `Team`) | `Infisical` |
|
| `SMTP_SECURE` | If true, use TLS when connecting to host. If false, TLS will be used if STARTTLS is supported | `false` |
|
||||||
| `TELEMETRY_ENABLED` | `true` or `false`. [More](../overview). | `true` |
|
| `SMTP_FROM_ADDRESS` | ❗️ Email address to be used for sending emails (e.g. `team@infisical.com`) | `None` |
|
||||||
| `LICENSE_KEY` | License key if using Infisical Enterprise Edition | `true` |
|
| `SMTP_FROM_NAME` | Name label to be used in From field (e.g. `Team`) | `Infisical` |
|
||||||
| `CLIENT_ID_HEROKU` | OAuth2 client ID for Heroku integration | `None` |
|
| `TELEMETRY_ENABLED` | `true` or `false`. [More](../overview). | `true` |
|
||||||
| `CLIENT_ID_VERCEL` | OAuth2 client ID for Vercel integration | `None` |
|
| `LICENSE_KEY` | License key if using Infisical Enterprise Edition | `true` |
|
||||||
| `CLIENT_ID_NETLIFY` | OAuth2 client ID for Netlify integration | `None` |
|
| `CLIENT_ID_HEROKU` | OAuth2 client ID for Heroku integration | `None` |
|
||||||
| `CLIENT_ID_GITHUB` | OAuth2 client ID for GitHub integration | `None` |
|
| `CLIENT_ID_VERCEL` | OAuth2 client ID for Vercel integration | `None` |
|
||||||
| `CLIENT_SECRET_HEROKU` | OAuth2 client secret for Heroku integration | `None` |
|
| `CLIENT_ID_NETLIFY` | OAuth2 client ID for Netlify integration | `None` |
|
||||||
| `CLIENT_SECRET_VERCEL` | OAuth2 client secret for Vercel integration | `None` |
|
| `CLIENT_ID_GITHUB` | OAuth2 client ID for GitHub integration | `None` |
|
||||||
| `CLIENT_SECRET_NETLIFY` | OAuth2 client secret for Netlify integration | `None` |
|
| `CLIENT_SECRET_HEROKU` | OAuth2 client secret for Heroku integration | `None` |
|
||||||
| `CLIENT_SECRET_GITHUB` | OAuth2 client secret for GitHub integration | `None` |
|
| `CLIENT_SECRET_VERCEL` | OAuth2 client secret for Vercel integration | `None` |
|
||||||
| `CLIENT_SLUG_VERCEL` | OAuth2 slug for Netlify integration | `None` |
|
| `CLIENT_SECRET_NETLIFY` | OAuth2 client secret for Netlify integration | `None` |
|
||||||
| `SENTRY_DSN` | DSN for error-monitoring with Sentry | `None` |
|
| `CLIENT_SECRET_GITHUB` | OAuth2 client secret for GitHub integration | `None` |
|
||||||
| `INVITE_ONLY_SIGNUP` | If true, users can only sign up if they are invited | `false` |
|
| `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` |
|
||||||
|
|||||||
Reference in New Issue
Block a user