Commit f6532a64 by linxu

feat(security): add account lockout management and concurrent session control

Add admin-only /lockedUsers and /unlock endpoints to view and unlock
brute-force-locked accounts, gated by self.login.admin-user property.

Add optional concurrent login constraint (maximumSessions=1) enabled via
self.concurrent-login-constraint.enabled, returning a JSON ApiError when
a session expires due to a login from another location.

Make ApiError and its constructor public for reuse by the session
expired strategy.
parent c51bc65a
package com.keymobile.sso.api;
import com.keymobile.sso.logging.LogConstants;
import com.keymobile.sso.logging.LogManager;
import com.keymobile.sso.security.LoginAttemptService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
......@@ -16,6 +24,14 @@ import java.util.Map;
@RequestMapping(value = "/")
public class LoginManagement {
private static final String HINT_AUTHENTICATION_IS_NULL = "Authentication is null.";
@Value("${self.login.admin-user:root}")
private String adminUser;
@Autowired
private LoginAttemptService loginAttemptService;
@RequestMapping(value = "/sessionInfo", method = {RequestMethod.POST, RequestMethod.GET})
public @ResponseBody Map<String,Object> verifyLogin(HttpServletRequest request, HttpServletResponse response) {
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
......@@ -45,4 +61,57 @@ public class LoginManagement {
return session.getAttribute(Constants.Session_Lang).toString();
}
@RequestMapping(value = "/lockedUsers", method = RequestMethod.GET)
public @ResponseBody Map<String, Object> lockedUsers(HttpServletResponse response) {
boolean isAdmin = getCurrentUserName().equals(adminUser);
Map<String, Object> rs = new HashMap<>();
if (!isAdmin) {
response.setStatus(HttpStatus.FORBIDDEN.value());
rs.put("status", HttpStatus.FORBIDDEN.value());
rs.put("message", "Admin role required");
rs.put("cnMessage", "需要管理员权限");
return rs;
}
rs.put("status", HttpStatus.OK.value());
rs.put("users", loginAttemptService.getLockedUsers());
return rs;
}
@RequestMapping(value = "/unlock", method = RequestMethod.POST)
public @ResponseBody Map<String, Object> unlock(HttpServletResponse response,
@RequestParam(value = "username", required = true) String username) {
String currentUser = getCurrentUserName();
boolean isAdmin = getCurrentUserName().equals(adminUser);
Map<String, Object> rs = new HashMap<>();
if (!isAdmin) {
LogManager.logWarning(LogConstants.CTX_AUDIT, "Unauthorized unlock attempt by " + currentUser + " for user: " + username);
response.setStatus(HttpStatus.FORBIDDEN.value());
rs.put("status", HttpStatus.FORBIDDEN.value());
rs.put("message", "Admin role required");
rs.put("cnMessage", "需要管理员权限");
return rs;
}
boolean wasLocked = loginAttemptService.unlock(username);
LogManager.logWarning(LogConstants.CTX_AUDIT, "Admin " + currentUser + " unlocked account: " + username);
rs.put("status", HttpStatus.OK.value());
rs.put("username", username);
rs.put("wasLocked", wasLocked);
return rs;
}
private String getCurrentUserName() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Assert.notNull(authentication, HINT_AUTHENTICATION_IS_NULL);
Object principal = authentication.getPrincipal();
Assert.notNull(principal, "Principal is null.");
if (principal.toString().equals("anonymousUser")) {
return null;
} else {
UserDetails userDetails = (UserDetails) principal;
String userNameWithIdAttached = userDetails.getUsername();
return userNameWithIdAttached.split(":")[0];
}
}
}
package com.keymobile.sso.conf;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.keymobile.sso.exception.ApiError;
import com.keymobile.sso.logging.LogConstants;
import com.keymobile.sso.logging.LogManager;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.web.session.SessionInformationExpiredEvent;
import org.springframework.security.web.session.SessionInformationExpiredStrategy;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class ConcurrentSessionExpiredStrategy implements SessionInformationExpiredStrategy {
private static final String MESSAGE = "Session expired: this account signed in from another location";
private static final String CN_MESSAGE = "该账号已在其他地方登录,您已被迫下线";
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException {
HttpServletResponse response = event.getResponse();
LogManager.logInfo(LogConstants.CTX_AUDIT, MESSAGE);
ApiError apiError = new ApiError(HttpStatus.UNAUTHORIZED, MESSAGE, CN_MESSAGE, null);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(apiError));
}
}
......@@ -2,6 +2,7 @@ package com.keymobile.sso.conf;
import com.keymobile.sso.security.LoginAttemptFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
......@@ -15,6 +16,9 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
@EnableWebSecurity
public class SsoSecurityConfig {
@Value("${self.concurrent-login-constraint.enabled:false}")
private boolean isConstraintEnabled;
@Autowired
private RESTAuthenticationEntryPoint authenticationEntryPoint;
@Autowired
......@@ -25,6 +29,8 @@ public class SsoSecurityConfig {
private RESTLogoutSuccessHandler logoutSuccessHandler;
@Autowired
private LoginAttemptFilter loginAttemptFilter;
@Autowired
private ConcurrentSessionExpiredStrategy concurrentSessionExpiredStrategy;
@Bean
public PasswordEncoder passwordEncoder() {
......@@ -61,6 +67,13 @@ public class SsoSecurityConfig {
logout.logoutSuccessHandler(logoutSuccessHandler);
});
http.addFilterBefore(loginAttemptFilter, UsernamePasswordAuthenticationFilter.class);
if (isConstraintEnabled) {
http.sessionManagement((sessionManagement) -> {
sessionManagement.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.expiredSessionStrategy(concurrentSessionExpiredStrategy);
});
}
return http.build();
}
......
......@@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.springframework.http.HttpStatus;
@JsonTypeInfo(include = JsonTypeInfo.As.WRAPPER_OBJECT, use = JsonTypeInfo.Id.NAME, property = "error", visible = true)
class ApiError {
public class ApiError {
private HttpStatus status;
private Long timestamp;
......@@ -31,7 +31,7 @@ class ApiError {
this.message = message;
}
ApiError(HttpStatus status, String message, String cnMessage, Throwable ex) {
public ApiError(HttpStatus status, String message, String cnMessage, Throwable ex) {
this();
this.status = status;
this.message = message;
......
......@@ -5,6 +5,8 @@ import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
......@@ -31,6 +33,30 @@ public class LoginAttemptService {
attempts.remove(key);
}
public boolean unlock(String key) {
LoginAttempt attempt = attempts.get(key);
if (attempt == null) {
return false;
}
attempts.remove(key);
return attempt.isLocked();
}
public List<String> getLockedUsers() {
List<String> lockedUsers = new ArrayList<>();
for (Map.Entry<String, LoginAttempt> entry : attempts.entrySet()) {
LoginAttempt attempt = entry.getValue();
if (attempt.isLocked()) {
if (attempt.getLockTime().plus(lockoutDurationMinutes, ChronoUnit.MINUTES).isBefore(LocalDateTime.now())) {
attempts.remove(entry.getKey());
} else {
lockedUsers.add(entry.getKey());
}
}
}
return lockedUsers;
}
public boolean isBlocked(String key) {
LoginAttempt attempt = attempts.get(key);
if (attempt == null) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment