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

GrottoCenter / grottocenter-api / 26527336612

27 May 2026 05:24PM UTC coverage: 87.102% (+0.3%) from 86.841%
26527336612

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

3397 of 4057 branches covered (83.73%)

Branch coverage included in aggregate %.

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

15 existing lines in 1 file now uncovered.

6807 of 7658 relevant lines covered (88.89%)

56.98 hits per line

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

88.1
/api/services/AdminLoginProtectionService.js
1
/**
2
 * AdminLoginProtectionService.js
3
 *
4
 * @description :: Handles consecutive failure tracking, account banning,
5
 *                 and suspicious-activity email notifications for
6
 *                 Administrator accounts. All state is persisted in the
7
 *                 database (stateless service).
8
 */
9

10
const LanguageService = require('./LanguageService');
1✔
11

12
module.exports = {
1✔
13
  /**
14
   * Record a failed login attempt for an admin account.
15
   * Increments the loginFailedAttempts counter, updates lastFailedLoginAt,
16
   * bans the account if the threshold is reached, and sends notification
17
   * emails as appropriate (fire-and-forget).
18
   * @param {Object} caver - The caver record (with groups populated)
19
   * @param {string} ip - Source IP address
20
   * @returns {Promise<{ banned: boolean }>}
21
   */
22
  async recordFailedLogin(caver, ip) {
23
    const threshold = sails.config.custom.adminLoginFailureThreshold || 5;
40!
24
    const suspiciousThreshold =
25
      sails.config.custom.suspiciousActivityEmailThreshold || 3;
40!
26
    const cooldownMs =
27
      sails.config.custom.suspiciousActivityEmailCooldown || 900000;
40!
28

29
    const newCount = (caver.loginFailedAttempts || 0) + 1;
40✔
30
    const banned = newCount >= threshold;
40✔
31
    const now = new Date();
40✔
32

33
    const updateValues = {
40✔
34
      loginFailedAttempts: newCount,
35
      lastFailedLoginAt: now,
36
    };
37

38
    if (banned) {
40✔
39
      updateValues.banned = true;
3✔
40
    }
41

42
    // Check if suspicious-activity email should be sent
43
    if (newCount >= suspiciousThreshold && !banned) {
40✔
44
      const lastEmailAt = caver.lastSuspiciousEmailAt
15✔
45
        ? new Date(caver.lastSuspiciousEmailAt)
46
        : null;
47
      const cooldownExpired =
48
        !lastEmailAt || now.getTime() - lastEmailAt.getTime() >= cooldownMs;
15✔
49

50
      if (cooldownExpired) {
15✔
51
        // Update the timestamp BEFORE sending so that concurrent requests
52
        // within the cooldown window don't trigger duplicate emails.
53
        // If SES fails (logged but swallowed), no alert is sent during this
54
        // window — an acceptable trade-off vs. potential email storms.
55
        updateValues.lastSuspiciousEmailAt = now;
5✔
56
        // Fire-and-forget: send suspicious login email
57
        this.sendSuspiciousLoginEmail(caver, {
5✔
58
          failedAttempts: newCount,
59
          lastAttemptTime: now.toISOString(),
60
          sourceIp: ip || 'unknown',
5!
61
        });
62
      }
63
    }
64

65
    await TCaver.updateOne({ id: caver.id }).set(updateValues);
40✔
66

67
    if (banned) {
40✔
68
      sails.log.warn(
3✔
69
        `AdminLoginProtection: account ${caver.id} banned after ${newCount} failed login attempts (IP: ${ip || 'unknown'})`
3!
70
      );
71
      // Fire-and-forget: send ban notification email
72
      this.sendAccountBannedEmail(caver, {
3✔
73
        lastAttemptTime: now.toISOString(),
74
        sourceIp: ip || 'unknown',
3!
75
      });
76
    }
77

78
    return { banned };
40✔
79
  },
80

81
  /**
82
   * Record a failed TOTP attempt for an admin account.
83
   * Increments the totpFailedAttempts counter and bans the account
84
   * if the threshold is reached.
85
   * @param {Object} caver - The caver record
86
   * @returns {Promise<{ banned: boolean }>}
87
   */
88
  async recordFailedTotp(caver) {
89
    const threshold = sails.config.custom.adminTotpFailureThreshold || 5;
7!
90
    const newCount = (caver.totpFailedAttempts || 0) + 1;
7✔
91
    const banned = newCount >= threshold;
7✔
92

93
    const updateValues = {
7✔
94
      totpFailedAttempts: newCount,
95
    };
96

97
    if (banned) {
7✔
98
      updateValues.banned = true;
1✔
99
    }
100

101
    await TCaver.updateOne({ id: caver.id }).set(updateValues);
7✔
102

103
    if (banned) {
7✔
104
      sails.log.warn(
1✔
105
        `AdminLoginProtection: account ${caver.id} banned after ${newCount} failed TOTP attempts`
106
      );
107
    }
108

109
    return { banned };
7✔
110
  },
111

112
  /**
113
   * Reset failure counters on successful login.
114
   * @param {number} caverId
115
   * @returns {Promise<void>}
116
   */
117
  async resetCounters(caverId) {
118
    await TCaver.updateOne({ id: caverId }).set({
62✔
119
      loginFailedAttempts: 0,
120
      totpFailedAttempts: 0,
121
    });
122
  },
123

124
  /**
125
   * Check if an admin account is banned.
126
   * @param {Object} caver - The caver record
127
   * @returns {boolean}
128
   */
129
  isAccountBanned(caver) {
130
    return caver.banned === true;
70✔
131
  },
132

133
  /**
134
   * Send a suspicious-login notification email (fire-and-forget).
135
   * Errors are caught and logged — this method never throws.
136
   * @param {Object} caver - The caver record
137
   * @param {Object} details
138
   * @param {number} details.failedAttempts
139
   * @param {string} details.lastAttemptTime - ISO 8601 UTC
140
   * @param {string} details.sourceIp
141
   */
142
  sendSuspiciousLoginEmail(
143
    caver,
144
    { failedAttempts, lastAttemptTime, sourceIp }
145
  ) {
146
    const doSend = async () => {
5✔
147
      const locale =
148
        (await LanguageService.getLocale(caver.language)) ||
5!
149
        sails.config.i18n.defaultLocale;
150

151
      await sails.helpers.sendEmail.with({
5✔
152
        allowResponse: false,
153
        emailSubject: 'Suspicious Login Activity',
154
        locale,
155
        recipientEmail: caver.mail,
156
        viewName: 'suspiciousLogin',
157
        viewValues: {
158
          recipientName: caver.nickname,
159
          failedAttempts,
160
          lastAttemptTime,
161
          sourceIp,
162
        },
163
      });
164
    };
165

166
    // Fire-and-forget with error logging
167
    doSend().catch((error) => {
5✔
168
      sails.log.error(
1✔
169
        `AdminLoginProtection: failed to send suspicious-login email for caver ${caver.id} at ${new Date().toISOString()}: ${error.message}`
170
      );
171
    });
172
  },
173

174
  /**
175
   * Send an account-banned notification email (fire-and-forget).
176
   * Errors are caught and logged — this method never throws.
177
   * @param {Object} caver - The caver record
178
   * @param {Object} details
179
   * @param {string} details.lastAttemptTime - ISO 8601 UTC
180
   * @param {string} details.sourceIp
181
   */
182
  sendAccountBannedEmail(caver, { lastAttemptTime, sourceIp }) {
183
    const doSend = async () => {
3✔
184
      const locale =
185
        (await LanguageService.getLocale(caver.language)) ||
3!
186
        sails.config.i18n.defaultLocale;
187

188
      await sails.helpers.sendEmail.with({
3✔
189
        allowResponse: false,
190
        emailSubject: 'Account Banned',
191
        locale,
192
        recipientEmail: caver.mail,
193
        viewName: 'accountBanned',
194
        viewValues: {
195
          recipientName: caver.nickname,
196
          lastAttemptTime,
197
          sourceIp,
198
        },
199
      });
200
    };
201

202
    // Fire-and-forget with error logging
203
    doSend().catch((error) => {
3✔
NEW
204
      sails.log.error(
×
205
        `AdminLoginProtection: failed to send account-banned email for caver ${caver.id} at ${new Date().toISOString()}: ${error.message}`
206
      );
207
    });
208
  },
209
};
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