20260726225220
This commit is contained in:
1 parent
e3c7c6f8da
commit
a8478d60ed
170 files changed
+6786
-1678
No files matched your search
@@ -6,6 +6,7 @@ import cn.g3soft.fmsapi.utils.ApiResponse;
|
||||
import cn.g3soft.fmsapi.utils.ParamUtils;
|
||||
import cn.g3soft.fmsapi.utils.StringUtils;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -24,7 +25,8 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<Map<String, Object>> login(
|
||||
@RequestBody(required = false) Map<String, Object> request
|
||||
@RequestBody(required = false) Map<String, Object> request,
|
||||
HttpServletRequest httpRequest
|
||||
) throws SQLException {
|
||||
String orgId = ParamUtils.getString(request, "orgid");
|
||||
String userId = ParamUtils.getString(request, "userid");
|
||||
@@ -40,7 +42,8 @@ public class AuthController {
|
||||
Optional<Map<String, Object>> loginResponse = authService.login(
|
||||
orgId,
|
||||
userId,
|
||||
password
|
||||
password,
|
||||
httpRequest
|
||||
);
|
||||
if (loginResponse.isEmpty()) {
|
||||
return ApiResponse.fail(
|
||||
|
||||
@@ -3,6 +3,7 @@ package cn.g3soft.fmsapi.service;
|
||||
import cn.g3soft.fmsapi.utils.DbUtils;
|
||||
import cn.g3soft.fmsapi.utils.JwtUtils;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -27,10 +28,14 @@ public class AuthService {
|
||||
@Resource
|
||||
private ActiveSessionRegistry activeSessionRegistry;
|
||||
|
||||
@Resource
|
||||
private LoginLogService loginLogService;
|
||||
|
||||
public Optional<Map<String, Object>> login(
|
||||
String orgId,
|
||||
String userId,
|
||||
String password
|
||||
String password,
|
||||
HttpServletRequest request
|
||||
) throws SQLException {
|
||||
String normalizedOrgId = orgId.trim().toUpperCase(Locale.ROOT);
|
||||
String normalizedUserId = userId.trim();
|
||||
@@ -40,25 +45,36 @@ public class AuthService {
|
||||
);
|
||||
|
||||
if (users.isEmpty()) {
|
||||
loginLogService.recordLogin(
|
||||
normalizedOrgId, normalizedUserId, null,
|
||||
false, "账号不存在", null, request);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Map<String, Object> account = users.getFirst();
|
||||
Map<String, Object> account = users.get(0);
|
||||
String storedPassword = getString(account, "b_password");
|
||||
if (!passwordMatches(storedPassword, password)) {
|
||||
loginLogService.recordLogin(
|
||||
normalizedOrgId, normalizedUserId, null,
|
||||
false, "密码错误", null, request);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String accountId = getString(account, "b_account");
|
||||
long userPrimaryKey = getLong(account, "b_id");
|
||||
String name = getString(account, "b_name");
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
String token = jwtUtils.generateToken(accountId, normalizedOrgId, sessionId);
|
||||
activeSessionRegistry.activate(normalizedOrgId, accountId, sessionId);
|
||||
|
||||
loginLogService.recordLogin(
|
||||
normalizedOrgId, accountId, userPrimaryKey,
|
||||
true, null, sessionId, request);
|
||||
|
||||
Map<String, Object> user = new LinkedHashMap<>();
|
||||
user.put("id", userPrimaryKey);
|
||||
user.put("account", accountId);
|
||||
user.put("name", getString(account, "b_name"));
|
||||
user.put("name", name);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("token", token);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package cn.g3soft.fmsapi.service;
|
||||
|
||||
import cn.g3soft.fmsapi.database.OrgContext;
|
||||
import cn.g3soft.fmsapi.utils.ClientInfo;
|
||||
import cn.g3soft.fmsapi.utils.ClientInfoExtractor;
|
||||
import cn.g3soft.fmsapi.utils.snowflake.idgen.IdGenerator;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 登录日志服务:在登录成功或失败时采集客户端信息,并通过通用 saveobjt 机制写入 s_log_login 表。
|
||||
* 写日志失败不影响登录主流程;机构上下文通过 OrgContext 临时设置并在 finally 中清理。
|
||||
*/
|
||||
@Service
|
||||
public class LoginLogService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LoginLogService.class);
|
||||
|
||||
private final DataSaveService dataSaveService;
|
||||
private final ClientInfoExtractor clientInfoExtractor;
|
||||
|
||||
public LoginLogService(DataSaveService dataSaveService, ClientInfoExtractor clientInfoExtractor) {
|
||||
this.dataSaveService = dataSaveService;
|
||||
this.clientInfoExtractor = clientInfoExtractor;
|
||||
}
|
||||
|
||||
public void recordLogin(
|
||||
String orgId,
|
||||
String account,
|
||||
Long userId,
|
||||
boolean success,
|
||||
String failReason,
|
||||
String sessionId,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
if (orgId == null || orgId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ClientInfo info = clientInfoExtractor.extract(request);
|
||||
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("b_id", IdGenerator.nextId());
|
||||
row.put("b_org_id", orgId);
|
||||
row.put("b_user_id", userId);
|
||||
row.put("b_account", account);
|
||||
row.put("b_login_time", Timestamp.valueOf(LocalDateTime.now()));
|
||||
row.put("b_ip", info.ip());
|
||||
row.put("b_ip_location", info.ipLocation());
|
||||
row.put("b_browser", info.browser());
|
||||
row.put("b_browser_version", info.browserVersion());
|
||||
row.put("b_os", info.os());
|
||||
row.put("b_os_version", info.osVersion());
|
||||
row.put("b_device_type", info.deviceType());
|
||||
row.put("b_user_agent", info.userAgent());
|
||||
row.put("b_login_result", success ? 1 : 0);
|
||||
row.put("b_fail_reason", failReason);
|
||||
row.put("b_session_id", sessionId);
|
||||
|
||||
Map<String, Object> tableOp = new LinkedHashMap<>();
|
||||
tableOp.put("table", "s_log_login");
|
||||
tableOp.put("key_field", "b_id");
|
||||
tableOp.put("inserts", List.of(row));
|
||||
|
||||
OrgContext.setOrgId(orgId);
|
||||
try {
|
||||
dataSaveService.save(List.of(tableOp));
|
||||
} finally {
|
||||
OrgContext.clear();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("写入登录日志失败 orgId={} account={}", orgId, account, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package cn.g3soft.fmsapi.utils;
|
||||
|
||||
/**
|
||||
* 登录时采集到的客户端环境信息载体。
|
||||
*/
|
||||
public record ClientInfo(
|
||||
String ip,
|
||||
String ipLocation,
|
||||
String browser,
|
||||
String browserVersion,
|
||||
String os,
|
||||
String osVersion,
|
||||
String deviceType,
|
||||
String userAgent) {
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package cn.g3soft.fmsapi.utils;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import ua_parser.Client;
|
||||
import ua_parser.Parser;
|
||||
|
||||
/**
|
||||
* 组合 IpUtils、IpRegionSearcher 与 uap-java,从 HttpServletRequest 提取客户端环境信息。
|
||||
*/
|
||||
@Component
|
||||
public class ClientInfoExtractor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ClientInfoExtractor.class);
|
||||
|
||||
private final IpRegionSearcher ipRegionSearcher;
|
||||
|
||||
// uap-java 的 Parser 加载一次正则表达式后即可安全复用(无状态、线程安全)
|
||||
private final Parser uaParser = new Parser();
|
||||
|
||||
public ClientInfoExtractor(IpRegionSearcher ipRegionSearcher) {
|
||||
this.ipRegionSearcher = ipRegionSearcher;
|
||||
}
|
||||
|
||||
public ClientInfo extract(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return new ClientInfo("unknown", "未知", "未知", "", "未知", "", "Unknown", null);
|
||||
}
|
||||
String ip = IpUtils.getClientIp(request);
|
||||
String userAgent = request.getHeader("User-Agent");
|
||||
String ipLocation = ipRegionSearcher.search(ip);
|
||||
|
||||
String browser = "未知";
|
||||
String browserVersion = "";
|
||||
String os = "未知";
|
||||
String osVersion = "";
|
||||
String deviceType = "Unknown";
|
||||
|
||||
if (userAgent != null && !userAgent.isBlank()) {
|
||||
try {
|
||||
Client client = uaParser.parse(userAgent);
|
||||
if (client.userAgent != null) {
|
||||
browser = orDefault(client.userAgent.family, "未知");
|
||||
browserVersion = buildVersion(
|
||||
client.userAgent.major, client.userAgent.minor, client.userAgent.patch);
|
||||
}
|
||||
if (client.os != null) {
|
||||
os = orDefault(client.os.family, "未知");
|
||||
osVersion = buildVersion(client.os.major, client.os.minor, client.os.patch);
|
||||
}
|
||||
if (client.device != null) {
|
||||
deviceType = deriveDeviceType(client.device.family, userAgent);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("User-Agent 解析失败: {}", userAgent, e);
|
||||
}
|
||||
}
|
||||
return new ClientInfo(ip, ipLocation, browser, browserVersion, os, osVersion, deviceType, userAgent);
|
||||
}
|
||||
|
||||
private static String buildVersion(String major, String minor, String patch) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (isNotBlank(major)) {
|
||||
sb.append(major);
|
||||
}
|
||||
if (isNotBlank(minor)) {
|
||||
sb.append('.').append(minor);
|
||||
}
|
||||
if (isNotBlank(patch)) {
|
||||
sb.append('.').append(patch);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String deriveDeviceType(String deviceFamily, String userAgent) {
|
||||
if (deviceFamily == null) {
|
||||
return "Unknown";
|
||||
}
|
||||
String family = deviceFamily.toLowerCase();
|
||||
if (family.contains("ipad") || family.contains("tablet") || family.contains("kindle")) {
|
||||
return "Tablet";
|
||||
}
|
||||
if (family.contains("spider") || family.contains("bot") || family.contains("crawler")) {
|
||||
return "Bot";
|
||||
}
|
||||
if (family.contains("iphone") || family.contains("android")
|
||||
|| family.contains("windows phone") || family.contains("phone")) {
|
||||
return "Mobile";
|
||||
}
|
||||
// device.family 通常为 "Other",再依据 UA 关键字推断
|
||||
String ua = userAgent.toLowerCase();
|
||||
if (ua.contains("mobile")) {
|
||||
return "Mobile";
|
||||
}
|
||||
if (family.equals("other")) {
|
||||
return "Desktop";
|
||||
}
|
||||
return "Desktop";
|
||||
}
|
||||
|
||||
private static String orDefault(String value, String def) {
|
||||
return (value == null || value.isBlank()) ? def : value;
|
||||
}
|
||||
|
||||
private static boolean isNotBlank(String s) {
|
||||
return s != null && !s.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package cn.g3soft.fmsapi.utils;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.lionsoul.ip2region.xdb.LongByteArray;
|
||||
import org.lionsoul.ip2region.xdb.Searcher;
|
||||
import org.lionsoul.ip2region.xdb.Version;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* 基于本地 ip2region xdb 数据库的 IP 归属地查询。
|
||||
* 启动时将 xdb 文件读入内存,避免每次查询的磁盘 IO。
|
||||
*/
|
||||
@Component
|
||||
public class IpRegionSearcher {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(IpRegionSearcher.class);
|
||||
|
||||
private Searcher searcher;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
try (InputStream in = new ClassPathResource("ip2region.xdb").getInputStream()) {
|
||||
byte[] bytes = in.readAllBytes();
|
||||
LongByteArray buffer = new LongByteArray();
|
||||
buffer.append(bytes);
|
||||
searcher = Searcher.newWithBuffer(Version.IPv4, buffer);
|
||||
log.info("ip2region Searcher 初始化完成,数据大小 {} 字节", bytes.length);
|
||||
} catch (IOException e) {
|
||||
log.error("未找到 classpath 下的 ip2region.xdb,IP 归属地将返回「未知」", e);
|
||||
searcher = null;
|
||||
} catch (Exception e) {
|
||||
log.error("ip2region Searcher 初始化失败,IP 归属地将返回「未知」", e);
|
||||
searcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 IP 查询归属地,返回格式如 国家|省|市|运营商。
|
||||
* 内网/保留地址直接返回「内网」;任何异常或数据缺失时返回「未知」,不影响主流程。
|
||||
*/
|
||||
public String search(String ip) {
|
||||
if (ip == null || ip.isBlank()) {
|
||||
return "未知";
|
||||
}
|
||||
if (searcher == null) {
|
||||
return "未知";
|
||||
}
|
||||
if (isInternalIp(ip)) {
|
||||
return "内网";
|
||||
}
|
||||
try {
|
||||
String region = searcher.search(ip);
|
||||
return (region == null || region.isBlank()) ? "未知" : region;
|
||||
} catch (Exception e) {
|
||||
log.warn("ip2region 查询失败 ip={}", ip, e);
|
||||
return "未知";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为内网/保留地址(IPv4 RFC1918、回环、链路本地、未指定地址,以及 IPv6 回环/唯一本地地址)。
|
||||
*/
|
||||
private static boolean isInternalIp(String ip) {
|
||||
if (ip.contains(".")) {
|
||||
String v4 = ip;
|
||||
int colon = ip.lastIndexOf(':');
|
||||
if (colon >= 0 && ip.substring(colon + 1).contains(".")) {
|
||||
v4 = ip.substring(colon + 1);
|
||||
}
|
||||
String[] parts = v4.split("\\.");
|
||||
if (parts.length != 4) {
|
||||
return false;
|
||||
}
|
||||
int[] o = new int[4];
|
||||
try {
|
||||
for (int i = 0; i < 4; i++) {
|
||||
int n = Integer.parseUnsignedInt(parts[i]);
|
||||
if (n > 255) {
|
||||
return false;
|
||||
}
|
||||
o[i] = n;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
if (o[0] == 0) {
|
||||
return true; // 0.0.0.0/8 未指定
|
||||
}
|
||||
if (o[0] == 10) {
|
||||
return true; // 10.0.0.0/8
|
||||
}
|
||||
if (o[0] == 127) {
|
||||
return true; // 127.0.0.0/8 回环
|
||||
}
|
||||
if (o[0] == 169 && o[1] == 254) {
|
||||
return true; // 169.254.0.0/16 链路本地
|
||||
}
|
||||
if (o[0] == 172 && o[1] >= 16 && o[1] <= 31) {
|
||||
return true; // 172.16.0.0/12
|
||||
}
|
||||
return o[0] == 192 && o[1] == 168; // 192.168.0.0/16
|
||||
}
|
||||
if (ip.equalsIgnoreCase("::1")) {
|
||||
return true; // IPv6 回环
|
||||
}
|
||||
return ip.startsWith("fe80") || ip.startsWith("fc") || ip.startsWith("fd");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package cn.g3soft.fmsapi.utils;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* 从 HttpServletRequest 中解析客户端真实 IP。
|
||||
* 获取顺序兼容 Nginx 等反向代理:X-Real-IP → X-Forwarded-For(首个) → 远程地址。
|
||||
*/
|
||||
public final class IpUtils {
|
||||
|
||||
private static final String UNKNOWN = "unknown";
|
||||
|
||||
private IpUtils() {
|
||||
}
|
||||
|
||||
public static String getClientIp(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return UNKNOWN;
|
||||
}
|
||||
String ip = request.getHeader("X-Real-IP");
|
||||
if (isEffective(ip)) {
|
||||
return ip.trim();
|
||||
}
|
||||
ip = request.getHeader("X-Forwarded-For");
|
||||
if (isEffective(ip)) {
|
||||
// X-Forwarded-For 可能包含多个以逗号分隔的 IP,取第一个
|
||||
return ip.split(",")[0].trim();
|
||||
}
|
||||
ip = request.getHeader("Proxy-Client-IP");
|
||||
if (isEffective(ip)) {
|
||||
return ip.trim();
|
||||
}
|
||||
ip = request.getHeader("WL-Proxy-Client-IP");
|
||||
if (isEffective(ip)) {
|
||||
return ip.trim();
|
||||
}
|
||||
ip = request.getRemoteAddr();
|
||||
if (ip == null || ip.isBlank()) {
|
||||
return UNKNOWN;
|
||||
}
|
||||
// 归一化 IPv6 本地回环地址
|
||||
if ("0:0:0:0:0:0:0:1".equals(ip)) {
|
||||
ip = "127.0.0.1";
|
||||
}
|
||||
return ip.trim();
|
||||
}
|
||||
|
||||
private static boolean isEffective(String ip) {
|
||||
return ip != null && !ip.isBlank() && !UNKNOWN.equalsIgnoreCase(ip.trim());
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ class AuthServiceTests {
|
||||
private DbUtils dbUtils;
|
||||
private JwtUtils jwtUtils;
|
||||
private ActiveSessionRegistry activeSessionRegistry;
|
||||
private LoginLogService loginLogService;
|
||||
private AuthService authService;
|
||||
|
||||
@BeforeEach
|
||||
@@ -27,6 +28,7 @@ class AuthServiceTests {
|
||||
dbUtils = mock(DbUtils.class);
|
||||
jwtUtils = mock(JwtUtils.class);
|
||||
activeSessionRegistry = new ActiveSessionRegistry();
|
||||
loginLogService = mock(LoginLogService.class);
|
||||
authService = new AuthService();
|
||||
|
||||
ReflectionTestUtils.setField(authService, "dbUtils", dbUtils);
|
||||
@@ -36,6 +38,7 @@ class AuthServiceTests {
|
||||
"activeSessionRegistry",
|
||||
activeSessionRegistry
|
||||
);
|
||||
ReflectionTestUtils.setField(authService, "loginLogService", loginLogService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -56,7 +59,7 @@ class AuthServiceTests {
|
||||
))
|
||||
.thenReturn("token");
|
||||
|
||||
Optional<Map<String, Object>> result = authService.login("fms", "g3soft", "");
|
||||
Optional<Map<String, Object>> result = authService.login("fms", "g3soft", "", null);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.orElseThrow()).containsEntry("token", "token");
|
||||
|
||||
Reference in new issue
Block a user