Commit 9a444b5c by xieshaohua

Merge remote-tracking branch 'refs/remotes/origin/product-v2' into product-v2-hnrcb

# Conflicts:
#	pom.xml
parents d36b5cd7 b291e91d
...@@ -21,7 +21,9 @@ Java Spring Boot SSO (Single Sign-On) authentication service. Part of the KeyMob ...@@ -21,7 +21,9 @@ Java Spring Boot SSO (Single Sign-On) authentication service. Part of the KeyMob
- Java 17 - Java 17
- Spring Boot (via parent POM) - Spring Boot (via parent POM)
- Spring Security - Spring Security
- Spring Data JPA (from parent POM) over the existing MySQL datasource
- Spring Cloud (Eureka discovery client) - Spring Cloud (Eureka discovery client)
- springdoc-openapi 2.5.0 (Swagger UI)
- Maven - Maven
- Apache Commons Lang - Apache Commons Lang
- Jasypt for property encryption - Jasypt for property encryption
...@@ -80,8 +82,18 @@ Java Spring Boot SSO (Single Sign-On) authentication service. Part of the KeyMob ...@@ -80,8 +82,18 @@ Java Spring Boot SSO (Single Sign-On) authentication service. Part of the KeyMob
- Use `@RestController` with `@RequestMapping` - Use `@RestController` with `@RequestMapping`
- Session info endpoint: `/sessionInfo` - Session info endpoint: `/sessionInfo`
- Language endpoint: `/lang` - Language endpoint: `/lang`
- Login info endpoint: `/loginInfo?userId=` (admin only) - accepts multiple user ids (repeated param or comma-separated), returns last login time, current locked state/lock time per user
- Principal format: `username:userId:displayName` (colon-delimited) - Principal format: `username:userId:displayName` (colon-delimited)
### Login Records
- `com.keymobile.sso.login.UserLoginRecord` JPA entity persisted in table `user_login_record` in the existing MySQL datasource (see `application-default.yml`); keyed by `userId`
- Only the last login time is persisted (recorded in `AuthenticationSuccessListener`); lock state/lock time stays in-memory in `LoginAttemptService` (`getLockTime`) and is NOT stored in the database
- JPA `ddl-auto: update` creates/updates the table automatically
### API Documentation
- Swagger UI at `/swagger-ui.html`, OpenAPI JSON at `/v3/api-docs` (both require an authenticated session)
- OpenAPI bean config in `conf/OpenApiConfig.java`; controllers annotated with springdoc/`io.swagger.v3.oas.annotations`
## Testing ## Testing
- No existing test files in the repository - No existing test files in the repository
...@@ -96,5 +108,7 @@ Java Spring Boot SSO (Single Sign-On) authentication service. Part of the KeyMob ...@@ -96,5 +108,7 @@ Java Spring Boot SSO (Single Sign-On) authentication service. Part of the KeyMob
- **No linting tools configured** - no Checkstyle, SpotBugs, or PMD - **No linting tools configured** - no Checkstyle, SpotBugs, or PMD
- **License checking** is baked into auth success handler - **License checking** is baked into auth success handler
- **Hardcoded AES key/IV** in `LicenseChecker` and `LicenseMgr` - **Hardcoded AES key/IV** in `LicenseChecker` and `LicenseMgr`
- **Do NOT add a second DataSource** - `CustomizedUserDetailService` (auth library) injects the single MySQL `DataSource` for authentication
- If the internal nexus is unreachable, build offline: `mvn -o -llr clean package -DskipTests`
- Parent POM version: `product-v1-1.0.4-rc1` - Parent POM version: `product-v1-1.0.4-rc1`
- Auth library version: `product-v2-1.0.3-rc4` - Auth library version: `product-v2-1.0.3-rc4`
...@@ -52,6 +52,12 @@ ...@@ -52,6 +52,12 @@
<artifactId>jasypt-spring-boot-starter</artifactId> <artifactId>jasypt-spring-boot-starter</artifactId>
<version>3.0.3</version> <version>3.0.3</version>
</dependency> </dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
......
...@@ -2,12 +2,18 @@ package com.keymobile.sso.api; ...@@ -2,12 +2,18 @@ package com.keymobile.sso.api;
import com.keymobile.sso.logging.LogConstants; import com.keymobile.sso.logging.LogConstants;
import com.keymobile.sso.logging.LogManager; import com.keymobile.sso.logging.LogManager;
import com.keymobile.sso.login.UserLoginRecord;
import com.keymobile.sso.login.UserLoginRecordService;
import com.keymobile.sso.security.LoginAttemptService; import com.keymobile.sso.security.LoginAttemptService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
...@@ -20,18 +26,21 @@ import java.util.HashMap; ...@@ -20,18 +26,21 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@Tag(name = "Login Management", description = "SSO session, language and account lockout management APIs")
@RestController @RestController
@RequestMapping(value = "/") @RequestMapping(value = "/")
public class LoginManagement { public class LoginManagement {
private static final String HINT_AUTHENTICATION_IS_NULL = "Authentication is null."; private static final String HINT_AUTHENTICATION_IS_NULL = "Authentication is null.";
@Value("${self.login.admin-user:root}")
private String adminUser;
@Autowired @Autowired
private LoginAttemptService loginAttemptService; private LoginAttemptService loginAttemptService;
@Autowired
private UserLoginRecordService userLoginRecordService;
@Operation(summary = "Get current session info",
description = "Returns the authenticated user's name, id, display name, roles and language.")
@RequestMapping(value = "/sessionInfo", method = {RequestMethod.POST, RequestMethod.GET}) @RequestMapping(value = "/sessionInfo", method = {RequestMethod.POST, RequestMethod.GET})
public @ResponseBody Map<String,Object> verifyLogin(HttpServletRequest request, HttpServletResponse response) { public @ResponseBody Map<String,Object> verifyLogin(HttpServletRequest request, HttpServletResponse response) {
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
...@@ -50,8 +59,12 @@ public class LoginManagement { ...@@ -50,8 +59,12 @@ public class LoginManagement {
return rs; return rs;
} }
@Operation(summary = "Set session language",
description = "Sets the session language to 'en' or 'cn' (defaults to 'cn' for other values).")
@RequestMapping(value = "/lang", method = {RequestMethod.POST, RequestMethod.GET}) @RequestMapping(value = "/lang", method = {RequestMethod.POST, RequestMethod.GET})
public String setLANG(HttpServletRequest request, @RequestParam(value = "LANG", required = true) String LANG) { public String setLANG(HttpServletRequest request,
@Parameter(description = "Language code: en or cn", required = true)
@RequestParam(value = "LANG", required = true) String LANG) {
HttpSession session = request.getSession(); HttpSession session = request.getSession();
if (!LANG.equals("en") && !LANG.equals("cn")) if (!LANG.equals("en") && !LANG.equals("cn"))
session.setAttribute(Constants.Session_Lang, "cn"); session.setAttribute(Constants.Session_Lang, "cn");
...@@ -61,44 +74,65 @@ public class LoginManagement { ...@@ -61,44 +74,65 @@ public class LoginManagement {
return session.getAttribute(Constants.Session_Lang).toString(); return session.getAttribute(Constants.Session_Lang).toString();
} }
@Operation(summary = "List currently locked users", description = "Admin only.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "List of locked usernames returned")})
@RequestMapping(value = "/lockedUsers", method = RequestMethod.GET) @RequestMapping(value = "/lockedUsers", method = RequestMethod.GET)
public @ResponseBody Map<String, Object> lockedUsers(HttpServletResponse response) { public @ResponseBody Map<String, Object> lockedUsers(HttpServletResponse response) {
boolean isAdmin = getCurrentUserName().equals(adminUser);
Map<String, Object> rs = new HashMap<>(); 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()); rs.put("users", loginAttemptService.getLockedUsers());
return rs; return rs;
} }
@Operation(summary = "Unlock a user account", description = "Admin only.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Unlock result returned")})
@RequestMapping(value = "/unlock", method = RequestMethod.POST) @RequestMapping(value = "/unlock", method = RequestMethod.POST)
public @ResponseBody Map<String, Object> unlock(HttpServletResponse response, public @ResponseBody Map<String, Object> unlock(HttpServletResponse response,
@Parameter(description = "Username to unlock", required = true)
@RequestParam(value = "username", required = true) String username) { @RequestParam(value = "username", required = true) String username) {
String currentUser = getCurrentUserName(); String currentUser = getCurrentUserName();
boolean isAdmin = getCurrentUserName().equals(adminUser);
Map<String, Object> rs = new HashMap<>(); 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); boolean wasLocked = loginAttemptService.unlock(username);
LogManager.logWarning(LogConstants.CTX_AUDIT, "Admin " + currentUser + " unlocked account: " + username); LogManager.logWarning(LogConstants.CTX_AUDIT, "Admin " + currentUser + " unlocked account: " + username);
rs.put("status", HttpStatus.OK.value());
rs.put("username", username); rs.put("username", username);
rs.put("wasLocked", wasLocked); rs.put("wasLocked", wasLocked);
return rs; return rs;
} }
@Operation(summary = "Get login info for user(s)",
description = "Returns last login time (persisted) and current lock state/lock time (in-memory)."
+ "Accepts multiple user ids (repeated parameter or comma-separated).")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Login info returned")
})
@RequestMapping(value = "/loginInfo", method = RequestMethod.GET)
public @ResponseBody Map<String, Object> loginInfo(HttpServletResponse response,
@Parameter(description = "User id(s) to query; repeat the parameter or use commas for multiple", required = true)
@RequestParam(value = "userId", required = true) List<String> userIds) {
Map<String, Object> rs = new HashMap<>();
for (String userId : userIds) {
Map<String, Object> info = new HashMap<>();
UserLoginRecord record = userLoginRecordService.getRecord(userId);
if (record != null) {
info.put("lastLoginTime", record.getLastLoginTime());
boolean locked = loginAttemptService.isBlocked(record.getUsername());
info.put("locked", locked);
info.put("lockTime", locked ? loginAttemptService.getLockTime(record.getUsername()) : null);
} else {
info.put("lastLoginTime", null);
info.put("locked", false);
info.put("lockTime", null);
}
rs.put(userId, info);
}
return rs;
}
private String getCurrentUserName() { private String getCurrentUserName() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Assert.notNull(authentication, HINT_AUTHENTICATION_IS_NULL); Assert.notNull(authentication, HINT_AUTHENTICATION_IS_NULL);
......
package com.keymobile.sso.conf;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI ssoOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("SSO Login Service API")
.description("Single Sign-On authentication service APIs")
.version("product-v2-rc1"));
}
}
package com.keymobile.sso.login;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "sso_login_record")
public class UserLoginRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false, unique = true)
private String userId;
@Column(name = "username")
private String username;
@Column(name = "last_login_time")
private LocalDateTime lastLoginTime;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public LocalDateTime getLastLoginTime() {
return lastLoginTime;
}
public void setLastLoginTime(LocalDateTime lastLoginTime) {
this.lastLoginTime = lastLoginTime;
}
}
package com.keymobile.sso.login;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserLoginRecordRepository extends JpaRepository<UserLoginRecord, Long> {
UserLoginRecord findByUserId(String userId);
}
package com.keymobile.sso.login;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
public class UserLoginRecordService {
@Autowired
private UserLoginRecordRepository repository;
@Transactional
public synchronized void recordLogin(String userId, String username) {
UserLoginRecord record = getOrCreate(userId);
record.setUsername(username);
record.setLastLoginTime(LocalDateTime.now());
repository.save(record);
}
public UserLoginRecord getRecord(String userId) {
return repository.findByUserId(userId);
}
private UserLoginRecord getOrCreate(String userId) {
UserLoginRecord record = repository.findByUserId(userId);
if (record == null) {
record = new UserLoginRecord();
record.setUserId(userId);
}
return record;
}
}
...@@ -2,6 +2,7 @@ package com.keymobile.sso.security; ...@@ -2,6 +2,7 @@ package com.keymobile.sso.security;
import com.keymobile.sso.logging.LogConstants; import com.keymobile.sso.logging.LogConstants;
import com.keymobile.sso.logging.LogManager; import com.keymobile.sso.logging.LogManager;
import com.keymobile.sso.login.UserLoginRecordService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener; import org.springframework.context.ApplicationListener;
import org.springframework.security.authentication.event.AuthenticationSuccessEvent; import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
...@@ -13,10 +14,17 @@ public class AuthenticationSuccessListener implements ApplicationListener<Authen ...@@ -13,10 +14,17 @@ public class AuthenticationSuccessListener implements ApplicationListener<Authen
@Autowired @Autowired
private LoginAttemptService loginAttemptService; private LoginAttemptService loginAttemptService;
@Autowired
private UserLoginRecordService userLoginRecordService;
@Override @Override
public void onApplicationEvent(AuthenticationSuccessEvent event) { public void onApplicationEvent(AuthenticationSuccessEvent event) {
String username = event.getAuthentication().getName(); String username = event.getAuthentication().getName();
loginAttemptService.loginSucceeded(username); loginAttemptService.loginSucceeded(username);
String[] parts = username.split(":", 3);
if (parts.length >= 2) {
userLoginRecordService.recordLogin(parts[1], parts[0]);
}
LogManager.logInfo(LogConstants.CTX_AUDIT, "Successful login for user: " + username + ", cleared failed attempts"); LogManager.logInfo(LogConstants.CTX_AUDIT, "Successful login for user: " + username + ", cleared failed attempts");
} }
} }
...@@ -92,6 +92,14 @@ public class LoginAttemptService { ...@@ -92,6 +92,14 @@ public class LoginAttemptService {
return Math.max(0, maxAttempts - attempt.getAttempts()); return Math.max(0, maxAttempts - attempt.getAttempts());
} }
public LocalDateTime getLockTime(String key) {
LoginAttempt attempt = attempts.get(key);
if (attempt == null || !attempt.isLocked()) {
return null;
}
return attempt.getLockTime();
}
private static class LoginAttempt { private static class LoginAttempt {
private int attempts; private int attempts;
private LocalDateTime firstAttemptTime; private LocalDateTime firstAttemptTime;
......
...@@ -13,6 +13,9 @@ spring: ...@@ -13,6 +13,9 @@ spring:
url: jdbc:mysql://mysql0:3306/d0?autoReconnect=true&useUnicode=true&characterEncoding=UTF-8&useSsl=false url: jdbc:mysql://mysql0:3306/d0?autoReconnect=true&useUnicode=true&characterEncoding=UTF-8&useSsl=false
username: user0 username: user0
password: password0 password: password0
jpa:
hibernate:
ddl-auto: update
eureka: eureka:
client: client:
......
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