登录终端限制

v1.4.1
Parker 5 years ago
parent a584a50ab7
commit 97db30d2b1

@ -9,9 +9,10 @@ package org.opsli.common.enums;
public enum AlertType { public enum AlertType {
/** alert 弹出 */ /** alert 弹出 */
ALERT(), ALERT,
/** JSON 回推*/ /** JSON 回推*/
JSON, JSON,
; ;
} }

@ -0,0 +1,40 @@
/**
* Copyright 2020 OPSLI https://www.opsli.com
* <p>
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.opsli.common.enums;
/**
* @BelongsProject: opsli-boot
* @BelongsPackage: org.opsli.common.enums
* @Author: Parker
* @CreateTime: 2020-09-17 23:40
* @Description:
*/
public enum LoginLimitRefuse {
/**
*
*/
BEFORE,
/**
*
*/
AFTER;
;
}

@ -2,6 +2,7 @@ package org.opsli.core.autoconfigure.properties;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import org.opsli.common.enums.LoginLimitRefuse;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@ -129,6 +130,12 @@ public class GlobalProperties {
@EqualsAndHashCode(callSuper = false) @EqualsAndHashCode(callSuper = false)
public static class Login { public static class Login {
/** 限制登录数量 -1 为无限大 */
private Integer limitCount;
/** 限制登录拒绝策略 after为后者 before为前者 */
private LoginLimitRefuse limitRefuse = LoginLimitRefuse.AFTER;
/** 失败次数 */ /** 失败次数 */
private Integer slipCount; private Integer slipCount;
@ -138,7 +145,9 @@ public class GlobalProperties {
/** 失败锁定时间(秒) */ /** 失败锁定时间(秒) */
private Integer slipLockSpeed; private Integer slipLockSpeed;
} }
} }
/** /**

@ -30,6 +30,8 @@ public enum TokenMsg implements BaseMsg {
* Token * Token
*/ */
EXCEPTION_TOKEN_CREATE_ERROR(12000,"生成Token失败"), EXCEPTION_TOKEN_CREATE_ERROR(12000,"生成Token失败"),
EXCEPTION_TOKEN_CREATE_LIMIT_ERROR(12001,"您的账号已在其他设备登录"),
EXCEPTION_TOKEN_LOSE_EFFICACY(401,"Token失效请重新登录"), EXCEPTION_TOKEN_LOSE_EFFICACY(401,"Token失效请重新登录"),

