提交 1704ab90 作者: 方治民

feat: 新增 LoginLog 记录用户登录日志、使用 ip2region 解析登录 IP 信息等相关实现

上级 7eba35ac
......@@ -4,6 +4,7 @@ plugins {
dependencies {
implementation project(':basic-common:core')
implementation project(':basic-common:i18n')
implementation project(':basic-common:util')
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
......@@ -23,6 +24,7 @@ dependencies {
// hutool-core
implementation "cn.hutool:hutool-core:${hutoolVersion}"
implementation "cn.hutool:hutool-http:${hutoolVersion}"
// https://github.com/vladmihalcea/hypersistence-utils
// hypersistence-utils-hibernate-63
......
/* (C) 2024 YiRing, Inc. */
package com.yiring.auth.domain.log;
import com.yiring.common.domain.BasicEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.io.Serial;
import java.io.Serializable;
import lombok.*;
import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants;
import lombok.experimental.SuperBuilder;
import org.hibernate.annotations.Comment;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
/**
* @author Jim
*/
@Getter
@Setter
@SuperBuilder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
@FieldDefaults(level = AccessLevel.PRIVATE)
@SQLDelete(sql = LoginLog.DELETE_SQL + BasicEntity.Where.WHERE_ID)
@SQLRestriction(BasicEntity.Where.EXIST)
@Entity
@Table(name = LoginLog.TABLE_NAME)
@Comment("系统用户登录日志")
public class LoginLog extends BasicEntity implements Serializable {
@Serial
private static final long serialVersionUID = -3979598668491115376L;
public static final String TABLE_NAME = "SYS_LOGIN_LOG";
public static final String DELETE_SQL = "update " + TABLE_NAME + BasicEntity.Where.DELETE_SET;
@Comment("登录账号")
String account;
@Comment("登录IP地址")
String ip;
@Comment("登录地点")
String location;
@Comment("User-Agent")
String ua;
@Comment("操作系统")
String os;
@Comment("平台")
String platform;
@Comment("浏览器")
String browser;
@Comment("登录状态")
Boolean status;
@Comment("登录消息")
String msg;
}
/* (C) 2024 YiRing, Inc. */
package com.yiring.auth.domain.log;
import java.io.Serializable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
/**
* @author ifzm
* 2024/6/20 17:53
*/
@Repository
public interface LoginLogRepository extends JpaRepository<LoginLog, Serializable> {}
......@@ -5,7 +5,10 @@ import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.secure.SaSecureUtil;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.useragent.*;
import com.github.xiaoymin.knife4j.annotations.ApiSupport;
import com.yiring.auth.domain.log.LoginLog;
import com.yiring.auth.domain.log.LoginLogRepository;
import com.yiring.auth.domain.user.User;
import com.yiring.auth.domain.user.UserRepository;
import com.yiring.auth.param.auth.LoginParam;
......@@ -14,13 +17,16 @@ import com.yiring.auth.param.auth.SafeParam;
import com.yiring.auth.util.Auths;
import com.yiring.auth.vo.auth.LoginVo;
import com.yiring.common.annotation.RateLimiter;
import com.yiring.common.core.I18n;
import com.yiring.common.core.Result;
import com.yiring.common.core.Status;
import com.yiring.common.exception.BusinessException;
import com.yiring.common.util.Commons;
import com.yiring.common.util.IpUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springdoc.core.annotations.ParameterObject;
......@@ -48,8 +54,10 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
public class AuthController {
final I18n i18n;
final Auths auths;
final UserRepository userRepository;
final LoginLogRepository loginLogRepository;
@RateLimiter(time = 1, count = 1)
@Operation(summary = "注册")
......@@ -93,39 +101,89 @@ public class AuthController {
@Operation(summary = "登录")
@PostMapping("login")
public Result<LoginVo> login(@ParameterObject @Validated LoginParam param, HttpServletRequest request) {
// 查询用户信息是否匹配
User user = userRepository.findByAccount(param.getAccount());
if (user == null) {
throw BusinessException.i18n("Code.100003");
}
// 获取登录 IP
// String ip = "222.244.92.58";
String ip = IpUtil.getClientIp(request);
// 获取登录 User-Agent
String ua = request.getHeader("User-Agent");
// 构建登录日志
LoginLog loginLog = LoginLog
.builder()
.account(param.getAccount())
.ip(ip)
.location(IpUtil.getIpRegion(ip))
.ua(ua)
.build();
// 检查用户是否已被删除
if (Boolean.TRUE.equals(user.getDeleted())) {
throw BusinessException.i18n("Code.100004");
}
// 尝试解析 User-Agent
UserAgent userAgent = UserAgentUtil.parse(ua);
if (Objects.nonNull(userAgent)) {
// 获取操作系统
OS os = userAgent.getOs();
if (Objects.nonNull(os)) {
loginLog.setOs(os.toString());
}
// 检查用户是否被允许登录
if (Boolean.TRUE.equals(user.getDisabled())) {
throw BusinessException.i18n("Code.100005");
}
// 获取浏览器
Browser browser = userAgent.getBrowser();
if (Objects.nonNull(browser)) {
loginLog.setBrowser(browser.toString());
}
// 检查密码
String cps = SaSecureUtil.sha256(param.getPassword());
if (!cps.equals(user.getPassword())) {
throw BusinessException.i18n("Code.100003");
// 获取平台
Platform platform = userAgent.getPlatform();
if (Objects.nonNull(platform)) {
loginLog.setPlatform(platform.toString());
}
}
// 更新用户信息
user.setLastLoginIp(Commons.getClientIpAddress(request));
user.setLastLoginTime(LocalDateTime.now());
userRepository.saveAndFlush(user);
try {
// 查询用户信息是否匹配
User user = userRepository.findByAccount(param.getAccount());
if (user == null) {
throw BusinessException.i18n("Code.100003");
}
// 检查用户是否已被删除
if (Boolean.TRUE.equals(user.getDeleted())) {
throw BusinessException.i18n("Code.100004");
}
// 登录
StpUtil.login(user.getId());
// 检查用户是否被允许登录
if (Boolean.TRUE.equals(user.getDisabled())) {
throw BusinessException.i18n("Code.100005");
}
// 检查密码
String cps = SaSecureUtil.sha256(param.getPassword());
if (!cps.equals(user.getPassword())) {
throw BusinessException.i18n("Code.100003");
}
// 构建用户所需信息
LoginVo vo = LoginVo.builder().userId(user.getId()).token(StpUtil.getTokenValue()).build();
return Result.ok(vo);
// 更新用户信息
user.setLastLoginIp(IpUtil.getClientIp(request));
user.setLastLoginTime(LocalDateTime.now());
userRepository.saveAndFlush(user);
// 登录
StpUtil.login(user.getId());
// 登录日志状态
loginLog.setStatus(true);
loginLog.setMsg(i18n.get(Status.OK.getReasonPhrase()));
// 构建用户所需信息
LoginVo vo = LoginVo.builder().userId(user.getId()).token(StpUtil.getTokenValue()).build();
return Result.ok(vo);
} catch (Exception e) {
// 登录日志状态
loginLog.setStatus(false);
loginLog.setMsg(e.getMessage());
throw e;
} finally {
// 保存登录日志
loginLogRepository.saveAndFlush(loginLog);
}
}
@Operation(summary = "检查登录")
......
......@@ -31,4 +31,5 @@ dependencies {
// https://mvnrepository.com/artifact/org.jetbrains/annotations
implementation "org.jetbrains:annotations:${jetbrainsAnnotationsVersion}"
}
......@@ -5,7 +5,7 @@ import cn.hutool.core.util.StrUtil;
import com.yiring.common.annotation.RateLimiter;
import com.yiring.common.core.Redis;
import com.yiring.common.core.Status;
import com.yiring.common.util.Commons;
import com.yiring.common.util.IpUtil;
import com.yiring.common.utils.Contexts;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
......@@ -81,7 +81,7 @@ public class RateLimiterAspect {
StringBuilder sb = new StringBuilder(prefixKey);
HttpServletRequest request = Contexts.getRequest();
// 获取请求的 IP 地址
sb.append(Commons.getClientIpAddress(request));
sb.append(IpUtil.getClientIp(request));
// 考虑登录用户的 token
if (StrUtil.isNotBlank(tokenName)) {
......
......@@ -6,7 +6,7 @@ import com.alibaba.fastjson2.JSONObject;
import com.alibaba.fastjson2.JSONWriter;
import com.yiring.common.constant.DateFormatter;
import com.yiring.common.core.Result;
import com.yiring.common.util.Commons;
import com.yiring.common.util.IpUtil;
import com.yiring.common.utils.Contexts;
import jakarta.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
......@@ -97,7 +97,7 @@ public class RequestAspect {
// 打印请求日志 (Optional Replace: MDC, Trace)
log.info(
"[Request] IP: {}, Method: {}, URL: {}, Status: {}, Times: {}{}",
Commons.getClientIpAddress(request),
IpUtil.getClientIp(request),
request.getMethod(),
request.getRequestURL(),
status,
......
......@@ -13,4 +13,7 @@ dependencies {
// hutool
implementation "cn.hutool:hutool-core:${hutoolVersion}"
implementation "cn.hutool:hutool-extra:${hutoolVersion}"
// ip2region
implementation "org.lionsoul:ip2region:${ip2regionVersion}"
}
......@@ -41,7 +41,7 @@ public class ObjectMappingSerializer extends StdSerializer<Object> implements Co
String mapping = mappingSerialize.mapping();
Map<String, String> map = new HashMap<>();
if (mapping.length() > 0) {
if (!mapping.isEmpty()) {
String[] strings = mapping.split(",");
for (String str : strings) {
String[] split = str.split(":");
......@@ -57,7 +57,7 @@ public class ObjectMappingSerializer extends StdSerializer<Object> implements Co
@Override
public void serialize(Object value, JsonGenerator gen, SerializerProvider provider) throws IOException {
String text = mapping.get(Objects.toString(value));
if (text == null || text.length() == 0) {
if (text == null || text.isEmpty()) {
gen.writeObject(value);
} else {
gen.writeString(text);
......
......@@ -2,7 +2,6 @@
package com.yiring.common.util;
import cn.hutool.core.collection.CollUtil;
import jakarta.servlet.http.HttpServletRequest;
import java.lang.reflect.Constructor;
import java.util.*;
import lombok.experimental.UtilityClass;
......@@ -22,41 +21,6 @@ import org.springframework.beans.BeanUtils;
public class Commons {
/**
* 代理 IP 请求头
*/
private static final String[] HEADERS_TO_TRY = {
"X-Forwarded-For",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_X_FORWARDED_FOR",
"HTTP_X_FORWARDED",
"HTTP_X_CLUSTER_CLIENT_IP",
"HTTP_CLIENT_IP",
"HTTP_FORWARDED_FOR",
"HTTP_FORWARDED",
"HTTP_VIA",
"REMOTE_ADDR",
"X-Real-IP",
};
/***
* 获取客户端ip地址(可以穿透代理)
*
* @param request HttpServletRequest
* @return IP
*/
public String getClientIpAddress(HttpServletRequest request) {
for (String header : HEADERS_TO_TRY) {
String ip = request.getHeader(header);
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
return ip;
}
}
return request.getRemoteAddr();
}
/**
* 随机生成一个UUID
*
* @return UUID 字符串
......
/* (C) 2024 YiRing, Inc. */
package com.yiring.common.util;
import jakarta.servlet.http.HttpServletRequest;
import java.io.InputStream;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import org.lionsoul.ip2region.xdb.Searcher;
import org.springframework.core.io.ClassPathResource;
import org.springframework.util.FileCopyUtils;
/**
* @author Jim
*/
@SuppressWarnings("unused")
@Slf4j
@UtilityClass
public class IpUtil {
/**
* 代理 IP 请求头
*/
private static final String[] HEADERS_TO_TRY = {
"X-Forwarded-For",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_X_FORWARDED_FOR",
"HTTP_X_FORWARDED",
"HTTP_X_CLUSTER_CLIENT_IP",
"HTTP_CLIENT_IP",
"HTTP_FORWARDED_FOR",
"HTTP_FORWARDED",
"HTTP_VIA",
"REMOTE_ADDR",
"X-Real-IP",
};
private static Searcher searcher;
static {
try {
ClassPathResource resource = new ClassPathResource("ipdb/ip2region.xdb");
InputStream inputStream = resource.getInputStream();
byte[] bytes = FileCopyUtils.copyToByteArray(inputStream);
searcher = Searcher.newWithBuffer(bytes);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
/***
* 获取客户端ip地址(可以穿透代理)
*
* @param request HttpServletRequest
* @return IP
*/
public String getClientIp(HttpServletRequest request) {
for (String header : HEADERS_TO_TRY) {
String ip = request.getHeader(header);
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
return ip;
}
}
return request.getRemoteAddr();
}
/**
* 判断是否为合法 IP
*/
public static boolean checkIp(String ipAddress) {
String ip = "([1-9]|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])(\\.(\\d|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])){3}";
Pattern pattern = Pattern.compile(ip);
Matcher matcher = pattern.matcher(ipAddress);
return matcher.matches();
}
/**
* 获取 ip 属地
*
* @param ip ip
* @return ip 属地
*/
public static String getIpRegion(String ip) {
if (checkIp(ip)) {
try {
// searchIpInfo 的数据格式: 国家|区域|省份|城市|ISP
String searchIpInfo = searcher.search(ip);
String[] splitIpInfo = searchIpInfo.split("\\|");
if (splitIpInfo.length > 0) {
if ("中国".equals(splitIpInfo[0])) {
// 国内属地返回城市
return splitIpInfo[3];
} else if ("0".equals(splitIpInfo[0])) {
if ("内网IP".equals(splitIpInfo[4])) {
// 内网 IP
return splitIpInfo[4];
}
} else {
// 国外属地返回国家
return splitIpInfo[0];
}
}
} catch (Exception e) {
log.error(e.getMessage());
}
return "";
} else {
throw new IllegalArgumentException("Illegal IP address");
}
}
}
# ip2region
- [代码库](https://github.com/lionsoul2014/ip2region)
- [ip2region.xdb](https://github.com/lionsoul2014/ip2region/blob/master/data/ip2region.xdb)
......@@ -64,6 +64,8 @@ ext {
jetbrainsAnnotationsVersion = '24.1.0'
// https://central.sonatype.com/artifact/org.apache.pdfbox/pdfbox
pdfboxVersion = '3.0.2'
// https://central.sonatype.com/artifact/org.lionsoul/ip2region
ip2regionVersion = '2.7.0'
// https://central.sonatype.com/artifact/net.bramp.ffmpeg/ffmpeg
// FIXED: ffmpeg 4.x
ffmpegWrapperVersion = '0.8.0'
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论