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

GrottoCenter / grottocenter-api / 26660435915

29 May 2026 08:26PM UTC coverage: 86.913% (+0.001%) from 86.912%
26660435915

push

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

3514 of 4205 branches covered (83.57%)

Branch coverage included in aggregate %.

218 of 236 new or added lines in 10 files covered. (92.37%)

2 existing lines in 1 file now uncovered.

7039 of 7937 relevant lines covered (88.69%)

55.59 hits per line

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

85.32
/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: only allow mfaDevSecret in development and test environments.
33
    // Any other environment (staging, qa, preview, production) is refused.
34
    const allowedEnvs = ['development', 'test'];
12✔
35
    const nodeEnv = process.env.NODE_ENV || '';
12!
36
    const sailsEnv = sails.config.environment || '';
12!
37
    if (allowedEnvs.includes(nodeEnv) || allowedEnvs.includes(sailsEnv)) {
12!
38
      return devSecret;
12✔
39
    }
NEW
40
    sails.log.warn(
×
41
      `MfaService: mfaDevSecret is set in "${nodeEnv || sailsEnv}" environment — ignoring it for security.`
×
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

83
  if (parts[0] !== ENCRYPTION_VERSION) {
72✔
84
    throw new Error(
1✔
85
      `Unknown encryption version: "${parts[0]}". Expected "${ENCRYPTION_VERSION}".`
86
    );
87
  }
88

89
  // Versioned format: v1:iv:ciphertext:authTag
90
  const [, ivB64, encB64, tagB64] = parts;
71✔
91

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

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

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

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

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

155
  const secret = generateSecret();
11✔
156
  const encryptedSecret = encryptSecret(secret);
11✔
157

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

162
  const otpauthUri = buildOtpauthUri(secret, caver.mail);
11✔
163

164
  return { secret, otpauthUri };
11✔
165
}
166

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

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

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

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

190
  // Store the enrollment code as lastUsedTotp to prevent immediate replay.
191
  // If the admin attempts a login with the same code within the 90s window,
192
  // it will be rejected as a replay. This is acceptable because /mfa/verify
193
  // already returns a full auth token, so a separate login is unnecessary.
194
  await TCaver.updateOne({ id: caverId }).set({
3✔
195
    mfaEnabled: true,
196
    totpFailedAttempts: 0,
197
    loginFailedAttempts: 0,
198
    lastUsedTotp: code,
199
    lastUsedTotpAt: new Date(),
200
  });
201

202
  return { success: true };
3✔
203
}
204

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

220
module.exports = {
2✔
221
  generateSecret,
222
  encryptSecret,
223
  decryptSecret,
224
  buildOtpauthUri,
225
  verifyCode,
226
  isReplay,
227
  startEnrollment,
228
  confirmEnrollment,
229
  resetMfa,
230
};
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