• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

GrottoCenter / grottocenter-api / 26345101095

23 May 2026 10:21PM UTC coverage: 87.168% (+0.1%) from 87.055%
26345101095

Pull #1609

github

ClemRz
feat(auth): harden admin account security with TOTP MFA and brute-force protection

- Reduce admin token TTL to 10 days (non-admin remains 90 days)
- Add mandatory TOTP-based MFA with enrollment, verification, and reset endpoints
- Apply stricter rate limiting for admin login (5 req / 15 min / IP)
- Ban admin accounts after 5 consecutive failed logins or TOTP attempts
- Send email notifications on suspicious login activity and account ban
- Revoke all admin tokens on password change
- Update Swagger documentation with new MFA endpoints and login statuses
Pull Request #1609: feat(auth): harden admin account security with TOTP MFA and brute-force protection

3385 of 4037 branches covered (83.85%)

Branch coverage included in aggregate %.

209 of 223 new or added lines in 10 files covered. (93.72%)

2 existing lines in 2 files now uncovered.

6798 of 7645 relevant lines covered (88.92%)

57.05 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.0
/api/services/MfaService.js
1
/**
2
 * MfaService
3
 *
4
 * @description :: Handles all MFA (TOTP) logic: secret generation, encryption,
5
 *                 verification, enrollment, and reset.
6
 */
7

8
const crypto = require('crypto');
2✔
9
const { authenticator } = require('otplib');
2✔
10

11
// Use a cloned instance to avoid mutating global otplib state.
12
const totpAuth = authenticator.clone();
2✔
13
totpAuth.options = {
2✔
14
  step: 30,
15
  window: 1,
16
  digits: 6,
17
  algorithm: 'sha1',
18
};
19

20
// Version prefix for encrypted secrets — allows future key rotation or
21
// algorithm changes without breaking existing ciphertexts.
22
const ENCRYPTION_VERSION = 'v1';
2✔
23

24
/**
25
 * Generate a TOTP secret. Uses mfaDevSecret config if set, otherwise
26
 * generates a cryptographically random 160-bit secret.
27
 * @returns {string} Base32-encoded secret
28
 */
29
function generateSecret() {
30
  const devSecret = sails.config.custom.mfaDevSecret;
13✔
31
  if (devSecret) {
13✔
32
    // Safety guard: refuse to use mfaDevSecret in production
33
    if (
12!
34
      process.env.NODE_ENV === 'production' ||
24✔
35
      sails.config.environment === 'production'
36
    ) {
NEW
37
      sails.log.error(
×
38
        'MfaService: mfaDevSecret is set in production — ignoring it for security.'
39
      );
40
    } else {
41
      return devSecret;
12✔
42
    }
43
  }
44
  // 20 bytes = 160 bits → 32 Base32 characters
45
  return totpAuth.generateSecret(20);
1✔
46
}
47

48
/**
49
 * Encrypt a TOTP secret for database storage using AES-256-GCM.
50
 * @param {string} plainSecret - Base32-encoded TOTP secret
51
 * @returns {string} Base64-encoded ciphertext (iv:ciphertext:authTag format)
52
 */
53
function encryptSecret(plainSecret) {
54
  const keyHex = sails.config.custom.mfaEncryptionKey;
34✔
55
  if (!keyHex) {
34!
NEW
56
    throw new Error('MFA encryption key is not configured');
×
57
  }
58
  const key = Buffer.from(keyHex, 'hex');
34✔
59
  const iv = crypto.randomBytes(12); // 96-bit IV for GCM
34✔
60
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
34✔
61
  const encrypted = Buffer.concat([
34✔
62
    cipher.update(plainSecret, 'utf8'),
63
    cipher.final(),
64
  ]);
65
  const authTag = cipher.getAuthTag();
34✔
66
  return `${ENCRYPTION_VERSION}:${iv.toString('base64')}:${encrypted.toString('base64')}:${authTag.toString('base64')}`;
34✔
67
}
68

69
/**
70
 * Decrypt a stored TOTP secret.
71
 * @param {string} encryptedSecret - Base64-encoded ciphertext from DB (iv:ciphertext:authTag)
72
 * @returns {string} Base32-encoded TOTP secret
73
 */
74
function decryptSecret(encryptedSecret) {
75
  const keyHex = sails.config.custom.mfaEncryptionKey;
72✔
76
  if (!keyHex) {
72!
NEW
77
    throw new Error('MFA encryption key is not configured');
×
78
  }
79
  const key = Buffer.from(keyHex, 'hex');
72✔
80

81
  const parts = encryptedSecret.split(':');
72✔
82
  let ivB64;
83
  let encB64;
84
  let tagB64;
85

86
  if (parts[0] === ENCRYPTION_VERSION) {
72✔
87
    // Versioned format: v1:iv:ciphertext:authTag
88
    [, ivB64, encB64, tagB64] = parts;
71✔
89
  } else {
90
    // Legacy format (no version prefix): iv:ciphertext:authTag
91
    [ivB64, encB64, tagB64] = parts;
1✔
92
  }
93

94
  const iv = Buffer.from(ivB64, 'base64');
72✔
95
  const encrypted = Buffer.from(encB64, 'base64');
72✔
96
  const authTag = Buffer.from(tagB64, 'base64');
72✔
97
  const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
72✔
98
  decipher.setAuthTag(authTag);
72✔
99
  return decipher.update(encrypted, null, 'utf8') + decipher.final('utf8');
72✔
100
}
101

