参考文章:
单点登录:https://mp.weixin.qq.com/s/DGFFPl93kZxS5G_DSFTBDA
多端登录:https://blog.csdn.net/zhourenfei17/article/details/88826911
最近一个springboot项目要求增加一些app的功能。然后首先要改的就是这个登录的功能。我原本的登录就只是web端登录,实现了单点登录,就是同一个用户只能同时登录一次,如果再次登录的话,会清除上一次的登录信息。(具体实现参考上面的单点登录链接)
现在加了app的话,那就得实现app和web可以同时在线,如果登录了app,web就强制下线,或者登录了web,app就强制下线的,这用户体验很不好,所以得在单点登录的基础上加上多端同时在线。
区分是app登录还是pc登录,就是通过User-Agent来区分的。这个User-Agent可以和前端约定好,比如如果是移动端的登录请求的话,User-Agent的值就是mobile,如果是PC端的请求的话,User-Agent的值就是web。
我一开始想的是这两个终端登录的时候,分别存储不同的sessionID,比如app登录,生成的token,前缀带有mobile,web登录,生成的token前缀带有web。
在踢人的时候,根据上次登录的token,获取到前缀,去和当前登录的登录类型进行对比,如果一致说明是同一终端,就把上次的登录信息清除;如果不一致,说明不是同一终端,对上一次的登录不进行操作。
我生成sessionid用的是自定义的SessionId生成器,然后在shiroConfig里配置。
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.eis.JavaUuidSessionIdGenerator;
import org.apache.shiro.session.mgt.eis.SessionIdGenerator;
import java.io.Serializable;
/**
* 自定义SessionId生成器
*/
public class ShiroSessionIdGenerator implements SessionIdGenerator {
/**
* 实现SessionId生成
*/
@Override
public Serializable generateId(Session session) {
Serializable sessionId = new JavaUuidSessionIdGenerator().generateId(session);
//在这里生成sessionid的时候,在"login_token_"这个token前缀前面,再加个登录类型的前缀
//比如:mobile_login_token_ 或者 web_login_token_
return String.format("login_token_%s", sessionId);
}
}
可是我发现,我在这里一时找不到办法来获取登录类型,那这种通过sessionid的方式来区分终端的办法就行不通。后面我找了另一种方法,就是通过定义不同登录方式的Realm来进行区分,就不对sessionid进行其他处理了。
org.springframework.boot
spring-boot-starter-aop
org.springframework.boot
spring-boot-starter-data-redis-reactive
org.apache.shiro
shiro-spring
1.4.0
org.crazycake
shiro-redis
3.1.0
spring:
# 配置Redis
redis:
host: localhost
port: 6379
timeout: 6000 #以秒为单位
password: 123456
database: 0
lettuce:
pool:
max-active: -1
max-wait: -1
max-idle: 16
min-idle: 8
shiro授权和身份认证的话,因为要区分移动端和pc端,所以另外多加两个分别验证移动端和PC端的Realm。
(1)、ShiroRealm类
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 统一的Shiro权限匹配和账号密码匹配
*/
public class ShiroRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
@Autowired
private RoleService roleService;
@Autowired
private MenuService menuService;
/**
* 授权权限
* 用户进行权限验证时候Shiro会去缓存中找,如果查不到数据,会执行这个方法去查权限,并放入缓存中
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
User user = (User) principalCollection.getPrimaryPrincipal();
//这里可以进行授权和处理
Set rolesSet = new HashSet<>();
Set permsSet = new HashSet<>();
//查询角色和权限(这里根据业务自行查询)
List roleList = roleService.selectRoleByUserId(user);
for (Role role:roleList) {
rolesSet.add(role.getRoleName());
List
(2)、MobileShiroRealm类
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.ByteSource;
import org.springframework.beans.factory.annotation.Autowired;
/**
* app端登录的Realm管理
*/
public class MobileShiroRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
/**
* 授权权限
* 在ShiroRealm统一处理,这里不做处理
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}
/**
* 身份认证
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
//获取用户的输入的账号.
String loginName = (String) authenticationToken.getPrincipal();
//通过登录名从数据库中查找 User对象,如果找到进行验证
User user = userService.getUserByName(loginName);
//判断账号是否存在
if (user == null ) {
throw new AuthenticationException();
}
//状态为2表示已删除(或者已锁定,根据自己的用户表的实际情况而定)
if ("2".equals(user.getStatus())){
throw new AuthenticationException();
}
//进行验证
SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
user, //用户名
user.getPassword(), //密码
ByteSource.Util.bytes(user.getSalt()),//密码盐值
getName()
);
//验证成功开始踢人(清除缓存和Session)
ShiroUtils.deleteCache(loginName,"mobile");
return authenticationInfo;
}
}
(3)、WebShiroRealm类
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.ByteSource;
import org.springframework.beans.factory.annotation.Autowired;
/**
* web端登录的Realm管理
*/
public class WebShiroRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
/**
* 授权权限
* 在ShiroRealm统一处理,这里不做处理
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}
/**
* 身份认证
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
//获取用户的输入的账号.
String loginName = (String) authenticationToken.getPrincipal();
//通过登录名从数据库中查找 User对象,如果找到进行验证
User user = userService.getUserByName(loginName);
//判断账号是否存在
if (user == null ) {
throw new AuthenticationException();
}
//状态为2表示已删除
if ("2".equals(user.getStatus())){
throw new AuthenticationException();
}
//进行验证
SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
user, //用户名
user.getPassword(), //密码
ByteSource.Util.bytes(user.getSalt()),//密码盐值
getName()
);
//验证成功开始踢人(清除缓存和Session)
ShiroUtils.deleteCache(loginName,"web");
return authenticationInfo;
}
}
(4)、ShiroModularRealmAuthenticator类
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.pam.ModularRealmAuthenticator;
import org.apache.shiro.realm.Realm;
import java.util.Collection;
import java.util.HashMap;
/**
* 自定义的Realm管理,主要针对多realm
*/
public class ShiroModularRealmAuthenticator extends ModularRealmAuthenticator {
@Override
protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
// 判断getRealms()是否返回为空
assertRealmsConfigured();
// 所有Realm
Collection realms = getRealms();
// 登录类型对应的所有Realm。要注意,对应的登录类型的Realm命名,必须要包含你指定的登录类型的字符。
//比如,你指定的区分登录类型的字符串是:mobile和web,那么这两个登录的Realm类的命名,必须包含mobile或者web字符。
HashMap realmHashMap = new HashMap<>(realms.size());
for (Realm realm : realms) {
realmHashMap.put(realm.getName(), realm);
}
UsernamePasswordLoginTypeToken token = (UsernamePasswordLoginTypeToken) authenticationToken;
// 登录类型
String type = token.getLoginType();
if (realmHashMap.get(type) != null) {
return doSingleRealmAuthentication(realmHashMap.get(type), token);
} else {
return doMultiRealmAuthentication(realms, token);
}
}
}
(5)、UsernamePasswordLoginTypeToken类
import org.apache.shiro.authc.UsernamePasswordToken;
/**
* 重写UsernamePasswordToken方法,增加登录类型(是app端还是pc端)
*/
public class UsernamePasswordLoginTypeToken extends UsernamePasswordToken {
/**
*登陆类型
*/
private String loginType;
public UsernamePasswordLoginTypeToken(String username, String password, String loginType) {
super(username, password);
this.loginType = loginType;
}
public String getLoginType() {
return loginType;
}
public void setLoginType(String loginType) {
this.loginType = loginType;
}
}
(6)、ShiroConfig类
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.authc.pam.AtLeastOneSuccessfulStrategy;
import org.apache.shiro.cache.CacheManager;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.crazycake.shiro.RedisCacheManager;
import org.crazycake.shiro.RedisManager;
import org.crazycake.shiro.RedisSessionDAO;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Shiro配置类
*/
@Configuration
public class ShiroConfig {
private final String CACHE_KEY = "shiro:cache:";
private final String SESSION_KEY = "shiro:session:";
//Redis配置
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Value("${spring.redis.timeout}")
private int timeout;
@Value("${spring.redis.password}")
private String password;
/**
* 开启Shiro-aop注解支持
* @Attention 使用代理方式所以需要开启代码支持
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
/**
* 开启Shiro的注解(如@RequiresRoles,@RequiresPermissions)
*/
/*@Bean
@ConditionalOnMissingBean
public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator(){
DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
advisorAutoProxyCreator.setProxyTargetClass(true);
return advisorAutoProxyCreator;
}*/
/**
* Shiro基础配置
*/
@Bean
public ShiroFilterFactoryBean shiroFilterFactory(SecurityManager securityManager){
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
Map filterChainDefinitionMap = new LinkedHashMap<>();
// 注意过滤器配置顺序不能颠倒
// 配置过滤:不会被拦截的链接
filterChainDefinitionMap.put("/swagger-ui.html", "anon");
filterChainDefinitionMap.put("/swagger/**", "anon");
filterChainDefinitionMap.put("/swagger-resources/**", "anon");
filterChainDefinitionMap.put("/v2/**", "anon");
filterChainDefinitionMap.put("/webjars/**", "anon");
filterChainDefinitionMap.put("/static/**", "anon");
filterChainDefinitionMap.put("/uploads/**", "anon");
filterChainDefinitionMap.put("/user/login", "anon");
filterChainDefinitionMap.put("/user/unauth", "anon");
filterChainDefinitionMap.put("/**", "authc");
// 配置shiro默认登录界面地址,前后端分离中登录界面跳转应由前端路由控制,后台仅返回json数据
shiroFilterFactoryBean.setLoginUrl("/user/unauth");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
/**
* 安全管理器
*/
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setAuthenticator(modularRealmAuthenticator());
// 自定义Ssession管理
securityManager.setSessionManager(sessionManager());
// 自定义Cache实现
securityManager.setCacheManager(cacheManager());
List realms = new ArrayList<>();
// 统一角色权限控制realm
realms.add(shiroRealm());
// app登录realm
realms.add(mobileShiroRealm());
// web登录realm
realms.add(webShiroRealm());
securityManager.setRealms(realms);
return securityManager;
}
/**
* 自定义的Realm管理,主要针对多realm
*/
@Bean("myModularRealmAuthenticator")
public ShiroModularRealmAuthenticator modularRealmAuthenticator() {
ShiroModularRealmAuthenticator customizedModularRealmAuthenticator = new ShiroModularRealmAuthenticator();
// 设置realm判断条件
customizedModularRealmAuthenticator.setAuthenticationStrategy(new AtLeastOneSuccessfulStrategy());
return customizedModularRealmAuthenticator;
}
/**
* 统一的身份验证器
*/
@Bean
public ShiroRealm shiroRealm() {
ShiroRealm shiroRealm = new ShiroRealm();
shiroRealm.setName("web");
return shiroRealm;
}
/**
* app端的身份验证器
*/
@Bean
public MobileShiroRealm mobileShiroRealm() {
MobileShiroRealm shiroRealm = new MobileShiroRealm();
shiroRealm.setName(UserConstant.APP);
//自定义的密码验证器
shiroRealm.setCredentialsMatcher(shiroRetryLimit(cacheManager()));
return shiroRealm;
}
/**
* web端的身份验证器
*/
@Bean
public WebShiroRealm webShiroRealm() {
WebShiroRealm shiroRealm = new WebShiroRealm();
shiroRealm.setName(UserConstant.WEB);
//自定义的密码验证器
shiroRealm.setCredentialsMatcher(shiroRetryLimit(cacheManager()));
return shiroRealm;
}
/**
* 配置Redis管理器
* @Attention 使用的是shiro-redis开源插件
*/
@Bean
public RedisManager redisManager() {
RedisManager redisManager = new RedisManager();
redisManager.setHost(host);
redisManager.setPort(port);
redisManager.setTimeout(timeout);
redisManager.setPassword(password);
return redisManager;
}
/**
* 配置Cache管理器
* 用于往Redis存储权限和角色标识
* @Attention 使用的是shiro-redis开源插件
*/
@Bean("ehCacheManager")
public RedisCacheManager cacheManager() {
RedisCacheManager redisCacheManager = new RedisCacheManager();
redisCacheManager.setRedisManager(redisManager());
redisCacheManager.setKeyPrefix(CACHE_KEY);
// 配置缓存的话要求放在session里面的实体类必须有个id标识
redisCacheManager.setPrincipalIdFieldName("userId");
return redisCacheManager;
}
/**
* 自定义的SessionID生成器
*/
@Bean
public ShiroSessionIdGenerator sessionIdGenerator(){
return new ShiroSessionIdGenerator();
}
/**
* 配置RedisSessionDAO
* @Attention 使用的是shiro-redis开源插件
*/
@Bean
public RedisSessionDAO redisSessionDAO() {
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
redisSessionDAO.setRedisManager(redisManager());
redisSessionDAO.setSessionIdGenerator(sessionIdGenerator());
redisSessionDAO.setKeyPrefix(SESSION_KEY);
redisSessionDAO.setExpire(timeout);
return redisSessionDAO;
}
/**
* 配置Session管理器
*/
@Bean
public SessionManager sessionManager() {
//自定义的Session管理器,获取token
ShiroSessionManager shiroSessionManager = new ShiroSessionManager();
shiroSessionManager.setSessionDAO(redisSessionDAO());
return shiroSessionManager;
}
/**
* 自定义的密码验证器
*/
@Bean("shiroRetryLimit")
public ShiroRetryLimit shiroRetryLimit(CacheManager cacheManager){
ShiroRetryLimit shiroRetryLimit = new ShiroRetryLimit(cacheManager);
// 散列算法:这里使用SHA256算法;
shiroRetryLimit.setHashAlgorithmName(SHA256Util.HASH_ALGORITHM_NAME);
// 散列的次数,比如散列两次,相当于 md5(md5(""));
shiroRetryLimit.setHashIterations(SHA256Util.HASH_ITERATIONS);
return shiroRetryLimit;
}
}
(7)、ShiroUtils类
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.Authenticator;
import org.apache.shiro.authc.LogoutAware;
import org.apache.shiro.session.Session;
import org.apache.shiro.subject.SimplePrincipalCollection;
import org.apache.shiro.subject.support.DefaultSubjectContext;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.crazycake.shiro.RedisSessionDAO;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
/**
* Shiro工具类
*/
public class ShiroUtils {
/** 私有构造器 **/
private ShiroUtils(){ }
private static RedisSessionDAO redisSessionDAO = SpringUtil.getBean(RedisSessionDAO.class);
private static RoleService roleService = SpringUtil.getBean(RoleService.class);
/**
* 获取当前用户Session
*/
public static Session getSession() {
return SecurityUtils.getSubject().getSession();
}
/**
* 用户退出登录
*/
public static void logout() {
SecurityUtils.getSubject().logout();
}
/**
* 获取当前用户信息
*/
public static User getUserInfo() {
return (User) SecurityUtils.getSubject().getPrincipal();
}
/**
* 获取当前用户id
*/
public static Integer getUserId() {
return getUserInfo().getUserId();
}
/**
* 删除用户缓存信息
* @Param loginName 用户登录名称
* @Param loginType 当前登录的登录方式(mobile 移动端登录,web PC端登录)
*/
public static void deleteCache(String loginName, String loginType){
//从缓存中获取Session
Session session = null;
Collection sessions = redisSessionDAO.getActiveSessions();
User sysUserEntity;
Object attribute = null;
for(Session sessionInfo : sessions){
//遍历Session,找到该用户名称对应的Session
attribute = sessionInfo.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
if (attribute == null) {
continue;
}
System.out.println(((SimplePrincipalCollection) attribute).getPrimaryPrincipal());
sysUserEntity = (User) ((SimplePrincipalCollection) attribute).getPrimaryPrincipal();
if (sysUserEntity == null) {
continue;
}
if (Objects.equals(sysUserEntity.getUserName(), loginName)) {
session=sessionInfo;
break;
}
}
if (session == null||attribute == null) {
return;
}
/**
* 根据当前登录类型,在attribute中查找上一次登录的Realm,
* 1、如果有值,说明当前登录的终端和上一次登录的终端相同,则删除上一次登录的session(清空上一次的登录信息,重新登录)。
* 2、如果没有值,说明当前登录的终端和上一次登录的终端不一样,不对上一次登录的session删除,实现app端和web端可同时在线
*/
Collection collection = ((SimplePrincipalCollection) attribute).fromRealm(loginType);
if (collection.size()>0){
//删除session
redisSessionDAO.delete(session);
}
//删除Cache,在访问受限接口时会重新授权
DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager) SecurityUtils.getSecurityManager();
Authenticator authc = securityManager.getAuthenticator();
((LogoutAware) authc).onLogout((SimplePrincipalCollection) attribute);
}
}
在controller层中,通过请求头的User-Agent 来区分登录终端是移动端还是PC端,这个User-Agent可以和前端约定好,比如如果是移动端的登录请求的话,User-Agent的值就是mobile,如果是PC端的请求的话,User-Agent的值就是web。还有其他登录方式,根据自己的实际情况而定。
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.AllArgsConstructor;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.LockedAccountException;
import org.apache.shiro.authz.annotation.RequiresUser;
import org.apache.shiro.subject.Subject;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.*;
/**
* 登录
*/
@RestController
@RequestMapping("/user")
public class LoginController {
/**
* 登录
*/
@RequestMapping("/login")
public ResultVo login(@RequestBody User user, HttpServletRequest request){
Map map = new HashMap<>();
//进行身份验证
try{
//验证身份和登陆
Subject subject = SecurityUtils.getSubject();
//获取登录的终端
String loginType = request.getHeader("User-Agent");
UsernamePasswordLoginTypeToken token = new UsernamePasswordLoginTypeToken(user.getUserName(), user.getPassword(), loginType);
//进行登录操作
subject.login(token);
}catch (IncorrectCredentialsException e) {
return ResultVoUtil.error(1000,e.getMessage());
} catch (LockedAccountException e) {
return ResultVoUtil.error(1004,e.getMessage());
} catch (AuthenticationException e) {
return ResultVoUtil.error(ResultEnum.USER_NOT_ERROR);
} catch (Exception e) {
return ResultVoUtil.error(ResultEnum.UNKNOWN_EXCEPTION);
}
map.put("code",0);
map.put("msg","登录成功");
map.put("token", ShiroUtils.getSession().getId().toString());
return ResultVoUtil.success(map);
}
/**
* 未登录
*/
@ApiOperation("未登录")
@RequestMapping("/unauth")
public ResultVo unauth(){
return ResultVoUtil.error(ResultEnum.USER_NOT_LOGIN);
}
/**
* 登出
*/
@ApiOperation("退出登录")
@RequestMapping("/logout")
@RequiresUser
@Log(operMethod = "退出登录",operInfo = "退出登录")
public ResultVo logout(){
//登出Shiro会帮我们清理掉Session和Cache
ShiroUtils.logout();
return ResultVoUtil.success(0,"退出登录成功");
}
}
文中还用到的一些其他配置类和工具类,完整的可以看我开头放的单点登录的那篇链接,这里我是在这篇链接上的单点登录的基础上,再加了移动端和PC端同时在线:如果登录终端是同一个的话,就会踢掉上一次的登录,不同的话,则多个终端可同时在线。