Commit 96a604bf by linxu

feat(security): add admin unlock endpoints and shared session management

Add /lockedUsers and /unlock admin-only endpoints backed by
LoginAttemptService unlock/getLockedUsers, wire shared session
management configurer for session limiting, and serve an unlock.html
admin page. Bump parent/config/crypto to product-v1-1.1.0-beta2 and
manage config/crypto versions via dependencyManagement.
parent c51bc65a
......@@ -11,13 +11,13 @@
<parent>
<groupId>com.keymobile</groupId>
<artifactId>parent</artifactId>
<version>product-v1-1.0.4-rc1</version>
<version>product-v1-1.1.0-beta2</version>
</parent>
<properties>
<auth.version>product-v2-1.0.3-rc4</auth.version>
<config.version>product-v1-1.0.4-rc1</config.version>
<crypto.version>product-v1-1.0.4-rc1</crypto.version>
<config.version>product-v1-1.1.0-beta2</config.version>
<crypto.version>product-v1-1.1.0-beta2</crypto.version>
</properties>
<dependencies>
......@@ -39,12 +39,10 @@
<dependency>
<groupId>com.keymobile</groupId>
<artifactId>config</artifactId>
<version>${config.version}</version>
</dependency>
<dependency>
<groupId>com.keymobile</groupId>
<artifactId>crypto</artifactId>
<version>${crypto.version}</version>
</dependency>
<dependency>
......@@ -54,6 +52,21 @@
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.keymobile</groupId>
<artifactId>config</artifactId>
<version>${config.version}</version>
</dependency>
<dependency>
<groupId>com.keymobile</groupId>
<artifactId>crypto</artifactId>
<version>${crypto.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<finalName>sso</finalName>
......
......@@ -12,7 +12,8 @@ import org.springframework.context.annotation.PropertySource;
@EnableDiscoveryClient
@ComponentScan(basePackages = {"com.keymobile.sso",
"com.keymobile.config.logging", "com.keymobile.config.naming",
"com.keymobile.config.redisclient", "com.keymobile.authservice.component"}, excludeFilters = {
"com.keymobile.config.redisclient", "com.keymobile.config.session",
"com.keymobile.authservice.component"}, excludeFilters = {
@ComponentScan.Filter(type= FilterType.ASSIGNABLE_TYPE, value= SecurityConfig.class)
})
@PropertySource(value = "classpath:/application.yml")
......
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];
}
}
}
......@@ -4,13 +4,15 @@ import com.keymobile.sso.security.LoginAttemptFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SsoSecurityConfig {
......@@ -25,6 +27,8 @@ public class SsoSecurityConfig {
private RESTLogoutSuccessHandler logoutSuccessHandler;
@Autowired
private LoginAttemptFilter loginAttemptFilter;
@Autowired
private Customizer<SessionManagementConfigurer<HttpSecurity>> sharedSessionManagement;
@Bean
public PasswordEncoder passwordEncoder() {
......@@ -60,6 +64,8 @@ public class SsoSecurityConfig {
logout.logoutUrl("/signout");
logout.logoutSuccessHandler(logoutSuccessHandler);
});
http.sessionManagement(sharedSessionManagement);
http.addFilterBefore(loginAttemptFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
......
......@@ -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) {
......
......@@ -33,4 +33,5 @@ self:
login:
max-attempts: 5
lockout-duration: 30
attempt-window: 15
\ No newline at end of file
attempt-window: 15
admin-role: ROLE_ADMIN
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unlock Locked Users</title>
<style>
body {
font-family: Arial, "Microsoft YaHei", sans-serif;
max-width: 640px;
margin: 40px auto;
padding: 0 16px;
color: #333;
}
h1 {
font-size: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
}
th, td {
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
}
th {
background: #f5f5f5;
}
button {
padding: 6px 14px;
border: 1px solid #337ab7;
background: #337ab7;
color: #fff;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
.manual {
margin-top: 24px;
display: flex;
gap: 8px;
}
.manual input {
flex: 1;
padding: 6px 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
#message {
margin-top: 16px;
padding: 10px 12px;
border-radius: 4px;
display: none;
}
#message.ok {
display: block;
background: #e6f4e6;
border: 1px solid #8cc08c;
color: #2e6b2e;
}
#message.err {
display: block;
background: #fbeaea;
border: 1px solid #d99;
color: #8a2b2b;
}
.empty {
color: #888;
margin-top: 12px;
}
</style>
</head>
<body>
<h1>Locked Users / 已锁定用户</h1>
<div id="list">
<p class="empty">Loading...</p>
</div>
<div class="manual">
<input type="text" id="usernameInput" placeholder="Username to unlock / 输入要解锁的用户名">
<button id="unlockManual">Unlock / 解锁</button>
</div>
<div id="message"></div>
<script>
function showMessage(text, ok) {
var el = document.getElementById("message");
el.textContent = text;
el.className = ok ? "ok" : "err";
}
function loadLockedUsers() {
fetch("/lockedUsers", { method: "GET", credentials: "same-origin" })
.then(function (res) { return res.json(); })
.then(function (data) {
var list = document.getElementById("list");
if (data.status === 403) {
list.innerHTML = '<p class="empty">' + (data.message || "Admin role required") + '</p>';
return;
}
var users = data.users || [];
if (users.length === 0) {
list.innerHTML = '<p class="empty">No locked users / 没有锁定的用户</p>';
return;
}
var html = "<table><tr><th>Username / 用户名</th><th>Action / 操作</th></tr>";
users.forEach(function (u) {
html += "<tr><td>" + escapeHtml(u) + "</td>" +
'<td><button onclick="unlockUser(\'' + escapeJs(u) + '\')">Unlock / 解锁</button></td></tr>';
});
html += "</table>";
list.innerHTML = html;
})
.catch(function () {
document.getElementById("list").innerHTML = '<p class="empty">Failed to load / 加载失败</p>';
});
}
function unlockUser(username) {
if (!username) {
showMessage("Please enter a username / 请输入用户名", false);
return;
}
fetch("/unlock?username=" + encodeURIComponent(username), { method: "POST", credentials: "same-origin" })
.then(function (res) { return res.json(); })
.then(function (data) {
if (data.status === 200) {
showMessage("Unlocked " + username + " (wasLocked: " + data.wasLocked + ")", true);
} else {
showMessage((data.message || "Unlock failed") + " " + (data.cnMessage || ""), false);
}
loadLockedUsers();
})
.catch(function () {
showMessage("Request failed / 请求失败", false);
});
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function escapeJs(s) {
return String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
document.getElementById("unlockManual").addEventListener("click", function () {
unlockUser(document.getElementById("usernameInput").value.trim());
});
loadLockedUsers();
</script>
</body>
</html>
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