@ -20,7 +20,9 @@ import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUnit; import cn.hutool.core.date.DateUnit;
import cn.hutool.core.date.DateUtil; import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.google.common.collect.Maps; import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.crypto.hash.Md5Hash; import org.apache.shiro.crypto.hash.Md5Hash;
@ -29,6 +31,7 @@ import org.opsli.api.wrapper.system.user.UserModel;
import org.opsli.common.constants.SignConstants; import org.opsli.common.constants.SignConstants;
import org.opsli.common.constants.TokenConstants; import org.opsli.common.constants.TokenConstants;
import org.opsli.common.constants.TokenTypeConstants; import org.opsli.common.constants.TokenTypeConstants;
import org.opsli.common.enums.LoginLimitRefuse;
import org.opsli.common.exception.TokenException; import org.opsli.common.exception.TokenException;
import org.opsli.core.autoconfigure.properties.GlobalProperties; import org.opsli.core.autoconfigure.properties.GlobalProperties;
import org.opsli.core.cache.local.CacheUtil; import org.opsli.core.cache.local.CacheUtil;
@ -41,7 +44,6 @@ import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import java.util.Date; import java.util.Date;
import java.util.Map;
import static org.opsli.common.constants.OrderConstants.UTIL_ORDER; import static org.opsli.common.constants.OrderConstants.UTIL_ORDER;
@ -65,13 +67,10 @@ public class UserTokenUtil {
public static final String ACCOUNT_SLIP_COUNT_PREFIX; public static final String ACCOUNT_SLIP_COUNT_PREFIX;
/** 账号失败锁定KEY */ /** 账号失败锁定KEY */
public static final String ACCOUNT_SLIP_LOCK_PREFIX; public static final String ACCOUNT_SLIP_LOCK_PREFIX;
/** 账号失败阈值 */ /** 限制登录数量 -1 为无限大 */
public static int ACCOUNT_SLIP_COUNT; public static final int ACCOUNT_LIMIT_INFINITE = -1;
/** 账号失败N次后弹出验证码 */ /** 登录配置信息 */
public static int ACCOUNT_SLIP_VERIFY_COUNT; public static GlobalProperties.Auth.Login LOGIN_PROPERTIES;
/** 账号锁定时间 */
public static int ACCOUNT_SLIP_LOCK_SPEED;
/** Redis插件 */ /** Redis插件 */
private static RedisPlugin redisPlugin; private static RedisPlugin redisPlugin;
@ -89,15 +88,31 @@ public class UserTokenUtil {
* @param user * @param user
* @return * @return
*/ */
public static ResultVo<Map<String,Object>> createToken(UserModel user) { public static ResultVo<UserTokenUtil.TokenRet> createToken(UserModel user) {
if (user == null) { if (user == null) {
// 生成Token失败 // 生成Token失败
throw new TokenException(TokenMsg.EXCEPTION_TOKEN_CREATE_ERROR); throw new TokenException(TokenMsg.EXCEPTION_TOKEN_CREATE_ERROR);
} }
Map<String,Object> map = Maps.newHashMapWithExpectedSize(2);
try { try {
// 如果当前登录开启 数量限制
if(LOGIN_PROPERTIES.getLimitCount() > ACCOUNT_LIMIT_INFINITE){
// 当前用户已存在 Token数量
Long ticketLen = redisPlugin.sSize(TICKET_PREFIX + ":" + user.getUsername());
if(ticketLen !=null && ticketLen >= LOGIN_PROPERTIES.getLimitCount()){
// 如果是拒绝后者 则直接抛出异常
if(LoginLimitRefuse.AFTER == LOGIN_PROPERTIES.getLimitRefuse()){
// 生成Token失败 您的账号已在其他设备登录
throw new TokenException(TokenMsg.EXCEPTION_TOKEN_CREATE_LIMIT_ERROR);
}
// 如果是拒绝前者 则弹出前者
else {
redisPlugin.sPop(TICKET_PREFIX + ":" + user.getUsername());
}
}
}
// 生效时间 // 生效时间
int expire = Integer.parseInt( int expire = Integer.parseInt(
String.valueOf(JwtUtil.EXPIRE) String.valueOf(JwtUtil.EXPIRE)
@ -106,9 +121,6 @@ public class UserTokenUtil {
// 生成 Token 包含 username userId timestamp // 生成 Token 包含 username userId timestamp
String signToken = JwtUtil.sign(TokenTypeConstants.TYPE_SYSTEM, user.getUsername(), user.getId()); String signToken = JwtUtil.sign(TokenTypeConstants.TYPE_SYSTEM, user.getUsername(), user.getId());
// 生成MD5 16进制码 用于缩减存储
String signTokenHex = new Md5Hash(signToken).toHex();
// 获得当前时间戳时间 // 获得当前时间戳时间
long timestamp = Convert.toLong( long timestamp = Convert.toLong(
JwtUtil.getClaim(signToken, SignConstants.TIMESTAMP)); JwtUtil.getClaim(signToken, SignConstants.TIMESTAMP));
@ -119,12 +131,19 @@ public class UserTokenUtil {
long endTimestamp = dateTime.getTime(); long endTimestamp = dateTime.getTime();
// 在redis存一份 token 是为了防止 人为造假 // 在redis存一份 token 是为了防止 人为造假
boolean tokenFlag = redisPlugin.put(TICKET_PREFIX + signTokenHex, endTimestamp, expire); // 保存用户token
if(tokenFlag){ Long saveLong = redisPlugin.sPut(TICKET_PREFIX + ":" + user.getUsername(), signToken);
map.put("token", signToken); if(saveLong != null && saveLong > 0){
map.put("expire", endTimestamp); // 设置该用户全部token失效时间 如果这时又有新设备登录 则续命
return ResultVo.success(map); redisPlugin.expire(TICKET_PREFIX + ":" + user.getUsername(), expire);
TokenRet tokenRet = new TokenRet();
tokenRet.setToken(signToken);
tokenRet.setEndTimestamp(endTimestamp);
return ResultVo.success(tokenRet);
} }
}catch (Exception e){ }catch (Exception e){
log.error(e.getMessage() , e); log.error(e.getMessage() , e);
} }
@ -175,19 +194,22 @@ public class UserTokenUtil {
return; return;
} }
try { try {
// 生成MD5 16进制码 用于缩减存储 // 获得要推出用户
String signTokenHex = new Md5Hash(token).toHex();
redisPlugin.del(TICKET_PREFIX + signTokenHex);
// 删除相关信息
String userId = getUserIdByToken(token); String userId = getUserIdByToken(token);
UserModel user = UserUtil.getUser(userId); UserModel user = UserUtil.getUser(userId);
if(user != null){ if(user != null){
UserUtil.refreshUser(user); // 删除Token信息
UserUtil.refreshUserRoles(user.getId()); redisPlugin.sRemove(TICKET_PREFIX + ":" + user.getUsername(), token);
UserUtil.refreshUserAllPerms(user.getId());
UserUtil.refreshUserMenus(user.getId()); // 如果缓存中 无该用户任何Token信息 则删除用户缓存
Long size = redisPlugin.sSize(TICKET_PREFIX + ":" + user.getUsername());
if(size == null || size == 0L){
// 删除相关信息
UserUtil.refreshUser(user);
UserUtil.refreshUserRoles(user.getId());
UserUtil.refreshUserAllPerms(user.getId());
UserUtil.refreshUserMenus(user.getId());
}
} }
}catch (Exception ignored){} }catch (Exception ignored){}
@ -211,12 +233,12 @@ public class UserTokenUtil {
// 2. 校验当前缓存中token是否失效 // 2. 校验当前缓存中token是否失效
// 生成MD5 16进制码 用于缩减存储 // 生成MD5 16进制码 用于缩减存储
String signTokenHex = new Md5Hash(token).toHex(); // 删除相关信息
boolean hasKey = redisPlugin.hasKey(TICKET_PREFIX + signTokenHex); String username = getUserNameByToken(token);
if(!hasKey){ boolean hashKey = redisPlugin.sHashKey(TICKET_PREFIX + ":" + username, token);
if(!hashKey){
return false; return false;
} }
// JWT 自带过期校验 无需多做处理 // JWT 自带过期校验 无需多做处理
} catch (Exception e){ } catch (Exception e){
@ -238,7 +260,7 @@ public class UserTokenUtil {
Date currDate = new Date(); Date currDate = new Date();
DateTime loseDate = DateUtil.date(loseTimeMillis); DateTime loseDate = DateUtil.date(loseTimeMillis);
// 偏移5分钟 // 偏移5分钟
DateTime currLoseDate = DateUtil.offsetSecond(loseDate, ACCOUNT_SLIP_LOCK_SPEED); DateTime currLoseDate = DateUtil.offsetSecond(loseDate, LOGIN_PROPERTIES.getSlipLockSpeed());
// 计算失效剩余时间( 分 ) // 计算失效剩余时间( 分 )
long betweenM = DateUtil.between(currLoseDate, currDate, DateUnit.MINUTE); long betweenM = DateUtil.between(currLoseDate, currDate, DateUnit.MINUTE);
@ -265,14 +287,14 @@ public class UserTokenUtil {
Long slipNum = redisPlugin.increment(ACCOUNT_SLIP_COUNT_PREFIX + username); Long slipNum = redisPlugin.increment(ACCOUNT_SLIP_COUNT_PREFIX + username);
if (slipNum != null){ if (slipNum != null){
// 设置失效时间为 5分钟 // 设置失效时间为 5分钟
redisPlugin.expire(ACCOUNT_SLIP_COUNT_PREFIX + username, ACCOUNT_SLIP_LOCK_SPEED); redisPlugin.expire(ACCOUNT_SLIP_COUNT_PREFIX + username, LOGIN_PROPERTIES.getSlipLockSpeed());
// 如果确认 都失败 则存入临时缓存 // 如果确认 都失败 则存入临时缓存
if(slipNum >= ACCOUNT_SLIP_COUNT){ if(slipNum >= LOGIN_PROPERTIES.getSlipCount()){
long currentTimeMillis = System.currentTimeMillis(); long currentTimeMillis = System.currentTimeMillis();
// 存入Redis // 存入Redis
redisPlugin.put(ACCOUNT_SLIP_LOCK_PREFIX + username, redisPlugin.put(ACCOUNT_SLIP_LOCK_PREFIX + username,
currentTimeMillis, ACCOUNT_SLIP_LOCK_SPEED); currentTimeMillis, LOGIN_PROPERTIES.getSlipLockSpeed());
} }
} }
@ -334,18 +356,28 @@ public class UserTokenUtil {
if(globalProperties != null && globalProperties.getAuth() != null if(globalProperties != null && globalProperties.getAuth() != null
&& globalProperties.getAuth().getLogin() != null && globalProperties.getAuth().getLogin() != null
){ ){
// 账号失败阈值 // 登录配置信息
UserTokenUtil.ACCOUNT_SLIP_COUNT = globalProperties.getAuth() UserTokenUtil.LOGIN_PROPERTIES = globalProperties.getAuth().getLogin();
.getLogin().getSlipCount();
// 账号失败N次后弹出验证码
UserTokenUtil.ACCOUNT_SLIP_VERIFY_COUNT = globalProperties.getAuth()
.getLogin().getSlipVerifyCount();
// 账号锁定时间
UserTokenUtil.ACCOUNT_SLIP_LOCK_SPEED = globalProperties.getAuth()
.getLogin().getSlipLockSpeed();
} }
// Redis 插件 // Redis 插件
UserTokenUtil.redisPlugin = redisPlugin; UserTokenUtil.redisPlugin = redisPlugin;
} }
// =====================
@Data
@EqualsAndHashCode(callSuper = false)
public static class TokenRet {
/** Token */
@ApiModelProperty(value = "Token")
private String token;
/** 失效时间戳 */
@ApiModelProperty(value = "失效时间戳")
private Long endTimestamp;
}
} }

@ -173,6 +173,18 @@
"description": "认证类 Token 排除URL." "description": "认证类 Token 排除URL."
}, },
{
"name": "opsli.auth.login.limit-count",
"sourceType": "org.opsli.core.autoconfigure.properties.GlobalProperties$Auth$Login",
"type": "java.lang.Integer",
"defaultValue": -1,
"description": "限制登录数量 -1 为无限大."
},
{
"name": "opsli.auth.login.limit-refuse",
"sourceType": "org.opsli.core.autoconfigure.properties.GlobalProperties$Auth$Login",
"description": "限制登录拒绝策略 after为后者 before为前者."
},
{ {
"name": "opsli.auth.login.slip-count", "name": "opsli.auth.login.slip-count",
"sourceType": "org.opsli.core.autoconfigure.properties.GlobalProperties$Auth$Login", "sourceType": "org.opsli.core.autoconfigure.properties.GlobalProperties$Auth$Login",

@ -21,6 +21,7 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.tomcat.util.http.fileupload.IOUtils; import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.opsli.api.base.result.ResultVo; import org.opsli.api.base.result.ResultVo;
import org.opsli.core.autoconfigure.properties.GlobalProperties;
import org.opsli.core.utils.ValidationUtil; import org.opsli.core.utils.ValidationUtil;
import org.opsli.api.wrapper.system.tenant.TenantModel; import org.opsli.api.wrapper.system.tenant.TenantModel;
import org.opsli.api.wrapper.system.user.UserModel; import org.opsli.api.wrapper.system.user.UserModel;
@ -38,6 +39,7 @@ import org.opsli.core.security.shiro.realm.JwtRealm;
import org.opsli.core.utils.*; import org.opsli.core.utils.*;
import org.opsli.modulars.system.login.entity.LoginForm; import org.opsli.modulars.system.login.entity.LoginForm;
import org.opsli.modulars.system.user.service.IUserService; import org.opsli.modulars.system.user.service.IUserService;
import org.opsli.plugins.redis.RedisPlugin;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -68,7 +70,6 @@ public class LoginRestController {
@Autowired @Autowired
private IUserService iUserService; private IUserService iUserService;
/** /**
* *
*/ */
@ -76,7 +77,8 @@ public class LoginRestController {
@InterfaceEncryptAndDecrypt(responseEncrypt = false) @InterfaceEncryptAndDecrypt(responseEncrypt = false)
@ApiOperation(value = "登录", notes = "登录") @ApiOperation(value = "登录", notes = "登录")
@PostMapping("/sys/login") @PostMapping("/sys/login")
public ResultVo<?> login(@RequestBody LoginForm form, HttpServletRequest request){ public ResultVo<UserTokenUtil.TokenRet> login(@RequestBody LoginForm form, HttpServletRequest request){
// 非空验证 // 非空验证
if(form == null){ if(form == null){
throw new TokenException(TokenMsg.EXCEPTION_LOGIN_NULL); throw new TokenException(TokenMsg.EXCEPTION_LOGIN_NULL);
@ -92,7 +94,7 @@ public class LoginRestController {
long slipCount = UserTokenUtil.getSlipCount(form.getUsername()); long slipCount = UserTokenUtil.getSlipCount(form.getUsername());
// 失败次数超过 验证次数阈值 开启验证码验证 // 失败次数超过 验证次数阈值 开启验证码验证
if(slipCount >= UserTokenUtil.ACCOUNT_SLIP_VERIFY_COUNT){ if(slipCount >= UserTokenUtil.LOGIN_PROPERTIES.getSlipVerifyCount()){
CaptchaUtil.validate(form.getUuid(), form.getCaptcha()); CaptchaUtil.validate(form.getUuid(), form.getCaptcha());
} }
@ -124,13 +126,13 @@ public class LoginRestController {
} }
// 失败次数超过 验证次数阈值 开启验证码验证 // 失败次数超过 验证次数阈值 开启验证码验证
if(slipCount >= UserTokenUtil.ACCOUNT_SLIP_VERIFY_COUNT){ if(slipCount >= UserTokenUtil.LOGIN_PROPERTIES.getSlipVerifyCount()){
// 删除验证过后验证码 // 删除验证过后验证码
CaptchaUtil.delCaptcha(form.getUuid()); CaptchaUtil.delCaptcha(form.getUuid());
} }
//生成token并保存到Redis //生成token并保存到Redis
ResultVo<Map<String, Object>> resultVo = UserTokenUtil.createToken(user); ResultVo<UserTokenUtil.TokenRet> resultVo = UserTokenUtil.createToken(user);
if(resultVo.isSuccess()){ if(resultVo.isSuccess()){
// 异步保存IP // 异步保存IP
AsyncProcessQueueReFuse.execute(()->{ AsyncProcessQueueReFuse.execute(()->{
@ -170,7 +172,7 @@ public class LoginRestController {
// 获得当前失败次数 // 获得当前失败次数
long slipCount = UserTokenUtil.getSlipCount(username); long slipCount = UserTokenUtil.getSlipCount(username);
Map<String, Object> ret = Maps.newHashMap(); Map<String, Object> ret = Maps.newHashMap();
ret.put("base", UserTokenUtil.ACCOUNT_SLIP_VERIFY_COUNT); ret.put("base", UserTokenUtil.LOGIN_PROPERTIES.getSlipVerifyCount());
ret.put("curr", slipCount); ret.put("curr", slipCount);
return ResultVo.success(ret); return ResultVo.success(ret);
} }
@ -212,7 +214,7 @@ public class LoginRestController {
); );
} }
// =================
public static void main(String[] args) { public static void main(String[] args) {
String passwordStr = "Aa123456"; String passwordStr = "Aa123456";

@ -198,6 +198,10 @@ opsli:
# 登录设置 # 登录设置
login: login:
# 限制登录数量 -1 为无限大
limit-count: 4
# 限制登录拒绝策略 after为后者 before为前者
limit-refuse: after
# 失败次数 # 失败次数
slip-count: 5 slip-count: 5
# 失败N次后弹出验证码 (超过验证码阈值 弹出验证码) # 失败N次后弹出验证码 (超过验证码阈值 弹出验证码)

Loading…
Cancel
Save