102
/**
103
 * Build the otpauth:// URI for QR code generation.
104
 * @param {string} secret - Base32-encoded TOTP secret
105
 * @param {string} email - Administrator's email
106
 * @returns {string} otpauth:// URI
107
 */
108
function buildOtpauthUri(secret, email) {
109
  const issuer = sails.config.custom.mfaIssuerName || 'Grottocenter';
11!
110
  return totpAuth.keyuri(email, issuer, secret);
11✔
111
}
112

113
/**
114
 * Verify a TOTP code against a secret with ±1 step tolerance (30-second steps).
115
 * @param {string} code - 6-digit code from user
116
 * @param {string} secret - Base32-encoded TOTP secret (decrypted)
117
 * @returns {boolean}
118
 */
119
function verifyCode(code, secret) {
120
  if (!code || !/^\d{6}$/.test(code)) {
78✔
121
    return false;
5✔
122
  }
123
  return totpAuth.verify({ token: code, secret });
73✔
124
}
125

126
/**
127
 * Check if a TOTP code is a replay (same code used within 90s window).
128
 * @param {string} code - The TOTP code to check
129
 * @param {Object} caver - The caver record (with lastUsedTotp, lastUsedTotpAt)
130
 * @returns {boolean} true if replay detected
131
 */
132
function isReplay(code, caver) {
133
  if (!caver.lastUsedTotp || !caver.lastUsedTotpAt) {
69✔
134
    return false;
65✔
135
  }
136
  if (caver.lastUsedTotp !== code) {
4✔
137
    return false;
1✔
138
  }
139
  const lastUsedAt = new Date(caver.lastUsedTotpAt).getTime();
3✔
140
  const now = Date.now();
3✔
141
  const windowMs = 90 * 1000; // 90 seconds
3✔
142
  return now - lastUsedAt < windowMs;
3✔
143
}
144

145
/**
146
 * Start enrollment: generate secret, encrypt, store on caver record.
147
 * If a pending (unverified) secret exists, it is replaced.
148
 * @param {number} caverId
149
 * @returns {{ secret: string, otpauthUri: string }}
150
 */
151
async function startEnrollment(caverId) {
152
  const caver = await TCaver.findOne({ id: caverId }).populate('groups');
11✔
153
  if (!caver) {
11!
NEW
154
    throw new Error(`Caver not found: ${caverId}`);
×
155
  }
156

157
  const secret = generateSecret();
11✔
158
  const encryptedSecret = encryptSecret(secret);
11✔
159

160
  await TCaver.updateOne({ id: caverId }).set({
11✔
161
    totpSecret: encryptedSecret,
162
  });
163

164
  const otpauthUri = buildOtpauthUri(secret, caver.mail);
11✔
165

166
  return { secret, otpauthUri };
11✔
167
}
168

169
/**
170
 * Confirm enrollment: verify code, activate MFA, reset failure counters.
171
 * @param {number} caverId
172
 * @param {string} code - 6-digit TOTP code
173
 * @returns {{ success: boolean, error?: string }}
174
 */
175
async function confirmEnrollment(caverId, code) {
176
  const caver = await TCaver.findOne({ id: caverId });
6✔
177
  if (!caver) {
6!
NEW
178
    return { success: false, error: 'Caver not found' };
×
179
  }
180

181
  if (!caver.totpSecret) {
6✔
182
    return { success: false, error: 'No pending enrollment found' };
1✔
183
  }
184

185
  const secret = decryptSecret(caver.totpSecret);
5✔
186
  const isValid = verifyCode(code, secret);
5✔
187

188
  if (!isValid) {
5✔
189
    return { success: false, error: 'Invalid TOTP code' };
2✔
190
  }
191

192
  await TCaver.updateOne({ id: caverId }).set({
3✔
193
    mfaEnabled: true,
194
    totpFailedAttempts: 0,
195
    loginFailedAttempts: 0,
196
    lastUsedTotp: code,
197
    lastUsedTotpAt: new Date(),
198
  });
199

200
  return { success: true };
3✔
201
}
202

203
/**
204
 * Reset MFA for a caver: clear secret, set mfaEnabled=false.
205
 * @param {number} caverId
206
 * @returns {Promise<void>}
207
 */
208
async function resetMfa(caverId) {
209
  await TCaver.updateOne({ id: caverId }).set({
3✔
210
    totpSecret: null,
211
    mfaEnabled: false,
212
    totpFailedAttempts: 0,
213
    lastUsedTotp: null,
214
    lastUsedTotpAt: null,
215
  });
216
}
217

218
module.exports = {
2✔
219
  generateSecret,
220
  encryptSecret,
221
  decryptSecret,
222
  buildOtpauthUri,
223
  verifyCode,
224
  isReplay,
225
  startEnrollment,
226
  confirmEnrollment,
227
  resetMfa,
228
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc