Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package it.infn.mw.iam.api.account.lockout;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

import it.infn.mw.iam.authn.lockout.LoginLockoutService;
import it.infn.mw.iam.persistence.model.IamAccountLoginLockout;
import it.infn.mw.iam.persistence.repository.IamAccountLoginLockoutRepository;

@RestController
public class AccountLockoutController {

private final IamAccountLoginLockoutRepository lockoutRepo;
private final LoginLockoutService lockoutService;

public AccountLockoutController(IamAccountLoginLockoutRepository lockoutRepo,
LoginLockoutService lockoutService) {
this.lockoutRepo = lockoutRepo;
this.lockoutService = lockoutService;
}

@GetMapping("/iam/account/{uuid}/lockout")
@PreAuthorize("#iam.hasScope('iam:admin.read') or #iam.hasDashboardRole('ROLE_ADMIN')")
public ResponseEntity<Map<String, Object>> getLockoutStatus(@PathVariable String uuid) {
Optional<IamAccountLoginLockout> lockout = lockoutRepo.findByAccountUuid(uuid);

if (lockout.isPresent() && lockout.get().getSuspendedUntil() != null
&& Instant.now().isBefore(lockout.get().getSuspendedUntil().toInstant())) {
return ResponseEntity.ok(Map.of(
"suspended", true,
"suspendedUntil", lockout.get().getSuspendedUntil().getTime()));
}

return ResponseEntity.ok(Map.of("suspended", false));
}

@GetMapping("/iam/account/lockout/suspended")
@PreAuthorize("#iam.hasScope('iam:admin.read') or #iam.hasDashboardRole('ROLE_ADMIN')")
public ResponseEntity<List<String>> getAllSuspendedUsers() {
List<String> suspendedUsers = lockoutRepo.findAllSuspendedUsers();
return ResponseEntity.ok(suspendedUsers);
}

@DeleteMapping("/iam/account/{uuid}/lockout")
@PreAuthorize("#iam.hasScope('iam:admin.write') or #iam.hasDashboardRole('ROLE_ADMIN')")
public ResponseEntity<Map<String, Object>> revokeLockout(@PathVariable String uuid) {
lockoutService.adminRevokeLockout(uuid);
return ResponseEntity.ok(Map.of("unlocked", true));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/**
* Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package it.infn.mw.iam.authn.lockout;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Optional;

import javax.annotation.PostConstruct;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.LockedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import it.infn.mw.iam.config.IamProperties;
import it.infn.mw.iam.config.IamProperties.LoginLockoutProperties;
import it.infn.mw.iam.persistence.model.IamAccount;
import it.infn.mw.iam.persistence.model.IamAccountLoginLockout;
import it.infn.mw.iam.persistence.repository.IamAccountRepository;
import it.infn.mw.iam.persistence.repository.IamAccountLoginLockoutRepository;

@Service
public class DefaultLoginLockoutService implements LoginLockoutService {

private static final Logger LOG = LoggerFactory.getLogger(DefaultLoginLockoutService.class);

private final IamAccountLoginLockoutRepository lockoutRepo;
private final IamAccountRepository accountRepo;
private final LoginLockoutProperties properties;

public DefaultLoginLockoutService(IamAccountLoginLockoutRepository lockoutRepo,
IamAccountRepository accountRepo, IamProperties iamProperties) {
this.lockoutRepo = lockoutRepo;
this.accountRepo = accountRepo;
this.properties = iamProperties.getLoginLockout();
}

@PostConstruct
void validateConfiguration() {
if (!properties.isEnabled()) {
LOG.info("Iam Account Login lockout feature is disabled");
return;
}

if (properties.getMaxFailedAttempts() < 1) {
throw new IllegalStateException(
"iam.login-lockout.max-failed-attempts must be >= 1. "
+ "Please provide the maximum number of failed login attempts allowed before suspension.");
}

if (properties.getLockoutMinutes() < 1) {
throw new IllegalStateException(
"iam.login-lockout.lockout-minutes must be >= 1. "
+ "Please provide the suspension duration in minutes.");
}

if (properties.isDisableAfterMaxFailures() && properties.getMaxConcurrentFailures() < 1) {
throw new IllegalStateException(
"iam.login-lockout.max-concurrent-failures must be >= 1 when disable-after-max-failures is true. "
+ "Please provide the maximum number of suspension rounds allowed before the account is permanently disabled.");
}

LOG.info("Iam Account Login lockout enabled: max-failed-attempts={}, lockout-minutes={}, "
+ "max-concurrent-failures={}, disable-after-max-failures={}",
properties.getMaxFailedAttempts(), properties.getLockoutMinutes(),
properties.getMaxConcurrentFailures(), properties.isDisableAfterMaxFailures());
}

@Override
@Transactional
public void checkIamAccountLockout(String username) {

if (!properties.isEnabled()) {
return;
}

Optional<IamAccountLoginLockout> lockoutOpt = lockoutRepo.findByAccountUsername(username);

if (lockoutOpt.isEmpty()) {
return;
}

IamAccountLoginLockout lockout = lockoutOpt.get();

if (isSuspended(lockout)) {
LOG.info("Login blocked: account '{}' is suspended until {}", username,
lockout.getSuspendedUntil());
throw new LockedException(
"Account is temporarily suspended. Please try again later or contact support for assistance.");
}

// Previous suspension has expired; reset the attempt counter for a fresh round
if (lockout.getSuspendedUntil() != null) {
LOG.debug("Suspension for '{}' has expired, starting fresh round", username);
lockout.setFailedAttempts(0);
lockout.setFirstFailureTime(null);
lockout.setSuspendedUntil(null);
lockoutRepo.save(lockout);
}
}

@Override
@Transactional
public void recordFailedAttempt(String username) {

if (!properties.isEnabled()) {
return;
}

Optional<IamAccount> accountOpt = accountRepo.findByUsername(username);

if (accountOpt.isEmpty()) {
return;
}

IamAccount account = accountOpt.get();

if (!account.isActive()) {
return;
}

IamAccountLoginLockout lockout = lockoutRepo.findByAccountUsername(username)
.orElseGet(() -> new IamAccountLoginLockout(account));

if (isSuspended(lockout)) {
return;
}

// if a previous suspension has expired but checkIamAccountLockout was not called,
// reset the counter so we don't carry over stale failedAttempts from the prior round.
if (lockout.getSuspendedUntil() != null) {
lockout.setFailedAttempts(0);
lockout.setFirstFailureTime(null);
lockout.setSuspendedUntil(null);
}

Instant now = Instant.now();

if (lockout.getFailedAttempts() == 0) {
lockout.setFirstFailureTime(Date.from(now));
}

lockout.setFailedAttempts(lockout.getFailedAttempts() + 1);

LOG.info("Failed login attempt {} of {} for account '{}'", lockout.getFailedAttempts(),
properties.getMaxFailedAttempts(), username);

if (lockout.getFailedAttempts() >= properties.getMaxFailedAttempts()) {

lockout.setLockoutCount(lockout.getLockoutCount() + 1);

if (properties.isDisableAfterMaxFailures()
&& lockout.getLockoutCount() > properties.getMaxConcurrentFailures()) {
// All suspension rounds exhausted; disable the account and clean up
account.setActive(false);
accountRepo.save(account);
lockoutRepo.delete(lockout);
LOG.warn("Account '{}' disabled after {} suspension rounds", username,
properties.getMaxConcurrentFailures());
return;
}

// Suspend for the configured duration
Instant suspendUntil = now.plus(properties.getLockoutMinutes(), ChronoUnit.MINUTES);
lockout.setSuspendedUntil(Date.from(suspendUntil));

LOG.warn("Account '{}' suspended until {} (round {} of {})", username,
lockout.getSuspendedUntil(), lockout.getLockoutCount(),
properties.getMaxConcurrentFailures());
}

lockoutRepo.save(lockout);
}

@Override
@Transactional
public void resetFailedAttempts(String username) {

if (!properties.isEnabled()) {
return;
}

lockoutRepo.findByAccountUsername(username).ifPresent(lockout -> {
lockoutRepo.delete(lockout);
LOG.debug("Lockout record deleted for account '{}'", username);
});
}

@Override
@Transactional
public void adminRevokeLockout(String accountUuid) {

lockoutRepo.findByAccountUuid(accountUuid).ifPresent(lockout -> {
lockoutRepo.delete(lockout);
LOG.info("Admin revoked suspension for account '{}'", lockout.getAccount().getUsername());
});
}

private boolean isSuspended(IamAccountLoginLockout lockout) {
return lockout.getSuspendedUntil() != null
&& Instant.now().isBefore(lockout.getSuspendedUntil().toInstant());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package it.infn.mw.iam.authn.lockout;

/**
* Tracks failed login attempts and enforces temporary suspensions
* and permanent account disabling, if `disable-after-max-failures` enabled.
*
* Password failures and TOTP failures share the same counter. The lifecycle:
*
* User fails {max-failed-attempts} times => suspended for {lockout-minutes}.
* Suspension expires => counter resets, user gets another round of attempts.
* If {disable-after-max-failures} is true:
* after {max-concurrent-failures} suspension rounds the account is disabled.
* If false (default): the account is never disabled, only repeatedly suspended.
* An admin can clear a lockout at any time.
*/
public interface LoginLockoutService {

/**
* Throws {@link org.springframework.security.authentication.LockedException} if the account
* is currently suspended. If a previous suspension has expired, silently resets the attempt
* counter for a fresh round.
*/
void checkIamAccountLockout(String username);

/**
* Records a single failed attempt (password or TOTP). When the attempt count reaches the
* threshold the account is suspended. When all suspension rounds are exhausted and
* disable-after-max-failures is true, the account is disabled and the lockout row is deleted.
*/
void recordFailedAttempt(String username);

/**
* Deletes the lockout row for the given username. Called after a fully successful
* authentication (password-only login, or TOTP verification).
*/
void resetFailedAttempts(String username);

/**
* Admin-triggered unlock. Deletes the lockout row for the given account,
* clearing any active suspension.
*/
void adminRevokeLockout(String accountUuid);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

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

private final IamAccountRepository accountRepo;
private final IamTotpMfaService totpMfaService;
private final LoginLockoutService lockoutService;

public MultiFactorTotpCheckProvider(IamAccountRepository accountRepo,
IamTotpMfaService totpMfaService) {
IamTotpMfaService totpMfaService, LoginLockoutService lockoutService) {
this.accountRepo = accountRepo;
this.totpMfaService = totpMfaService;
this.lockoutService = lockoutService;
}

@Override
Expand All @@ -62,13 +65,21 @@ private Authentication processAuthentication(Authentication authentication) {
return null;
}

IamAccount account = accountRepo.findByUsername(authentication.getName())
String username = authentication.getName();

lockoutService.checkIamAccountLockout(username);

IamAccount account = accountRepo.findByUsername(username)
.orElseThrow(() -> new BadCredentialsException("Invalid login details"));

if (!isValidTotp(account, totp)) {
lockoutService.recordFailedAttempt(username);
throw new BadCredentialsException("Bad TOTP");
}

// TOTP verified; full authentication achieved. Clear lockout state.
lockoutService.resetFailedAttempts(username);

return createSuccessfulAuthentication(authentication);
}

Expand Down
Loading
Loading