shiro登录参考链接
https://juejin.cn/post/6880872387416588295
https://juejin.cn/post/6881493214650433549
shiro学习参考链接
https://www.cnblogs.com/bingfengdev/p/13768829.html
https://www.jianshu.com/p/c15da4e85734
https://www.w3cschool.cn/shiro/co4m1if2.html
关于加盐和MD5加密:
https://juejin.cn/post/6879761189845991438
shiro理解
主体是subject,注入securityManagement来管理,在securityManagement中注入自己实现的Realm来获取用户信息进行验证相关操作
在 shiro 中,用户需要提供
principals
(身份)和credentials
(证明)给 shiro,从而应用能验证用户身份:principals:身份,即主体的标识属性,一般是用户名 / 密码 / 手机号。
credentials:证明 / 凭证,即只有主体知道的安全值,如密码 / 数字证书等。
最常见的
principals
和credentials
组合就是用户名 / 密码了。
Subject
及Realm
,分别是主体及验证主体的数据源。
subject.login
方法进行登录,其会自动委托给SecurityManager.login
方法进行登录;
subject.logout
退出,其会自动委托给SecurityManager.logout
方法退出。Realm:域,
Shiro
从Realm
获取安全数据(如用户、角色、权限),就是说SecurityManager
要验证用户身份,那么它需要从Realm
获取相应的用户进行比较以确定用户身份是否合法;也需要从Realm
得到用户相应的角色 / 权限进行验证用户是否能进行操作;可以把Realm
看成DataSource
,即安全数据源。String getName(); //返回一个唯一的Realm名字 boolean supports(AuthenticationToken token); //判断此Realm是否支持此类型的Token AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException; //根据Token获取认证信息
自定义Realm需要继承AuthorizingRealm 需要重写两个方法。doGetAuthenticationInfo方法是通过查询数据库的用户信息,返回一个SimpleAuthenticationInfo来实现登陆信息认证。
protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException { CredentialsMatcher cm = this.getCredentialsMatcher(); if (cm != null) { if (!cm.doCredentialsMatch(token, info)) { String msg = "Submitted credentials for token [" + token + "] did not match the expected credentials."; throw new IncorrectCredentialsException(msg); } } else { throw new AuthenticationException("A CredentialsMatcher must be configured in order to verify credentials during authentication. If you do not wish for credentials to be examined, you can configure an " + AllowAllCredentialsMatcher.class.getName() + " instance."); }
这是AuthenticatingRealm的一个方法,入参示是登陆时前端传过来的登陆信息和我们自定义Realm返回的信息。通过 this.getredentialMatcher() 获取到一个CredentialsMatcher对象,如果我们不设置的话,默认是使用的SimpleCredentialsMatcher。进入到doCredentialsMatch方法:
protected boolean equals(Object tokenCredentials, Object accountCredentials) { if (log.isDebugEnabled()) { log.debug("Performing credentials equality check for tokenCredentials of type [" + tokenCredentials.getClass().getName() + " and accountCredentials of type [" + accountCredentials.getClass().getName() + "]"); } if (this.isByteSource(tokenCredentials) && this.isByteSource(accountCredentials)) { if (log.isDebugEnabled()) { log.debug("Both credentials arguments can be easily converted to byte arrays. Performing array equals comparison"); } byte[] tokenBytes = this.toBytes(tokenCredentials); byte[] accountBytes = this.toBytes(accountCredentials); return MessageDigest.isEqual(tokenBytes, accountBytes); } else { return accountCredentials.equals(tokenCredentials); } } public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) { Object tokenCredentials = this.getCredentials(token); Object accountCredentials = this.getCredentials(info); return this.equals(tokenCredentials, accountCredentials); }
项目数据库是存储的加密的密码,使用的是BCryptPasswordEncoder来进行hash加密的,是需要用他的BCryptPasswordEncoder::matches(String plainPass,String encodePass) 来验证密码,所以可以自己重写一个matcher,代码如下:
public class CustomerCredentialsMatcher implements CredentialsMatcher { PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); @Override public boolean doCredentialsMatch(AuthenticationToken authenticationToken, AuthenticationInfo authenticationInfo) { //前端传来的密码 String currentPass = String.copyValueOf((char[]) authenticationToken.getCredentials()); //数据库密码 String dbPass = (String) authenticationInfo.getCredentials(); return passwordEncoder.matches(currentPass,dbPass); } }
在自己的Realm里设置一下我们自己的matcher:
public class CustRealm extends AuthorizingRealm { ... @Override public void setCredentialsMatcher(CredentialsMatcher credentialsMatcher) { CustomerCredentialsMatcher customerCredentialsMatcher = new CustomerCredentialsMatcher(); super.setCredentialsMatcher(customerCredentialsMatcher); } ... }
自定义realm
-
授权;doGetAuthorizationInfo方法是在我们调用;SecurityUtils.getSubject().isPermitted()这个方法,授权后用户角色及权限会保存在缓存中的
-
认证,登录,doGetAuthenticationInfo这个方法是在用户登录的时候调用的;也就是执行SecurityUtils.getSubject().login()的时候调用;(即:登录验证),验证通过后会用户保存在缓存中的
在doGetAuthenticationInfo方法里最后返回SimpleAuthenticationInfo对象,生成 AuthenticationInfo 信息,交给间接父类 AuthenticatingRealm 使用 CredentialsMatcher 进行判断密码是否匹配,需要传入:身份信息(用户名)、凭据(密文密码)、盐(username+salt),CredentialsMatcher 使用盐加密传入的明文密码和此处的密文密码进行匹配。
new SimpleAuthenticationInfo(user, user.getPassWord(), credentialsSalt, this.getClass().getName());
// 第三个参数一般可以是ByteSource.Util.bytes(shiroUser.getUserName()+shiroPasswordService.getPublicSalt())
在父类AuthenticatingRealm中的getAuthenticationInfo中会调用自定义realm的doGetAuthenticationInfo方法获取对应的SimpleAuthenticationInfo对象,然后调用this.assertCredentialsMatch(token, info);来比对token里的用户信息和SimpleAuthenticationInfo中得到的数据库里用户信息,在该方法里调用getCredentialsMatcher方法去获取对应的匹配算法,然后执行cm.doCredentialsMatch(token, info)进行实际的比对
AuthenticatingRealm中的getAuthenticationInfo方法在什么时候执行呢?(该方法的回调一般是通过subject.login(token)方法来实现的)
AuthenticatingFilter在执行executeLogin方法的时候会执行AuthenticationToken token = this.createToken(request, response);来生成token,这里可以继承该filter来实现自定义的生成token的方法也可以使用默认的,然后调用
Subject subject = this.getSubject(request, response); subject.login(token);
在subject.login(token)里会调用AuthenticatingRealm中的getAuthenticationInfo方法,在该方法里调用自定义realm的doGetAuthenticationInfo方法获取对应的SimpleAuthenticationInfo对象
Shiro 拦截器机制
-
ShiroFilter 是整个 Shiro 的入口点,用于拦截需要安全控制的请求进行处理
-
AdviceFilter 提供了 AOP 风格的支持,类似于 SpringMVC 中的 Interceptor:
boolean preHandle(ServletRequest request, ServletResponse response) throws Exception void postHandle(ServletRequest request, ServletResponse response) throws Exception void afterCompletion(ServletRequest request, ServletResponse response, Exception exception) throws Exception;
- preHandler:类似于 AOP 中的前置增强;在拦截器链执行之前执行;如果返回 true 则继续拦截器链;否则中断后续的拦截器链的执行直接返回;进行预处理(如基于表单的身份验证、授权)
- postHandle:类似于 AOP 中的后置返回增强;在拦截器链执行完成后执行;进行后处理(如记录执行时间之类的);
- afterCompletion:类似于 AOP 中的后置最终增强;即不管有没有异常都会执行;可以进行清理资源(如解除 Subject 与线程的绑定之类的);
-
AccessControlFilter 提供了访问控制的基础功能;比如是否允许访问/当访问拒绝时如何处理等:
上边的AuthenticatingFilter继承的AuthenticationFilter父类就是继承的AccessControlFilter,在该抽象类里定义了getSubject方法通过SecurityUtils.getSubject();实现
abstract boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception; boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception; abstract boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception;
isAccessAllowed:表示是否允许访问;mappedValue 就是[urls]配置中拦截器参数部分,如果允许访问返回 true,否则 false;
onAccessDenied:表示当访问拒绝时是否已经处理了;如果返回 true 表示需要继续处理;如果返回 false 表示该拦截器实例已经处理了,将直接返回即可。
onPreHandle 会自动调用这两个方法决定是否继续处理;
AccessControlFilter 还提供了如下方法用于处理如登录成功后/重定向到上一个请求:
void setLoginUrl(String loginUrl) //身份验证时使用,默认/login.jsp String getLoginUrl() Subject getSubject(ServletRequest request, ServletResponse response) //获取Subject 实例 boolean isLoginRequest(ServletRequest request, ServletResponse response)//当前请求是否是登录请求 void saveRequestAndRedirectToLogin(ServletRequest request, ServletResponse response) throws IOException //将当前请求保存起来并重定向到登录页面 void saveRequest(ServletRequest request) //将请求保存起来,如登录成功后再重定向回该请求 void redirectToLogin(ServletRequest request, ServletResponse response) //重定向到登录页面
如果我们想进行访问访问的控制就可以继承 AccessControlFilter;如果我们要添加一些通用数据我们可以直接继承 PathMatchingFilter。
拦截器链
Shiro 对 Servlet 容器的 FilterChain 进行了代理,即 ShiroFilter 在继续 Servlet 容器的 Filter 链的执行之前,通过 ProxiedFilterChain 对 Servlet 容器的 FilterChain 进行了代理;即先走 Shiro 自己的 Filter 体系,然后才会委托给 Servlet 容器的 FilterChain 进行 Servlet 容器级别的 Filter 链执行;Shiro 的 ProxiedFilterChain 执行流程:1、先执行 Shiro 自己的 Filter 链;2、再执行 Servlet 容器的 Filter 链(即原始的 Filter)。
而 ProxiedFilterChain 是通过 FilterChainResolver 根据配置文件中[urls]部分是否与请求的 URL 是否匹配解析得到的。
FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain);
即传入原始的 chain 得到一个代理的 chain。
Shiro 内部提供了一个路径匹配的 FilterChainResolver 实现:PathMatchingFilterChainResolver,其根据[urls]中配置的 url 模式(默认 Ant 风格)=拦截器链和请求的 url 是否匹配来解析得到配置的拦截器链的;而 PathMatchingFilterChainResolver 内部通过 FilterChainManager 维护着拦截器链,比如 DefaultFilterChainManager 实现维护着 url 模式与拦截器链的关系。因此我们可以通过 FilterChainManager 进行动态动态增加 url 模式与拦截器链的关系。
这里可能就是对shiro的map里put的路径进行的拦截设计
会话管理
登录成功后使用 Subject.getSession() 即可获取会话;其等价于 Subject.getSession(true),即如果当前没有创建 Session 对象会创建一个;另外 Subject.getSession(false),如果当前没有创建 Session 则返回 null(不过默认情况下如果启用会话存储功能的话在创建 Subject 时会主动创建一个 Session)。
如果是 Web 应用,每次进入 ShiroFilter 都会自动调用 session.touch() 来更新最后访问时间。
当 Subject.logout() 时会自动调用 stop 方法来销毁会话。
会话管理器
顶层组件 SecurityManager 直接继承了 SessionManager,且提供了SessionsSecurityManager 实现直接把会话管理委托给相应的 SessionManager
在 Servlet 容器中,默认使用 JSESSIONID Cookie 维护会话,且会话默认是跟容器绑定的;
会话存储 / 持久化
Shiro 提供 SessionDAO 用于会话的 CRUD,即 DAO(Data Access Object)模式实现:
//如DefaultSessionManager在创建完session后会调用该方法;如保存到关系数据库/文件系统/NoSQL数据库;即可以实现会话的持久化;返回会话ID;主要此处返回的ID.equals(session.getId());
Serializable create(Session session);
//根据会话ID获取会话
Session readSession(Serializable sessionId) throws UnknownSessionException;
//更新会话;如更新会话最后访问时间/停止会话/设置超时时间/设置移除属性等会调用
void update(Session session) throws UnknownSessionException;
//删除会话;当会话过期/会话停止(如用户退出时)会调用
void delete(Session session);
//获取当前所有活跃用户,如果用户量多此方法影响性能
Collection<Session> getActiveSessions();
如果自定义实现 SessionDAO,继承 CachingSessionDAO 即可:
Realm 缓存
Shiro 提供了 CachingRealm,其实现了 CacheManagerAware 接口,提供了缓存的一些基础实现;另外 AuthenticatingRealm 及 AuthorizingRealm 分别提供了对 AuthenticationInfo 和 AuthorizationInfo 信息的缓存。
总体流程的梳理
-
//4. 获取当前主题 Subject subject = SecurityUtils.getSubject();
-
//5.根据登录对象身份凭证信息创建登录令牌 UsernamePasswordToken token = new UsernamePasswordToken(username,password);
-
认证:
subject.login(token);
在该方法中经历了以下流程:
进入了DelegatingSubject.login方法:
在该方法中还是调用了securityManager的login方法,真正的认证操作还是由安全管理器对象securityManager执行。:
// 1. 真正做认证的还是securityManager对象 Subject subject = this.securityManager.login(this, token);
接着进入到了进入到securityManager的login方法当中:
在该方法里调用了authenticate(token)方法:
//调用认证方法 info = this.authenticate(token);
最终经过多层嵌套进入到了ModularRealmAuthenticator认证器对象的doAuthenticate方法
public class ModularRealmAuthenticator extends AbstractAuthenticator { protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException { this.assertRealmsConfigured(); Collection<Realm> realms = this.getRealms(); return realms.size() == 1 ? /**终于到了真正的认证逻辑*/ this.doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken) : this.doMultiRealmAuthentication(realms, authenticationToken); } }
检验我们的Realms对象创建后,开始进入到doSingleRealmAuthentication方法当中进行认证操作
protected AuthenticationInfo doSingleRealmAuthentication(Realm realm, AuthenticationToken token) { if (!realm.supports(token)) { String msg = "Realm [" + realm + "] does not support authentication token [" + token + "]. Please ensure that the appropriate Realm implementation is configured correctly or that the realm accepts AuthenticationTokens of this type."; throw new UnsupportedTokenException(msg); } else { //获取认证信息 AuthenticationInfo info = realm.getAuthenticationInfo(token); if (info == null) { String msg = "Realm [" + realm + "] was unable to find account data for the submitted AuthenticationToken [" + token + "]."; throw new UnknownAccountException(msg); } else { return info; } } }
getAuthenticationInfo()方法
在这一步当中开始根据我们传入的令牌获取认证信息
public abstract class AuthenticatingRealm extends CachingRealm implements Initializable { public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { // 首先从缓存中获取 AuthenticationInfo info = this.getCachedAuthenticationInfo(token); if (info == null) { //缓存中没有,则从持久化数据中获取 info = this.doGetAuthenticationInfo(token); log.debug("Looked up AuthenticationInfo [{}] from doGetAuthenticationInfo", info); if (token != null && info != null) { this.cacheAuthenticationInfoIfPossible(token, info); } } else { log.debug("Using cached authentication info [{}] to perform credentials matching.", info); } if (info != null) { this.assertCredentialsMatch(token, info); } else { log.debug("No AuthenticationInfo found for submitted AuthenticationToken [{}]. Returning null.", token); } return info; } }
这里一般就是调用的自定义的realm里的doGetAuthenticationInfo方法,然后返回SimpleAuthenticationInfo(user, user.getPassWord(), credentialsSalt, this.getClass().getName());,接着回到了AuthenticatingRealm的getAuthenticationInfo方法中,调用assertCredentialsMatch方法开始校验用户凭证,在该方法里获取默认的或者自定义的匹配规则算法将token里的用户凭证和数据库里获取的进行匹配对比验证。
总结:在SimpleAccountRealm对象中的doGetAuthenticationInfo方法中完成账户验证,在AuthenticatingRealm的assertCredentialsMatch完成对用户凭证的校验。
在redis中缓存的key的形式:
shiro:cache:com.example.autohomingtest.config.MyShiroRealm.authenticationCache:username
三个键值分别对应认证信息缓存、授权信息缓存和会话信息缓存。
登录成功之后看到cookie里存储了对应的token
但是这里的token不是以login_token开头的,因为在redisSessionDAO里设置了key的前缀和过期时间,并且注册了redisManager和sessionIdGenerator,在sessionIdGenerator里定义了SessionId是以login_token开头的,而redisSessionDAO是在sessionManager()方法里注册的,但是最开始代码里并没有去注册sessionManager方法,所以token格式是上述形式,在securityManager方法里把sessionManager注册进去就可以了
@Bean
public SecurityManager securityManager(@Qualifier("myShiroRealm") MyShiroRealm myShiroRealm){
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
manager.setRealm(myShiroRealm);
manager.setCacheManager(getCacheManager());
// 这里是我自己增加的,不然sessionManager没有注册进去
manager.setSessionManager(sessionManager());
return manager;
}
结果截图:
注册:
先生成一个盐值,然后将用户填写的明文密码和该盐值一起拼接成之后通过md5散列得到一个新的值,将这个值作为数据库中的用户密码,并将盐值记录到数据库中,这样每次用户登录,就是将数据库中的盐值和用户填写的密码明文(token里获取)拼接通过md5散列得到一个值,看该值是否和数据库获取到的密码密文一样,一样说明正确
//三个参数分别对应密码明文、盐值、散列次数
String salt = UUID.randomUUID().toString();
Md5Hash md5Hash = new Md5Hash(user.getPassword(), salt,1024);
log.debug("密文:"+md5Hash.toHex());
log.debug("盐值:"+salt);
user.setPassword(md5Hash.toHex());
user.setSalt(salt);
userMapper.insert(user);
所谓的加盐就是在原密码的基础上,加上一段随机字符串。然后再加密。
后端跨域问题
新建一个过滤器实现WebMvcConfigurer接口即可:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(true)
.maxAge(3600)
.allowedHeaders("*");
}
}
前端跨域问题:
在vue.config.js文件里把上边的before: require('./mock/mock-server.js'),注释掉,并添加下边的代码
更改.dev.development文件里的VUE_APP_BASE_API
把utils文件夹里的request.js文件里的下边的code!=20000改为code!=200(这个看不同前端项目而定,如果这里不改,即使获取到了后台的代码,后台默认是200为正确的,这里前台判定是20000,前台就会报错,而不是返回后台的数据显示)
什么时候创建的token,怎么从token里获取用户信息
AuthenticatingFilter在执行executeLogin方法的时候会执行AuthenticationToken token = this.createToken(request, response);来生成token,这里可以继承该filter来实现自定义的生成token的方法也可以使用默认的,然后调用
Subject subject = this.getSubject(request, response);
subject.login(token);
在subject.login(token)里会调用AuthenticatingRealm中的getAuthenticationInfo方法,在该方法里调用自定义realm的doGetAuthenticationInfo方法获取对应的SimpleAuthenticationInfo对象
而在本项目的代码里是在如下时机利用用户名和密码创建的token:
Subject subject = SecurityUtils.getSubject();
UsernamePasswordToken token = new UsernamePasswordToken(user.getUsername(), user.getPassword());
subject.login(token);
在FormAuthenticationFilter类中通过用户名和密码作为参数创建token:
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) {
String username = this.getUsername(request);
String password = this.getPassword(request);
return this.createToken(username, password, request, response);
}
然后调用父类AuthenticatingFilter中的createToken方法:
protected AuthenticationToken createToken(String username, String password, ServletRequest request, ServletResponse response) {
boolean rememberMe = this.isRememberMe(request);
String host = this.getHost(request);
return this.createToken(username, password, rememberMe, host);
}
protected AuthenticationToken createToken(String username, String password, boolean rememberMe, String host) {
return new UsernamePasswordToken(username, password, rememberMe, host);
}
最后生成的也是UsernamePasswordToken
在本项目中登录的时候,将会话信息存储在redis中,并在用户认证通过后将会话Id以token的形式返回给用户。用户请求受保护资源时带上这个token,我们根据token信息去redis中获取用户的权限信息,从而做访问控制。
/**
* 登录方法
* 在调用了login方法后,SecurityManager会收到AuthenticationToken,并将其发送给已配置的Realm执行必须的认证检查
* 每个Realm都能在必要时对提交的AuthenticationTokens作出反应
* @param user
* @return
* @throws AuthenticationException
*/
@RequestMapping("/login")
@ResponseBody
public ResponseWrapper loginUser(@RequestBody User user) throws AuthenticationException {
ResponseWrapper responseWrapper;
boolean flags = authcService.login(user);
if (flags){
// 将会话信息存储在redis中,并在用户认证通过后将会话Id以token的形式返回给用户。用户请求受保护资源时带上这个token,我们根据token信息去redis中获取用户的权限信息,从而做访问控制。
Serializable id = SecurityUtils.getSubject().getSession().getId();
responseWrapper=ResponseWrapper.markSuccess();
responseWrapper.setExtra("token",id);
}else {
responseWrapper = ResponseWrapper.markNoData();
}
return responseWrapper;
}
我的理解是这里的token存储的是redis里用户的权限信息对应的key,方便后面用户操作的时候通过这个token作为key从redis里获取权限信息
当再次调用需要权限的URL时会调用doGetAuthorizationInfo方法:
但是这里有个问题,调用/main的url的时候向后台传递token,传的错误的token也没有报错,如果请求头不带token会跳转到未登录认证的url提示未登录认证
如果是需要权限的URL比如说/manage
如果请求头没有带token,会跳转到未授权的url提示未授权,如果请求头带的token是错误的token,会跳转到未登录认证的URL提示未登录认证,如果请求头是正确的token,但是没有相应的权限,也会跳转到未授权url,如果是过期的token,但是过期了也会跳转到未登录认证url提示未登录认证
上述这两个问题已经解决:在MyshiroFilter里对OPTIONS请求放行,对于有token的不是直接返回true,而是交给父类去处理,不然就跳过了父类对token的判断处理
在shiroConfig里定义了redis的key值的前缀
// CACHE_KEY里是缓存AuthenticationInfo信息和AuthorizationInfo信息的缓存名称的前缀
private static final String CACHE_KEY = "shiro:cache:";
private static final String SESSION_KEY = "shiro:session:";
并且配置了redisManager里的前缀和后缀的形式:
redisCacheManager.setKeyPrefix(CACHE_KEY);
// shiro-redis要求放在session里面的实体类必须有个id标识
//这是组成redis中所存储数据的key的一部分,完整的key的形式:shiro:cache:com.example.autohomingtest.config.MyShiroRealm.authenticationCache:username
redisCacheManager.setPrincipalIdFieldName("username");
我的理解是前端传来的token是redis里的一个key,通过这个key可以获取到用户的信息比如说用户名,然后可以根据用户名去拼接对应的redis里存储的AuthenticationInfo信息和AuthorizationInfo信息的key,然后根据这些key获取对应的认证信息(SimpleAuthenticationInfo)和授权信息(SimpleAuthorizationInfo)
存储的session:
将该值序列化,key就是login_token 开头的sessionId,value就是序列化后的值,然后设置到redis里去
但是好像没有从redis里获取
发现是从这里根据token也就是sessionId获取
如果从ThreadLocal里获取不到对应的session,就从redis里去获取
如果是不对的token,那么从redis里就获取不到对应的value值
如果是正确的token,从redis里获取到对应的值,然后将该值和key进行验证:
然后从redis里获取用户信息
从AuthenticationInfo里去获取,如果还是为空,就从redis里获取,最后得到用户信息:
不正确的token也能认证通过的原因是因为在配置跨域的时候,MyShiroFilter类里的isAccessAllowed方法里除了放行OPTIONS请求之外,对有token的请求都放行了:
if (request.getAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID)!=null) {
return true;
}
把这几行代码注释掉就可以了
public class MyShiroFilter extends FormAuthenticationFilter {
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if (request instanceof HttpServletRequest) {
if (((HttpServletRequest)request).getMethod().toUpperCase().equals("OPTIONS")) {
return true;
}
}
// if (request.getAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID)!=null) {
// return true;
// }
return super.isAccessAllowed(request, response, mappedValue);
}
}
当请求头中错误的token时候,获取的session里的sessionId是空的
前端注意
前端每次请求都要在请求头里添加token,以"Authorization"为key,token作为value,可以写在前端的请求拦截器里
shiro 鉴权
1、基于注解(开启注解需要在shiroConfig里重写AuthorizationAttributeSourceAdvisor类)
@RequiresPermissions("user-home") -- 访问此方法必须具有的权限
@RequiresRoles("admin") -- 访问此方法必须具有的角色
2、基于拦截器
Map<String,String> filterMap = new LinkedHashMap<>();
filterMap.put("/home","anon"); // 当前请求url地址可匿名访问
// 具有某种权限才能访问
filterMap.put("/user/client","perms[user-client]");
// 具有某种角色才能访问
filterMap.put("/user/admin","roles[admin]");
filterMap.put("/user/**","authc"); // 当前请求地址必须认证之后可访问
// 设置过滤器
filterFactory.setFilterChainDefinitionMap(filterMap);
shiro 两种鉴权方式的区别
- 过滤器:如果权限信息不匹配 跳转到setUnauthorizedUrl地址
- 注解;如果权限信息不匹配,抛出异常
整体的搭建流程:
-
自定义Realm
重写授权和认证方法,doGetAuthorizationInfo和doGetAuthenticationInfo方法,并且设置密码加密匹配算法,设置开启缓存。
/** * @author :RealGang * @description:自定义权限匹配和密码匹配,认证用户,授权 * @date : 2021/10/18 18:04 */ public class MyShiroRealm extends AuthorizingRealm { private final static Logger logger = LoggerFactory.getLogger(MyShiroRealm.class); /** * 延迟加载bean,解决缓存Cache不能正常使用;事务Transaction注解不能正常运行 */ @Autowired @Lazy private UserServiceImpl userService; public MyShiroRealm() { //设置凭证匹配器,修改为hash凭证匹配器 HashedCredentialsMatcher myCredentialsMatcher = new HashedCredentialsMatcher(); //设置算法 myCredentialsMatcher.setHashAlgorithmName("md5"); //散列次数 myCredentialsMatcher.setHashIterations(1024); this.setCredentialsMatcher(myCredentialsMatcher); //开启缓存 this.setCachingEnabled(true); this.setAuthenticationCachingEnabled(true); this.setAuthorizationCachingEnabled(true); } /** * @description: 授权;doGetAuthorizationInfo方法是在我们调用;SecurityUtils.getSubject().isPermitted()这个方法,授权后用户角色及权限会保存在缓存中的 * "@RequiresPermissions"这个注解其实就是在执行SecurityUtils.getSubject().isPermitted() * 授权 * 这个方法在每次访问ShiroConfig里面配置的受保护资源时都会调用 * 因此,需要做缓存 * @param: principalCollection * @return: org.apache.shiro.authz.AuthorizationInfo * @author: RealGang * @date: 2021/10/19 */ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) { User user; Object object = principalCollection.getPrimaryPrincipal(); // 这里用json转化为USER,因为可能从redis获取的用户信息反序列化不能强制转换为user报错 if (object instanceof User) { user = (User) object; } else { user = JSON.parseObject(JSON.toJSON(object).toString(), User.class); } String username = user.getUsername(); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); // User user = (User) principalCollection.fromRealm(this.getClass().getName()).iterator().next(); //查询数据库 user = userService.findUserInfo(user.getUsername()); logger.info("##################执行Shiro权限授权##################user info is:{}" + JSONObject.toJSONString(user)); Set<String> userPermissions = new HashSet<String>(); Set<String> userRoles = new HashSet<String>(); for (Role role : user.getRoles()) { userRoles.add(role.getRoleName()); List<Permission> rolePermissions = role.getPermissions(); for (Permission permission : rolePermissions) { userPermissions.add(permission.getPermName()); } } //角色名集合 info.setRoles(userRoles); //权限名集合,将权限放入shiro中, // 这里可以把url,按钮,菜单,api等当做资源来进行权限控制,从而对用户进行权限控制 info.addStringPermissions(userPermissions); return info; } /** * @description: 认证,登录,doGetAuthenticationInfo这个方法是在用户登录的时候调用的;也就是执行SecurityUtils.getSubject().login()的时候调用;(即:登录验证),验证通过后会用户保存在缓存中的 * @param: authenticationToken * @return: org.apache.shiro.authc.AuthenticationInfo * @author: RealGang * @date: 2021/10/19 */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { logger.info("##################执行Shiro登录认证##################"); // 客户端传来的 username 和 password 会自动封装到 token,先根据 username 进行查询.如果返回 null,则表示用户名错误,直接 return null 即可,Shiro 会自动抛出 UnknownAccountException 异常。 if(authenticationToken==null){ return null; } String principal = (String) authenticationToken.getPrincipal(); //查询数据库 User user = userService.findByUserName(principal); //放入shiro.调用CredentialsMatcher检验密码 if (user != null) { // 若存在,将此用户存放到登录认证info中,无需自己做密码对比,Shiro会为我们进行密码对比校验 // 第三个参数一般也可以是ByteSource.Util.bytes(shiroUser.getUserName()+shiroPasswordService.getPublicSalt()) // //由于shiro-redis插件需要从这个属性中获取id作为redis的key,所有这里传的是user而不是username // return new SimpleAuthenticationInfo(user, user.getPassWord(), credentialsSalt, this.getClass().getName()); return new SimpleAuthenticationInfo(user, user.getPassword(), new CurrentSalt(user.getSalt()), this.getClass().getName()); } return null; } }
-
添加Shiro自定义会话
添加自定义会话ID生成器
这里配置token以"login_token"开头的token也就是sessionId
public class ShiroSessionIdGenerator implements SessionIdGenerator { /** *实现SessionId生成 * @param session * @return */ @Override public Serializable generateId(Session session) { Serializable sessionId = new JavaUuidSessionIdGenerator().generateId(session); return String.format("login_token_%s", sessionId); } }
添加自定义会话管理器
public class ShiroSessionManager extends DefaultWebSessionManager { //定义常量 private static final String AUTHORIZATION = "Authorization"; private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request"; //重写构造器 public ShiroSessionManager() { super(); this.setDeleteInvalidSessions(true); } /** * 重写方法实现从请求头获取Token便于接口统一 * * 每次请求进来, * Shiro会去从请求头找Authorization这个key对应的Value(Token) * @param request * @param response * @return */ @Override public Serializable getSessionId(ServletRequest request, ServletResponse response) { String token = WebUtils.toHttp(request).getHeader(AUTHORIZATION); //如果请求头中存在token 则从请求头中获取token if (!StringUtils.isEmpty(token)) { request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE); request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, token); request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE); return token; } else { // 这里禁用掉Cookie获取方式 return null; // 否则按默认规则从cookie取sessionId //return super.getSessionId(request, response); } } }
-
配置shiro:shiroConfig
在该配置文件里主要是一个filterFactoryBean,该类里注册SecurityManager,并且可以设置一些自定义过滤器,然后设置过滤url规则,在securityManager方法里注入自定义的realm,并且注入自己重写的redisCacheManager和会话管理器sessionManager
@Configuration public class ShiroConfig { // CACHE_KEY里是缓存AuthenticationInfo信息和AuthorizationInfo信息的缓存名称的前缀 private static final String CACHE_KEY = "shiro:cache:"; private static final String SESSION_KEY = "shiro:session:"; private static final int EXPIRE = 18000; @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; @Value("${spring.redis.jedis.pool.min-idle}") private int minIdle; @Value("${spring.redis.jedis.pool.max-idle}") private int maxIdle; @Value("${spring.redis.jedis.pool.max-active}") private int maxActive; //开启对shior注解的支持 @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(org.apache.shiro.mgt.SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor(); authorizationAttributeSourceAdvisor.setSecurityManager(securityManager); return authorizationAttributeSourceAdvisor; } /** * @description: 自定义过滤器 MyShiroRealm,我们的业务逻辑全部定义在这个 bean 中。 * @param: * @return: com.example.autohomingtest.config.MyShiroRealm * @author: RealGang * @date: 2021/10/19 */ @Bean public MyShiroRealm myShiroRealm(){ return new MyShiroRealm(); } /** * @description: 将 myShiroRealm 注入到 DefaultWebSecurityManager bean 中,完成注册。 * @param: myShiroRealm * @return: org.apache.shiro.web.mgt.DefaultWebSecurityManager * @author: RealGang * @date: 2021/10/19 */ @Bean public SecurityManager securityManager(@Qualifier("myShiroRealm") MyShiroRealm myShiroRealm){ DefaultWebSecurityManager manager = new DefaultWebSecurityManager(); manager.setRealm(myShiroRealm); manager.setCacheManager(redisCacheManager()); // 这里是我自己增加的,不然sessionManager没有注册进去 manager.setSessionManager(sessionManager()); SecurityUtils.setSecurityManager(manager); return manager; } /** * @description: ShiroFilterFactoryBean,这是 Shiro 自带的一个 Filter 工厂实例,所有的认证和授权判断都是由这个 bean 生成的 Filter 对象来完成的, * 这就是 Shiro 框架的运行机制,开发者只需要定义规则,进行配置,具体的执行者全部由 Shiro 自己创建的 Filter 来完成。 * @param: manager * @return: org.apache.shiro.spring.web.ShiroFilterFactoryBean * @author: RealGang * @date: 2021/10/19 */ @Bean public ShiroFilterFactoryBean filterFactoryBean(@Qualifier("securityManager") SecurityManager securityManager){ ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); Map<String, Filter> filterMap = factoryBean.getFilters(); // 这里把自定义拦截OPTIONS请求的拦截器注册进来 filterMap.put("authc",new MyShiroFilter()); Map<String,String> map = new LinkedHashMap<>(); /** * Shiro 内置过滤器,过滤链定义,从上向下顺序执行 * 常用的过滤器: * anon:无需认证(登录)可以访问 * authc:必须认证才可以访问 * user:只要登录过,并且记住了密码,如果设置了rememberMe的功能可以直接访问 * perms:该资源必须得到资源权限才可以访问 * role:该资源必须得到角色的权限才可以访问 */ map.put("/manage","perms[manage]"); map.put("/administrator","roles[administrator]"); //anon表示可以匿名访问 map.put("/index", "anon"); map.put("/login", "anon"); map.put("/static/**", "anon"); map.put("/user/testDb","anon"); //authc表示需要登录 map.put("/user/**","authc"); map.put("/main","authc"); factoryBean.setFilterChainDefinitionMap(map); //设置登录页面,覆盖默认的登录url,这里如果未认证会跳转到/unauthc这里来 factoryBean.setLoginUrl("/unauthc"); //未授权页面 factoryBean.setUnauthorizedUrl("/unauthr"); // 登录成功后要跳转的链接 factoryBean.setSuccessUrl("/index"); return factoryBean; } /** * 配置Redis管理器 * @Attention 使用的是shiro-redis开源插件 * @return */ @Bean public RedisManager redisManager() { RedisManager redisManager = new RedisManager(); redisManager.setHost(host); redisManager.setPort(port); redisManager.setTimeout(timeout); // redisManager.setPassword(password); JedisPoolConfig jedisPoolConfig = new JedisPoolConfig(); jedisPoolConfig.setMaxTotal(maxIdle+maxActive); jedisPoolConfig.setMaxIdle(maxIdle); jedisPoolConfig.setMinIdle(minIdle); redisManager.setJedisPoolConfig(jedisPoolConfig); return redisManager; } @Bean public RedisCacheManager redisCacheManager() { RedisCacheManager redisCacheManager = new RedisCacheManager(); redisCacheManager.setRedisManager(redisManager()); redisCacheManager.setKeyPrefix(CACHE_KEY); // shiro-redis要求放在session里面的实体类必须有个id标识 //这是组成redis中所存储数据的key的一部分,完整的key的形式:shiro:cache:com.example.autohomingtest.config.MyShiroRealm.authenticationCache:username redisCacheManager.setPrincipalIdFieldName("username"); return redisCacheManager; } /** * SessionID生成器 * */ @Bean public ShiroSessionIdGenerator sessionIdGenerator(){ return new ShiroSessionIdGenerator(); } /** * 配置RedisSessionDAO */ @Bean public RedisSessionDAO redisSessionDAO() { RedisSessionDAO redisSessionDAO = new RedisSessionDAO(); redisSessionDAO.setRedisManager(redisManager()); redisSessionDAO.setSessionIdGenerator(sessionIdGenerator()); redisSessionDAO.setKeyPrefix(SESSION_KEY); redisSessionDAO.setExpire(EXPIRE); return redisSessionDAO; } /** * 配置Session管理器 * @Author Sans * */ @Bean public SessionManager sessionManager() { ShiroSessionManager shiroSessionManager = new ShiroSessionManager(); shiroSessionManager.setSessionDAO(redisSessionDAO()); //禁用cookie shiroSessionManager.setSessionIdCookieEnabled(false); //禁用会话id重写 // ession管理器的setSessionIdUrlRewritingEnabled(false)配置没有生效,导致没有认证直接访问受保护资源出现多次重定向的错误。将shiro版本切换为1.5.0后就解决了这个bug。 shiroSessionManager.setSessionIdUrlRewritingEnabled(false); return shiroSessionManager; } }
-
解决跨域问题
配置CorConfig:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true) .maxAge(3600) .allowedHeaders("*"); } }
配置MyShiroFilter过滤器,这里主要拦截OPTIONS请求,并且注册到上述的filterFactoryBean中去:
public class MyShiroFilter extends FormAuthenticationFilter { @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { if (request instanceof HttpServletRequest) { if (((HttpServletRequest)request).getMethod().toUpperCase().equals("OPTIONS")) { return true; } } // if (request.getAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID)!=null) { // return true; // } return super.isAccessAllowed(request, response, mappedValue); } }
-
自定义盐值生成方法保证可以序列化(由于shiro当中的ByteSource没有实现序列化接口,缓存时会发生错误,因此,我们需要通过自定义ByteSource的方式实现这个接口):
public class CurrentSalt implements ByteSource, Serializable { private static final long serialVersionUID = 125096758372084309L; private byte[] bytes; private String cachedHex; private String cachedBase64; public CurrentSalt(){ } public CurrentSalt(byte[] bytes) { this.bytes = bytes; } public CurrentSalt(char[] chars) { this.bytes = CodecSupport.toBytes(chars); } public CurrentSalt(String string) { this.bytes = CodecSupport.toBytes(string); } public CurrentSalt(ByteSource source) { this.bytes = source.getBytes(); } public CurrentSalt(File file) { this.bytes = (new CurrentSalt.BytesHelper()).getBytes(file); } public CurrentSalt(InputStream stream) { this.bytes = (new CurrentSalt.BytesHelper()).getBytes(stream); } public static boolean isCompatible(Object o) { return o instanceof byte[] || o instanceof char[] || o instanceof String || o instanceof ByteSource || o instanceof File || o instanceof InputStream; } public void setBytes(byte[] bytes) { this.bytes = bytes; } @Override public byte[] getBytes() { return this.bytes; } @Override public String toHex() { if(this.cachedHex == null) { this.cachedHex = Hex.encodeToString(this.getBytes()); } return this.cachedHex; } @Override public String toBase64() { if(this.cachedBase64 == null) { this.cachedBase64 = Base64.encodeToString(this.getBytes()); } return this.cachedBase64; } @Override public boolean isEmpty() { return this.bytes == null || this.bytes.length == 0; } @Override public String toString() { return this.toBase64(); } @Override public int hashCode() { return this.bytes != null && this.bytes.length != 0? Arrays.hashCode(this.bytes):0; } @Override public boolean equals(Object o) { if(o == this) { return true; } else if(o instanceof ByteSource) { ByteSource bs = (ByteSource)o; return Arrays.equals(this.getBytes(), bs.getBytes()); } else { return false; } } private static final class BytesHelper extends CodecSupport { private BytesHelper() { } public byte[] getBytes(File file) { return this.toBytes(file); } public byte[] getBytes(InputStream stream) { return this.toBytes(stream); } } }
-
登录接口编写:
@RequestMapping("/login")
@ResponseBody
public ResponseWrapper loginUser(@RequestBody User user) throws AuthenticationException {
ResponseWrapper responseWrapper;
boolean flags = authcService.login(user);
if (flags){
// 将会话信息存储在redis中,并在用户认证通过后将会话Id以token的形式返回给用户。用户请求受保护资源时带上这个token,我们根据token信息去redis中获取用户的权限信息,从而做访问控制。
Serializable id = SecurityUtils.getSubject().getSession().getId();
logger.debug("会话ID:"+id);
responseWrapper=ResponseWrapper.markSuccess();
responseWrapper.setExtra("token",id);
}else {
responseWrapper = ResponseWrapper.markNoData();
}
return responseWrapper;
}
在authService里的login方法里,通过用户名和密码生成UsernamePasswordToken然后调用subject.login(token)让shiro自己去处理:
@Service
public class AuthcServiceImpl implements AuthcService {
@Override
public boolean login(User user) throws AuthenticationException {
if (user==null){
return false;
}
if (user.getUsername()==null||"".equals(user.getUsername())){
return false;
}
if (user.getPassword() == null || "".equals(user.getPassword())){
return false;
}
Subject subject = SecurityUtils.getSubject();
UsernamePasswordToken token = new UsernamePasswordToken(user.getUsername(), user.getPassword());
subject.login(token);
return true;
}
}
前端获取到token之后可以放到vuex里去,每次向后台发送请求可以在拦截器里将该token添加到Header里的“Authorization"里去