feat:会话强制下线

This commit is contained in:
haoxr 2021-03-09 16:38:55 +08:00
parent b01367fb2a
commit 112a8b1d19
17 changed files with 341 additions and 112 deletions

View File

@ -46,9 +46,10 @@
<seata.version>1.4.1</seata.version>
<commons-pool2-version>2.9.0</commons-pool2-version>
<knife4j.version>2.0.8</knife4j.version>
<redisson.version>3.11.1</redisson.version>
<logstash-logback-encoder.version>6.6</logstash-logback-encoder.version>
<elasticsearch.version>7.10.1</elasticsearch.version>
<redisson.version>3.11.1</redisson.version>
<ip2region.version>1.7.2</ip2region.version>
</properties>
<dependencies>
@ -174,6 +175,12 @@
<version>${redisson.version}</version>
</dependency>
<dependency>
<groupId>org.lionsoul</groupId>
<artifactId>ip2region</artifactId>
<version>${ip2region.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

View File

@ -0,0 +1,30 @@
package com.youlai.admin.pojo.domain;
import lombok.Data;
/**
* @author hxr
* @date 2021-03-09
*/
@Data
public class LoginRecord {
private String _id;
private String description;
private String clientIP;
private long elapsedTime;
private Object message;
private String token;
private String username;
private String loginTime;
private String region;
}

View File

@ -77,7 +77,10 @@
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
</dependency>
</dependencies>
<build>

View File

@ -8,4 +8,7 @@ public interface ESConstants {
String INDEX_LOGIN_PREFIX = "youlai-auth-login-";
String INDEX_LOGIN_PATTERN = "youlai-auth-login-*";
}

View File

@ -4,7 +4,7 @@ import cn.hutool.core.convert.Convert;
import com.youlai.admin.common.constant.ESConstants;
import com.youlai.common.elasticsearch.service.ElasticSearchService;
import com.youlai.common.result.Result;
import com.youlai.common.web.util.IpUtils;
import com.youlai.common.web.util.IPUtils;
import com.youlai.common.web.util.RequestUtils;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
@ -72,7 +72,7 @@ public class DashboardController {
// 当前用户统计
HttpServletRequest request = RequestUtils.getRequest();
String clientIP = IpUtils.getIpAddr(request);
String clientIP = IPUtils.getClientIP(request);
boolQueryBuilder.must(QueryBuilders.termQuery("clientIP", clientIP));
Map<String, Long> myCountMap = elasticSearchService.dateHistogram(boolQueryBuilder, "date", DateHistogramInterval.days(1), indices);
@ -97,4 +97,5 @@ public class DashboardController {
return Result.success(map);
}
}

View File

@ -0,0 +1,92 @@
package com.youlai.admin.controller;
import cn.hutool.core.util.StrUtil;
import com.youlai.admin.common.constant.ESConstants;
import com.youlai.admin.pojo.domain.LoginRecord;
import com.youlai.common.elasticsearch.service.ElasticSearchService;
import com.youlai.common.result.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.index.query.RangeQueryBuilder;
import org.elasticsearch.search.sort.FieldSortBuilder;
import org.elasticsearch.search.sort.SortOrder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* @author hxr
* @date 2021-03-09
*/
@Api(tags = "登录记录")
@RestController
@RequestMapping("/api.admin/v1/records/login")
@Slf4j
@AllArgsConstructor
public class LoginRecordController {
ElasticSearchService elasticSearchService;
@ApiOperation(value = "列表分页", httpMethod = "GET")
@ApiImplicitParams({
@ApiImplicitParam(name = "page", value = "页码", defaultValue = "1", paramType = "query", dataType = "Long"),
@ApiImplicitParam(name = "limit", value = "每页数量", defaultValue = "10", paramType = "query", dataType = "Long"),
@ApiImplicitParam(name = "startDate", value = "开始日期", paramType = "query", dataType = "String"),
@ApiImplicitParam(name = "endDate", value = "结束日期", paramType = "query", dataType = "String"),
@ApiImplicitParam(name = "clientIP", value = "客户端IP", paramType = "query", dataType = "String")
})
@GetMapping
public Result list(
Integer page,
Integer limit,
String startDate,
String endDate,
String clientIP
) {
// 日期范围
RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery("date");
if (StrUtil.isNotBlank(startDate)) {
rangeQueryBuilder.from(startDate);
}
if (StrUtil.isNotBlank(endDate)) {
rangeQueryBuilder.to(endDate);
}
BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery().must(rangeQueryBuilder);
if (StrUtil.isNotBlank(clientIP)) {
queryBuilder.must(QueryBuilders.wildcardQuery("clientIP", "*" + clientIP + "*"));
}
// 总记录数
long count = elasticSearchService.count(queryBuilder, ESConstants.INDEX_LOGIN_PATTERN);
// 排序
FieldSortBuilder sortBuilder = new FieldSortBuilder("@timestamp").order(SortOrder.DESC);
// 分页数
List<LoginRecord> list = elasticSearchService.search(queryBuilder, sortBuilder, page, limit, LoginRecord.class, ESConstants.INDEX_LOGIN_PATTERN);
return Result.success(list, count);
}
@ApiOperation(value = "删除登录记录", httpMethod = "DELETE")
@ApiImplicitParam(name = "ids", value = "id集合", required = true, paramType = "query", dataType = "String")
@DeleteMapping("/{ids}")
public Result delete(@PathVariable String ids) {
return Result.judge(true);
}
}

View File

@ -0,0 +1,57 @@
package com.youlai.admin.controller;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.nimbusds.jose.JWSObject;
import com.youlai.common.constant.AuthConstants;
import com.youlai.common.result.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiOperation;
import lombok.AllArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.util.Strings;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* @author hxr
* @date 2021-03-09
*/
@Api(tags = "令牌接口")
@RestController
@RequestMapping("/api.admin/v1/tokens")
@Slf4j
@AllArgsConstructor
public class TokenController {
RedisTemplate redisTemplate;
@ApiOperation(value = "强制下线", httpMethod = "POST")
@ApiImplicitParam(name = "token", value = "访问令牌", required = true, paramType = "query", dataType = "String")
@PostMapping("/{token}/_invalid")
@SneakyThrows
public Result invalidToken(@PathVariable String token) {
token = token.replace(AuthConstants.AUTHORIZATION_PREFIX, Strings.EMPTY);
JWSObject jwsObject = JWSObject.parse(token);
String payload = jwsObject.getPayload().toString();
JSONObject jsonObject = JSONUtil.parseObj(payload);
long currentTimeSeconds = System.currentTimeMillis() / 1000;
String jti = jsonObject.getStr(AuthConstants.JWT_JTI); // JWT唯一标识
long exp = jsonObject.getLong(AuthConstants.JWT_EXP); // JWT过期时间戳
if (exp < currentTimeSeconds) { // token已过期无需加入黑名单
return Result.success();
}
redisTemplate.opsForValue().set(AuthConstants.TOKEN_BLACKLIST_PREFIX + jti, null, (exp - currentTimeSeconds), TimeUnit.SECONDS);
return Result.success();
}
}

View File

@ -38,7 +38,6 @@ public class UserController extends BaseController {
private final ISysUserService iSysUserService;
private final ISysUserRoleService iSysUserRoleService;
private final ISysRoleService iSysRoleService;
private final PasswordEncoder passwordEncoder;
private final ISysPermissionService iSysPermissionService;

View File

@ -1,16 +1,11 @@
package com.youlai.admin;
import com.youlai.common.elasticsearch.service.ElasticSearchService;
import com.youlai.common.web.pojo.domain.LoginLog;
import lombok.extern.slf4j.Slf4j;
import org.elasticsearch.search.aggregations.bucket.histogram.DateHistogramInterval;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import java.util.Map;
@SpringBootTest
@Slf4j
public class ElasticSearchTests {
@ -19,21 +14,8 @@ public class ElasticSearchTests {
private ElasticSearchService elasticSearchService;
@Test
public void search() {
List<LoginLog> list = elasticSearchService.search(null, LoginLog.class, "youlai-auth-login-2021-03-06");
System.out.println(list.toString());
}
@Test
public void count(){
public void count() {
long count = elasticSearchService.count(null, "youlai-auth-login-2021-03-06");
log.info(String.valueOf(count));
}
@Test
public void group(){
Map<String, Long> map = elasticSearchService.dateHistogram(null, "date",DateHistogramInterval.days(1),"youlai-auth-login-2021-03-07","youlai-auth-login-2021-03-08");
log.info(map.toString());
}
}

View File

@ -27,8 +27,8 @@ public class LogoutController {
@DeleteMapping("/logout")
public Result logout() {
JSONObject jsonObject = RequestUtils.getJwtPayload();
String jti = jsonObject.getStr("jti"); // JWT唯一标识
long exp = jsonObject.getLong("exp"); // JWT过期时间戳
String jti = jsonObject.getStr(AuthConstants.JWT_JTI); // JWT唯一标识
long exp = jsonObject.getLong(AuthConstants.JWT_EXP); // JWT过期时间戳
long currentTimeSeconds = System.currentTimeMillis() / 1000;

View File

@ -11,7 +11,7 @@ public interface AuthConstants {
/**
* JWT令牌前缀
*/
String JWT_PREFIX = "bearer ";
String AUTHORIZATION_PREFIX = "bearer ";
/**
@ -24,6 +24,16 @@ public interface AuthConstants {
*/
String JWT_PAYLOAD_KEY = "payload";
/**
* JWT ID 唯一标识
*/
String JWT_JTI = "jti";
/**
* JWT ID 唯一标识
*/
String JWT_EXP = "exp";
/**
* Redis缓存权限规则key
*/
@ -51,7 +61,7 @@ public interface AuthConstants {
String USER_ID_KEY = "user_id";
String USER_NAME_KEY="username";
String USER_NAME_KEY = "username";
String CLIENT_ID_KEY = "client_id";
@ -83,5 +93,10 @@ public interface AuthConstants {
String ADMIN_URL_PATTERN = "*_/youlai-admin/**";
String LOGOUT_PATH= "/youlai-auth/oauth/logout";
String LOGOUT_PATH = "/youlai-auth/oauth/logout";
String GRANT_TYPE_KEY = "grant_type";
String REFRESH_TOKEN = "refresh_token";
}

View File

@ -12,7 +12,7 @@ import org.springframework.context.annotation.Configuration;
import java.util.List;
/**
* Ela
* ElasticSearch HighLevelClient
*
* @author hxr
* @date 2021-03-05

View File

@ -21,6 +21,7 @@ import org.elasticsearch.search.aggregations.bucket.histogram.DateHistogramInter
import org.elasticsearch.search.aggregations.bucket.histogram.Histogram;
import org.elasticsearch.search.aggregations.bucket.histogram.ParsedDateHistogram;
import org.elasticsearch.search.builder.SearchSourceBuilder;
import org.elasticsearch.search.sort.SortBuilder;
import org.springframework.stereotype.Service;
import java.util.*;
@ -39,12 +40,9 @@ public class ElasticSearchService {
@SneakyThrows
public long count(QueryBuilder queryBuilder, String... indices) {
// 构造搜索条件
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(queryBuilder);
// 构造请求
CountRequest countRequest = new CountRequest(indices);
countRequest.source(searchSourceBuilder);
countRequest.query(queryBuilder);
// 执行请求
CountResponse countResponse = client.count(countRequest, RequestOptions.DEFAULT);
long count = countResponse.getCount();
@ -53,10 +51,11 @@ public class ElasticSearchService {
/**
* 日期统计
*
* @param queryBuilder 查询条件
* @param field 聚合字段登录日志的 date 字段
* @param interval 统计时间间隔1天1周
* @param indices 索引名称
* @param field 聚合字段登录日志的 date 字段
* @param interval 统计时间间隔1天1周
* @param indices 索引名称
* @return
*/
@SneakyThrows
@ -107,11 +106,25 @@ public class ElasticSearchService {
@SneakyThrows
public <T> List<T> search(QueryBuilder queryBuilder, Class<T> clazz, String... indices) {
List<T> list = this.search(queryBuilder, null, 1, ESConstants.DEFAULT_PAGE_SIZE, clazz, indices);
return list;
}
@SneakyThrows
public <T> List<T> search(QueryBuilder queryBuilder, Integer page, Integer size, Class<T> clazz, String... indices) {
List<T> list = this.search(queryBuilder, null, 1, ESConstants.DEFAULT_PAGE_SIZE, clazz, indices);
return list;
}
@SneakyThrows
public <T> List<T> search(QueryBuilder queryBuilder, SortBuilder sortBuilder, Integer page, Integer size, Class<T> clazz, String... indices) {
// 构造SearchSourceBuilder
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(queryBuilder);
searchSourceBuilder.from(0);
searchSourceBuilder.size(ESConstants.DEFAULT_PAGE_SIZE);
searchSourceBuilder.sort(sortBuilder);
searchSourceBuilder.from((page - 1) * size);
searchSourceBuilder.size(size);
// 构造SearchRequest
SearchRequest searchRequest = new SearchRequest(indices);
searchRequest.source(searchSourceBuilder);
@ -128,5 +141,4 @@ public class ElasticSearchService {
return list;
}
}

View File

@ -28,12 +28,6 @@
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.github.dozermapper</groupId>
<artifactId>dozer-core</artifactId>
<version>6.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
@ -51,6 +45,18 @@
<artifactId>logstash-logback-encoder</artifactId>
</dependency>
<dependency>
<groupId>org.lionsoul</groupId>
<artifactId>ip2region</artifactId>
</dependency>
<dependency>
<groupId>com.github.dozermapper</groupId>
<artifactId>dozer-core</artifactId>
<version>6.2.0</version>
</dependency>
</dependencies>
</project>

View File

@ -1,13 +1,10 @@
package com.youlai.common.web.aspect;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.servlet.ServletUtil;
import cn.hutool.json.JSON;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.youlai.common.constant.AuthConstants;
import com.youlai.common.web.pojo.domain.LoginLog;
import com.youlai.common.web.util.IpUtils;
import com.youlai.common.web.util.IPUtils;
import io.swagger.annotations.ApiOperation;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -24,8 +21,9 @@ import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* @author hxr
@ -44,47 +42,50 @@ public class LoginLogAspect {
@Around("Log()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
// 时间统计
Date now = new Date();
long startTime = now.getTime();
Object result = joinPoint.proceed();
long endTime = System.currentTimeMillis();
long elapsedTime = endTime - startTime;
// 获取方法签名
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String description = signature.getMethod().getAnnotation(ApiOperation.class).value();
LocalDateTime startTime = LocalDateTime.now();
Object result = joinPoint.proceed();
// 获取请求信息
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// String clientIP = ServletUtil.getClientIP(request);
String clientIP= IpUtils.getIpAddr(request);
String requestUrl = request.getRequestURL().toString();
String method = request.getMethod();
// 刷新token不记录
String grantType=request.getParameter(AuthConstants.GRANT_TYPE_KEY);
if(grantType.equals(AuthConstants.REFRESH_TOKEN)){
return result;
}
// 时间统计
LocalDateTime endTime = LocalDateTime.now();
long elapsedTime = Duration.between(startTime, endTime).toMillis(); // 请求耗时毫秒
// 获取接口描述信息
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String description = signature.getMethod().getAnnotation(ApiOperation.class).value();// 方法描述
String username = request.getParameter(AuthConstants.USER_NAME_KEY); // 登录用户名
String date = startTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); // 索引名需要因为默认生成索引的date时区不一致
// 获取token
String token = Strings.EMPTY;
if (request != null) {
JSONObject jsonObject = JSONUtil.parseObj(result);
token = jsonObject.getStr("value");
}
String clientIP = IPUtils.getClientIP(request); // 客户端请求IP注意如果使用Nginx代理需配置
String region = IPUtils.ip2region(clientIP); // IP对应的城市信息
// MDC 扩展logback字段具体请看logback-spring.xml的自定义日志输出格式
MDC.put("elapsedTime", StrUtil.toString(elapsedTime));
MDC.put("description", description);
MDC.put("clientIP", clientIP);
MDC.put("url", requestUrl);
MDC.put("method", method);
String username = request.getParameter(AuthConstants.USER_NAME_KEY);
MDC.put("region", region);
MDC.put("username", username);
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
String date = simpleDateFormat.format(now);
MDC.put("date", date);
MDC.put("token", token);
MDC.put("clientIP", clientIP);
// 获取登录结果
String accessToken = Strings.EMPTY;
if (request != null) {
JSONObject jsonObject = JSONUtil.parseObj(result);
accessToken = jsonObject.getStr("value");
}
MDC.put("accessToken", accessToken);
log.info("{} 登录,耗费时间 {} 毫秒", username, elapsedTime); // 收集日志这里必须打印一条日志内容随便
log.info("{} 登录,耗费时间 {} 毫秒", username, elapsedTime); // 收集日志这里必须打印一条日志内容随便吧记录在message字段具体看logback-spring.xml文件
return result;
}
}

View File

@ -1,25 +0,0 @@
package com.youlai.common.web.pojo.domain;
import lombok.Data;
/**
* @Author haoxr
* @Date 2021-03-01 16:45
* @Version 1.0.0
*/
@Data
public class LoginLog {
private String description;
private String clientIP;
private String url;
private String method;
private long elapsedTime;
private Object result;
}

View File

@ -2,20 +2,29 @@ package com.youlai.common.web.util;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.util.Strings;
import org.lionsoul.ip2region.DataBlock;
import org.lionsoul.ip2region.DbConfig;
import org.lionsoul.ip2region.DbSearcher;
import org.lionsoul.ip2region.Util;
import javax.servlet.http.HttpServletRequest;
import java.io.File;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.net.UnknownHostException;
@Slf4j
public class IpUtils {
public class IPUtils {
private static String LOCAL_IP="127.0.0.1";
/**
* 获取IP地址
* 使用Nginx等反向代理软件 则不能通过request.getRemoteAddr()获取IP地址
* 如果使用了多级反向代理的话X-Forwarded-For的值并不止一个而是一串IP地址X-Forwarded-For中第一个非unknown的有效IP字符串则为真实IP地址
*/
public static String getIpAddr(HttpServletRequest request) {
public static String getClientIP(HttpServletRequest request) {
String ip = null;
try {
if (request == null) {
@ -46,10 +55,8 @@ public class IpUtils {
}
//使用代理则获取第一个IP地址
if (StrUtil.isEmpty(ip) && ip.length() > 15) {
if (ip.indexOf(",") > 0) {
ip = ip.substring(0, ip.indexOf(","));
}
if (StrUtil.isNotBlank(ip) && ip.indexOf(",") > 0) {
ip = ip.substring(0, ip.indexOf(","));
}
return ip;
@ -71,4 +78,43 @@ public class IpUtils {
}
return null;
}
/**
* 解析IP获取城市区域信息
*
* @param ip
* @return
*/
public static String ip2region(String ip) {
if (Util.isIpAddress(ip) == false) {
return Strings.EMPTY;
}
if(LOCAL_IP.equals(ip)){
return "本地访问";
}
String filePath = IPUtils.class.getResource("/data/ip2region.db").getPath();
File file = new File(filePath);
if (file.exists() == false) {
return Strings.EMPTY;
}
try {
DbConfig config = new DbConfig();
DbSearcher searcher = new DbSearcher(config, filePath);
Method method = searcher.getClass().getMethod("btreeSearch", String.class);
DataBlock dataBlock = (DataBlock) method.invoke(searcher, ip);
return dataBlock.getRegion();
} catch (Exception e) {
e.printStackTrace();
}
return Strings.EMPTY;
}
}