Skip to content

Commit 48c993f

Browse files
committed
Add code to Implement account lockout after configured failed attempts
- Provide default values to the account lockout config
1 parent 6cd6523 commit 48c993f

File tree

22 files changed

+845
-26
lines changed

22 files changed

+845
-26
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package it.infn.mw.iam.api.account.lockout;
17+
18+
import java.util.List;
19+
import java.util.Map;
20+
import java.util.Optional;
21+
22+
import org.springframework.http.ResponseEntity;
23+
import org.springframework.security.access.prepost.PreAuthorize;
24+
import org.springframework.web.bind.annotation.GetMapping;
25+
import org.springframework.web.bind.annotation.PathVariable;
26+
import org.springframework.web.bind.annotation.RestController;
27+
28+
import it.infn.mw.iam.persistence.model.IamAccountLoginLockout;
29+
import it.infn.mw.iam.persistence.repository.IamAccountLoginLockoutRepository;
30+
31+
@RestController
32+
public class AccountLockoutController {
33+
34+
private final IamAccountLoginLockoutRepository lockoutRepo;
35+
36+
public AccountLockoutController(IamAccountLoginLockoutRepository lockoutRepo) {
37+
this.lockoutRepo = lockoutRepo;
38+
}
39+
40+
@GetMapping("/iam/account/{uuid}/lockout")
41+
@PreAuthorize("#iam.hasScope('iam:admin.read') or #iam.hasDashboardRole('ROLE_ADMIN')")
42+
public ResponseEntity<?> getLockoutStatus(@PathVariable String uuid) {
43+
Optional<IamAccountLoginLockout> lockout = lockoutRepo.findByAccountUuid(uuid);
44+
45+
if (lockout.isPresent() && lockout.get().getSuspendedUntil() != null
46+
&& System.currentTimeMillis() < lockout.get().getSuspendedUntil().getTime()) {
47+
return ResponseEntity.ok(Map.of(
48+
"suspended", true,
49+
"suspendedUntil", lockout.get().getSuspendedUntil().getTime()));
50+
}
51+
52+
return ResponseEntity.ok(Map.of("suspended", false));
53+
}
54+
55+
@GetMapping("/iam/account/lockout/suspended")
56+
@PreAuthorize("#iam.hasScope('iam:admin.read') or #iam.hasDashboardRole('ROLE_ADMIN')")
57+
public ResponseEntity<?> getAllSuspendedUsers() {
58+
List<String> suspendedUsers = lockoutRepo.findAllSuspendedUsers();
59+
return ResponseEntity.ok(suspendedUsers);
60+
}
61+
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package it.infn.mw.iam.authn.lockout;
17+
18+
import java.util.Date;
19+
import java.util.Optional;
20+
21+
import javax.annotation.PostConstruct;
22+
23+
import org.slf4j.Logger;
24+
import org.slf4j.LoggerFactory;
25+
import org.springframework.security.authentication.LockedException;
26+
import org.springframework.stereotype.Service;
27+
import org.springframework.transaction.annotation.Transactional;
28+
29+
import it.infn.mw.iam.config.IamProperties;
30+
import it.infn.mw.iam.config.IamProperties.LoginLockoutProperties;
31+
import it.infn.mw.iam.persistence.model.IamAccount;
32+
import it.infn.mw.iam.persistence.model.IamAccountLoginLockout;
33+
import it.infn.mw.iam.persistence.repository.IamAccountRepository;
34+
import it.infn.mw.iam.persistence.repository.IamAccountLoginLockoutRepository;
35+
36+
@Service
37+
public class DefaultLoginLockoutService implements LoginLockoutService {
38+
39+
private static final Logger LOG = LoggerFactory.getLogger(DefaultLoginLockoutService.class);
40+
41+
private final IamAccountLoginLockoutRepository lockoutRepo;
42+
private final IamAccountRepository accountRepo;
43+
private final LoginLockoutProperties properties;
44+
45+
public DefaultLoginLockoutService(IamAccountLoginLockoutRepository lockoutRepo,
46+
IamAccountRepository accountRepo, IamProperties iamProperties) {
47+
this.lockoutRepo = lockoutRepo;
48+
this.accountRepo = accountRepo;
49+
this.properties = iamProperties.getLoginLockout();
50+
}
51+
52+
@PostConstruct
53+
void validateConfiguration() {
54+
if (!properties.isEnabled()) {
55+
LOG.info("Iam Account Login lockout feature is disabled");
56+
return;
57+
}
58+
59+
if (properties.getMaxFailedAttempts() < 1) {
60+
throw new IllegalStateException(
61+
"iam.login-lockout.max-failed-attempts must be >= 1. "
62+
+ "Please provide the maximum number of failed login attempts allowed before suspension.");
63+
}
64+
65+
if (properties.getLockoutMinutes() < 1) {
66+
throw new IllegalStateException(
67+
"iam.login-lockout.lockout-minutes must be >= 1. "
68+
+ "Please provide the suspension duration in minutes.");
69+
}
70+
71+
if (properties.getMaxConcurrentFailures() < 1) {
72+
throw new IllegalStateException(
73+
"iam.login-lockout.max-concurrent-failures must be >= 1. "
74+
+ "Please provide the maximum number of suspension rounds allowed before the account is permanently disabled.");
75+
}
76+
77+
LOG.info("Iam Account Login lockout enabled: max-failed-attempts={}, lockout-minutes={}, "
78+
+ "max-concurrent-failures={}", properties.getMaxFailedAttempts(),
79+
properties.getLockoutMinutes(), properties.getMaxConcurrentFailures());
80+
}
81+
82+
@Override
83+
@Transactional
84+
public void checkIamAccountLockout(String username) {
85+
86+
if (!properties.isEnabled()) {
87+
return;
88+
}
89+
90+
Optional<IamAccountLoginLockout> lockoutOpt = lockoutRepo.findByAccountUsername(username);
91+
92+
if (lockoutOpt.isEmpty()) {
93+
return;
94+
}
95+
96+
IamAccountLoginLockout lockout = lockoutOpt.get();
97+
98+
if (isSuspended(lockout)) {
99+
LOG.info("Login blocked: account '{}' is suspended until {}", username,
100+
lockout.getSuspendedUntil());
101+
throw new LockedException(
102+
"Account is temporarily suspended. Please try again later or contact support for assistance.");
103+
}
104+
105+
// Previous suspension has expired; reset the attempt counter for a fresh round
106+
if (lockout.getSuspendedUntil() != null) {
107+
LOG.debug("Suspension for '{}' has expired, starting fresh round", username);
108+
lockout.setFailedAttempts(0);
109+
lockout.setFirstFailureTime(null);
110+
lockout.setSuspendedUntil(null);
111+
lockoutRepo.save(lockout);
112+
}
113+
}
114+
115+
@Override
116+
@Transactional
117+
public void recordFailedAttempt(String username) {
118+
119+
if (!properties.isEnabled()) {
120+
return;
121+
}
122+
123+
Optional<IamAccount> accountOpt = accountRepo.findByUsername(username);
124+
125+
if (accountOpt.isEmpty()) {
126+
return;
127+
}
128+
129+
IamAccount account = accountOpt.get();
130+
131+
if (!account.isActive()) {
132+
return;
133+
}
134+
135+
IamAccountLoginLockout lockout = lockoutRepo.findByAccountUsername(username)
136+
.orElseGet(() -> new IamAccountLoginLockout(account));
137+
138+
if (isSuspended(lockout)) {
139+
return;
140+
}
141+
142+
// if a previous suspension has expired but checkLockout was not called,
143+
// reset the counter so we don't carry over stale failedAttempts from the prior round.
144+
if (lockout.getSuspendedUntil() != null) {
145+
lockout.setFailedAttempts(0);
146+
lockout.setFirstFailureTime(null);
147+
lockout.setSuspendedUntil(null);
148+
}
149+
150+
Date now = new Date();
151+
152+
if (lockout.getFailedAttempts() == 0) {
153+
lockout.setFirstFailureTime(now);
154+
}
155+
156+
lockout.setFailedAttempts(lockout.getFailedAttempts() + 1);
157+
158+
LOG.info("Failed login attempt {} of {} for account '{}'", lockout.getFailedAttempts(),
159+
properties.getMaxFailedAttempts(), username);
160+
161+
if (lockout.getFailedAttempts() >= properties.getMaxFailedAttempts()) {
162+
163+
lockout.setLockoutCount(lockout.getLockoutCount() + 1);
164+
165+
if (lockout.getLockoutCount() > properties.getMaxConcurrentFailures()) {
166+
// All suspension rounds exhausted; disable the account and clean up
167+
account.setActive(false);
168+
accountRepo.save(account);
169+
lockoutRepo.delete(lockout);
170+
LOG.warn("Account '{}' disabled after {} suspension rounds", username,
171+
properties.getMaxConcurrentFailures());
172+
return;
173+
}
174+
175+
// Suspend for the configured duration
176+
long suspendUntilMs = now.getTime() + ((long) properties.getLockoutMinutes() * 60 * 1000);
177+
lockout.setSuspendedUntil(new Date(suspendUntilMs));
178+
179+
LOG.warn("Account '{}' suspended until {} (round {} of {})", username,
180+
lockout.getSuspendedUntil(), lockout.getLockoutCount(),
181+
properties.getMaxConcurrentFailures());
182+
}
183+
184+
lockoutRepo.save(lockout);
185+
}
186+
187+
@Override
188+
@Transactional
189+
public void resetFailedAttempts(String username) {
190+
191+
if (!properties.isEnabled()) {
192+
return;
193+
}
194+
195+
lockoutRepo.findByAccountUsername(username).ifPresent(lockout -> {
196+
lockoutRepo.delete(lockout);
197+
LOG.debug("Lockout record deleted for account '{}'", username);
198+
});
199+
}
200+
201+
private boolean isSuspended(IamAccountLoginLockout lockout) {
202+
return lockout.getSuspendedUntil() != null
203+
&& System.currentTimeMillis() < lockout.getSuspendedUntil().getTime();
204+
}
205+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package it.infn.mw.iam.authn.lockout;
17+
18+
/**
19+
* Tracks failed login attempts and enforces temporary suspensions and permanent account disabling.
20+
*
21+
* Password failures and TOTP failures share the same counter. The lifecycle:
22+
*
23+
* User fails {@code max-failed-attempts} times => suspended for {@code lockout-minutes}
24+
* Suspension expires => counter resets, user gets another round of attempts
25+
* After {@code max-concurrent-failures} suspension rounds, the next round of failures
26+
* disables the account ({@code active = false}) and the lockout row is deleted
27+
* An admin can re-enable the account since the lockout row is gone, the user starts fresh
28+
*/
29+
public interface LoginLockoutService {
30+
31+
/**
32+
* Throws {@link org.springframework.security.authentication.LockedException} if the account
33+
* is currently suspended. If a previous suspension has expired, silently resets the attempt
34+
* counter for a fresh round.
35+
*/
36+
void checkIamAccountLockout(String username);
37+
38+
/**
39+
* Records a single failed attempt (password or TOTP). When the attempt count reaches the
40+
* threshold the account is suspended. When all suspension rounds are exhausted the account
41+
* is disabled and the lockout row is deleted.
42+
*/
43+
void recordFailedAttempt(String username);
44+
45+
/**
46+
* Deletes the lockout row for the given username. Called after a fully successful
47+
* authentication (password-only login, or TOTP verification).
48+
*/
49+
void resetFailedAttempts(String username);
50+
}

iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorTotpCheckProvider.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService;
2929
import it.infn.mw.iam.authn.AbstractExternalAuthenticationToken;
30+
import it.infn.mw.iam.authn.lockout.LoginLockoutService;
3031
import it.infn.mw.iam.core.ExtendedAuthenticationToken;
3132
import it.infn.mw.iam.core.user.exception.MfaSecretNotFoundException;
3233
import it.infn.mw.iam.persistence.model.IamAccount;
@@ -40,11 +41,13 @@ public class MultiFactorTotpCheckProvider implements AuthenticationProvider {
4041

4142
private final IamAccountRepository accountRepo;
4243
private final IamTotpMfaService totpMfaService;
44+
private final LoginLockoutService lockoutService;
4345

4446
public MultiFactorTotpCheckProvider(IamAccountRepository accountRepo,
45-
IamTotpMfaService totpMfaService) {
47+
IamTotpMfaService totpMfaService, LoginLockoutService lockoutService) {
4648
this.accountRepo = accountRepo;
4749
this.totpMfaService = totpMfaService;
50+
this.lockoutService = lockoutService;
4851
}
4952

5053
@Override
@@ -62,13 +65,21 @@ private Authentication processAuthentication(Authentication authentication) {
6265
return null;
6366
}
6467

65-
IamAccount account = accountRepo.findByUsername(authentication.getName())
68+
String username = authentication.getName();
69+
70+
lockoutService.checkIamAccountLockout(username);
71+
72+
IamAccount account = accountRepo.findByUsername(username)
6673
.orElseThrow(() -> new BadCredentialsException("Invalid login details"));
6774

6875
if (!isValidTotp(account, totp)) {
76+
lockoutService.recordFailedAttempt(username);
6977
throw new BadCredentialsException("Bad TOTP");
7078
}
7179

80+
// TOTP verified; full authentication achieved. Clear lockout state.
81+
lockoutService.resetFailedAttempts(username);
82+
7283
return createSuccessfulAuthentication(authentication);
7384
}
7485

0 commit comments

Comments
 (0)