完整版:springboot + shiro + jwt + salt.
放弃 Cookie ,Session ,使用 JWT 进行鉴权,完全实现无状态鉴权
shiro 完整流程以及集成:
/login
, 用户输入登录账号和密码被封装成 UsernamePasswordToken
对象,然后调用 subject.login()
方法UserRealm doGetAuthenticationInfo()
方法代码块。/pay
为例子,必须用户登录成功访问并在请求头中添加 token。@Slf4j
@RestController
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/login")
public AjaxResult loginUser(@RequestBody UserEntity userVo) {
Subject subject = SecurityUtils.getSubject();
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(userVo.getUsername(), userVo.getPassword());
usernamePasswordToken.setRememberMe(true);
try {
subject.login(usernamePasswordToken);
log.info("登录成功");
} catch (AuthenticationException ae) {
return AjaxResult.error("账号或密码不正确");
}
UserEntity userEntity = (UserEntity) subject.getPrincipal();
userEntity.setToken(JwtUtils.generateToken(userEntity.getUsername(),JwtUtils.secret));
return AjaxResult.success(userEntity);
}
@GetMapping("/pay")
public AjaxResult payWithToken() {
return AjaxResult.success("this Uri need token");
}
}
JWT 方案有许多种,这个网站列举了所有的常用方案,在这里我们选择 java-jwt
<dependency>
<groupId>org.apache.shirogroupId>
<artifactId>shiro-spring-boot-web-starterartifactId>
<version>1.10.0version>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-lang3artifactId>
<version>3.12.0version>
dependency>
<dependency>
<groupId>com.auth0groupId>
<artifactId>java-jwtartifactId>
<version>4.2.0version>
dependency>
对于 HTTP 请求,springboot 默认使用 Servlet 来处理,而 shiro 的过滤器正是基于 Servlet 实现,因此所有的 Http 请求,都会执行设定好的过滤器方法.
在前面的文章中,其实我们已经使用了过滤器,使用的都是 shiro 提供的现成过滤器名称缩写:shiro 常见过滤器
在 ShiroConfig ShiroFilterFactoryBean
中, 对过滤器进行统一的设定.代码变动位置有 2 处
@Configuration
public class ShiroConfig {
/**
* 默认web安全管理器
*
* @return {@link DefaultWebSecurityManager}
*/
@Bean
public SessionManager sessionManager(){
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
// 关闭 cookie 验证
sessionManager.setSessionIdCookieEnabled(false);
// 关闭 session 验证
sessionManager.setSessionValidationSchedulerEnabled(false);
return sessionManager;
}
@Bean
public DefaultWebSecurityManager getDefaultWebSecurityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(userRealm());
securityManager.setSessionManager(sessionManager());
/*
* 关闭shiro自带的session
* 文档: http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
*/
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);
return securityManager;
}
/**
* `shiroFilter`:过滤器
*
*/
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,ShiroFilterChainDefinition shiroFilterChainDefinition) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
// Shiro的核心安全接口,这个属性是必须的
shiroFilterFactoryBean.setSecurityManager(securityManager);
// 自定义过滤器 ================ 变动1
Map<String, Filter> filters = new LinkedHashMap<>();
filters.put("myFilter", new MyFilter());
shiroFilterFactoryBean.setFilters(filters);
// 定义过滤链
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
// 对静态资源设置匿名访问
filterChainDefinitionMap.put("/index.html", "anon");
filterChainDefinitionMap.put("/favicon.ico**", "anon");
filterChainDefinitionMap.put("/static/**","anon");
// 登录,不需要拦截的访问
filterChainDefinitionMap.put("/login", "anon");
// 错误页面无需认证
filterChainDefinitionMap.put("/error","anon");
// !!! 其他所有请求使用自定义的过滤器 myFilter 来处理 ================ 变动2
filterChainDefinitionMap.put("/**","myFilter")
// filterChainDefinitionMap.put("/**","authc");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
}
filterChainDefinitionMap.put("/",“myFilter”)** 用来设置其他所有请求包括
/pay
会执行即将创建的自定义过滤器myFilter
在 ShiroConfig 中,规定了只有登录接口(subjet.login())会使用
UserRealm
.需要携带token得接口与UserRealm 毫无关系
Shiro
将 Realm
设计为可拔插模块,而 Realm
又分为两部分:认证,授权。两个单词非常相似。
public class UserRealm extends AuthorizingRealm {
/**
* shiro默认机制是 通过token的类型来确认是否由当前realm来处理当前收到的登录请求
* 因此 在这里限定只有通过 UsernamePasswordToken这个类,调用的login接口可以使用此Realm认证
*
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof UsernamePasswordToken;
}
/**
* 授权
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}
/**
* 认证
* AuthenticationToken 接口提供了2方法,getPrincipal() 返回的用户的账号信息,getCredentials() 返回的是密码信息。
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
// 登陆时传入的用户名,密码
UsernamePasswordToken accessToken = (UsernamePasswordToken) authenticationToken;
// 获取用户名
String username = (String) authenticationToken.getPrincipal();
// 查询用户
UserEntity userEntity = userService.getOne(new LambdaQueryWrapper<UserEntity>().eq(UserEntity::getUsername, accessToken.getUsername()));
// 组装并返回
SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
userEntity, // 用户
accessToken.getPassword(), // 密码
ByteSource.Util.bytes(salt),// byte类型 salt
"anyRealmName" // realm name . getName()
);
return authenticationInfo;
}
}
当访问 /pay
时,请求会执行 MyFilter
中代码块。需要注意的是,当你修改了代码后,要先执行以下 /login
登录,复制返回得 token,再访问此接口
/**
* $$ 代码的执行流程 preHandle -> isAccessAllowed -> isLoginAttempt -> executeLogin
*
* @author ifredomvip@gmail.com
* @version 1.0.0
* @date 2022/10/27 17:18
**/
@Slf4j
public class MyFilter extends BasicHttpAuthenticationFilter {
/**
* 过滤器拦截请求的入口方法,所有请求都会进入该方法
* 1. 返回true则允许访问
* 2. 返回false,shiro才会根据onAccessDenied的方法的返回值决定是否允许访问url
* @param request 请求
* @param response 响应
* @param mappedValue 映射值
* @return boolean
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
log.info("允许访问 - 周期");
// 所有自定义过滤器得请求都需要携带token
return false;
}
/**
* isAccessAllowed()方法返回false,会进入该方法,表示拒绝访问
*
* 所有过滤器处理请求都可以在 isAccessAllowed中处理,或者在 onAccessDenied 中处理
*
* 由于过滤器在controller前运行,token过期时,抛出的异常不会全局异常捕获,而在 onAccessDenied 是可以精准抛出此异常。
*
* 所以在 onAccessDenied 中处理
*
* @param request 请求
* @param response 响应
* @return boolean
* @throws Exception 异常
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
log.info("拒绝访问 - 周期");
//获取请求token,如果token不存在,直接返回401
String token = getRequestToken((HttpServletRequest) request);
// 请求头不含token
if(StringUtils.isEmpty(token)) {
responseError(response,HttpStatus.UNAUTHORIZED.value(),"token不能为空");
return false;
}
// 请求头含有 token
String username = JwtUtils.getUserName(token);
if(!JwtUtils.verify(token, username, JwtUtils.secret)){
responseError(response,HttpStatus.UNAUTHORIZED.value(),"token无效");
return false;
}
if(JwtUtils.isExpired(token)) {
responseError(response,HttpStatus.UNAUTHORIZED.value(),"token已失效,请重新登录!");
return false;
}
log.info(String.valueOf("verify"));
return true;
}
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
fillCorsHeader(httpRequest,httpResponse);
// 过滤options方法。跨域时会首先发送一个option请求
if (((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name())) {
httpResponse.setStatus(HttpStatus.OK.value());
return true;
}
return super.preHandle(request, response);
}
/**
* 获取请求的token
*/
private String getRequestToken(HttpServletRequest httpRequest) {
//从header中获取token
String token = httpRequest.getHeader("Authorization");
//如果header中不存在token,则从参数中获取token
if (StringUtils.isBlank(token)) {
token = httpRequest.getParameter("Authorization");
}
return token;
}
/**
* 跨域请求的解决方案之一
*
* @param request 请求
* @param response 响应
*/
protected void fillCorsHeader(HttpServletRequest request, HttpServletResponse response) {
response.setContentType("text/html;charset=UTF-8");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Origin",request.getHeader("Origin"));
response.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
response.setHeader(
"Access-Control-Allow-Headers",
request.getHeader("Access-Control-Request-Headers")
);
}
protected void responseError(ServletResponse response,int code,String errorMsg) throws IOException {
HttpServletResponse httpResponse = (HttpServletResponse) response;
AjaxResult r = AjaxResult.error(HttpStatus.UNAUTHORIZED.value(), "token不能为空");
String json = new ObjectMapper().writeValueAsString(r);
httpResponse.getWriter().print(json);
}
}
工具类都定义为静态方法,使用时可以避免注入此工具类。
package com.mock.water.core.utils;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.mock.water.modules.system.user.entity.UserEntity;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* @Author ifredomvip@gmail.com
* @Date 2022/11/9 11:14
*/
@Slf4j
public class JwtUtils {
/**
* 密钥
*/
public static String secret = "ifredom123456";
/**
* 到期时间 7天
*/
public static long expire = 7*1000*60*60*24;
/**
* 创建 token
*/
public static String generateToken(String username, String secret) {
Date now = new Date();
Date date = new Date(now.getTime() + expire * 1000);
Algorithm algorithm = Algorithm.HMAC256(secret);
return JWT.create()
.withClaim("username", username)
.withExpiresAt(date)
.sign(algorithm);
}
/**
* 验证 token
*/
public static boolean verify(String token, String username, String secret) {
try {
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("username", username)
.build();
verifier.verify(token);
return true;
} catch (Exception e) {
log.error("token 无效 {}", e.getMessage());
return false;
}
}
/**
* token是否过期
*
* @return true:过期
*/
public static boolean isExpired(String token) {
DecodedJWT jwt = JWT.decode(token);
return System.currentTimeMillis() > jwt.getExpiresAt().getTime();
}
/**
* 从 token中获取字段
*
* @return token中包含的填入字段
*/
public static String getClaim(String token, String claim) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim(claim).asString();
} catch (JWTDecodeException e) {
log.error("error:{}", e.getMessage());
return null;
}
}
public static String getUserName(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
log.error("error:{}", e.getMessage());
return null;
}
}
public String getSecret() {return secret;}
public void setSecret(String secret) {this.secret = secret;}
public void setExpire(long expire) {this.expire = expire;}
public long getExpire() {return expire;}
}
在认证功能中会使用到 Shiro 封装好的
SimpleAuthenticationInfo
类.
SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
userEntity, // 用户
accessToken.getPassword(), // 密码
ByteSource.Util.bytes(salt),// byte类型 salt
"anyRealmName" // realm name . getName()
);
第一个参数,可以传入用户名 username,也可以传入从数据库查询得到的 userEntity 实体对象。(shiro 会自动调用实体的 getUserName()
去获取 username
字段值)建议传入 userEntity
。
传入 userEntity(实体),subject.getPrincipal() 得到的是 userEntity(实体);
传入 username(字符串),subject.getPrincipal() 得到的是字符串。
第二个参数,传入的是用户登录时输入的 password。它被装入 SimpleAuthenticationInfo
类返回后,会与 UsernamePasswordToken 中的 password 进行对比。匹配上了就表明验证通过,匹配不上就报异常。
需要注意,网上很多文章说的是传入数据库中的密码,数据库中应该存放的是 username 和 salt,或者加密之后的密码,一定不能是明文密码.
第三个参数(可选参数),salt 盐。此参数目的:用于对密码进行加密以及对比,防止用户的密码相同。
具体来说就是:假如两个用户的密码都是 123456, Shiro 在比较 数据库中获取的 password 和 UsernamePasswordToken 中的 password 的值时,默认会先调用这个类 new SimpleHash(String algorithmName, Object source)对密码执行一次 MD5 哈希算法得到字符串,然后使用哈希化后的两个字符串进行比较,这两字符串相同,那么就表示密码相同。(shiro 并不会上来就直接比较 2 个密码原文,会分别哈希算法转换一次后,对比转换后的值)
所以问题就来了, 如果两个用户密码相同,在没有 salt 的情况下,他们的哈希值是一样的,就会造成错误判断。加盐后就可以避免不同用户的密码不一样。
第四个参数:当前 realm 对象的 beanName, 可以通过 getName()
获取
认证方法
doGetAuthenticationInfo()
有一个入参,类型为AuthenticationToken
protected AuthenticationInfo doGetAuthenticationInfo(
AuthenticationToken authenticationToken
) throws AuthenticationException {}
参数
AuthenticationToken
是一个接口,它拥有 2 个实现类和 2 个继承接口,关系如下。
参数 authenticationToken 从哪里来呢?
它是在登陆
login()
时,我们创建一个 UsernamePasswordToken 对象然后传入的,传入的必须是一个AuthenticationToken
的实现类.(经过测试,此处并不能传入 new BearerToken()这个实现类)
@PostMapping("/login")
public void loginUser(@Validated @RequestBody UserVo userVo, BindingResult bindingResult) {
Subject subject = SecurityUtils.getSubject();
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(userVo.getUsername(), userVo.getPassword());
usernamePasswordToken.setRememberMe(true);
// 传入
subject.login(usernamePasswordToken);
}
从继承关系图可以看出,为什么它可以向下转型
UsernamePasswordToken accessToken = (UsernamePasswordToken) authenticationToken;
在配置类中,shiro 提供了一个简单得封装类 ShiroFilterChainDefinition,可以将过滤连提取为一个单独得方法,代码看上去更为舒适.
@Configuration
public class ShiroConfig {
......
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,ShiroFilterChainDefinition shiroFilterChainDefinition) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
// 自定义过滤器
Map<String, Filter> filters = new LinkedHashMap<>();
filters.put("myFilter", new MyFilter());
shiroFilterFactoryBean.setFilters(filters);
// 定义过滤链
Map<String, String> filterMap = shiroFilterChainDefinition.getFilterChainMap();
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterMap);
return shiroFilterFactoryBean;
}
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
// 不需要拦截的访问
// 对静态资源设置匿名访问
chainDefinition.addPathDefinition("/index.jsp", "anon");
chainDefinition.addPathDefinition("/login.jsp", "anon");
chainDefinition.addPathDefinition("/favicon.ico**", "anon");
chainDefinition.addPathDefinition("/captcha/captchaImage**", "anon");
chainDefinition.addPathDefinition("/static/**","anon");
// 登录,
chainDefinition.addPathDefinition("/login", "anon");
// 注册
chainDefinition.addPathDefinition("/register", "anon");
// 错误页面
chainDefinition.addPathDefinition("/error","anon");
// 登出,shiro 自动清除 session
chainDefinition.addPathDefinition("/logout","logout");
// druid连接池的角色控制,只有拥有admin角色的admin用户可以访问,不理解可以先不管
chainDefinition.addPathDefinition("/druid/**","authc, roles[admin]");
// 其余资源都交给 MyFilter 这个过滤器处理
chainDefinition.addPathDefinition("/**","myFilter");
return chainDefinition;
}
}
DefaultShiroFilterChainDefinition
内部使用LinkedHashMap
实现 . HashMap 是无序的,LinkedHashMap 将会按序加载。最后添加了 chainDefinition.addPathDefinition(“/**”, “authc”), 如果使用 HashMap 将会优先加载了此配置,导致其他配置失效。
配置缩写 | 对应的过滤器 | 功能 |
---|---|---|
anon | AnonymousFilter | 指定 url 可以匿名访问 |
authc | FormAuthenticationFilter | 基于表单的拦截器;如“/**=authc”,如果没有登录会跳到相应的登录页面登录;主要属性:usernameParam:表单提交的用户名参数名( username); passwordParam:表单提交的密码参数名(password); rememberMeParam:表单提交的密码参数名(rememberMe); loginUrl:登录页面地址(/login.jsp);successUrl:登录成功后的默认重定向地址; failureKeyAttribute:登录失败后错误信息存储 key(shiroLoginFailure) |
shiro 对权限授权划分为:角色 和 资源
角色
资源权限
————————————————
注解方式授权
@RequiresAuthentication
: 使用该注解标注的类,实例,方法在访问或调用时,当前 Subject 必须在当前 session 中已经过认证。
@RequiresGuest
: 使用该注解标注的类,实例,方法在访问或调用时,当前 Subject 可以是“gust”身份,不需要经过认证或者在原先的 session 中存在记录。
@RequiresPermissions
: 当前 Subject 需要拥有某些特定的权限时,才能执行被该注解标注的方法。如果当前 Subject 不具有这样的权限,则方法不会被执行。
@RequiresRoles
: 当前 Subject 必须拥有所有指定的角色时,才能访问被该注解标注的方法。如果当天 Subject 不同时拥有所有指定角色,则方法不会执行还会抛出 AuthorizationException 异常。
@RequiresUser
:当前 Subject 必须是应用的用户,才能访问或调用被该注解标注的类,实例,方法。
ShiroFilterFactoryBean
方法名取名为 shiroFilterFactoryBean()
logout
功能。如果再拦截器链中配置了 logout,那么不要再定义 controller。只定义其中一个@Configuration
public class ShiroConfig {
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
chainDefinition.addPathDefinition("/logout","logout");
return chainDefinition;
}
}
====================
@RestController
public class UserController {
@GetMapping("/logout")
public void logout(){
SecurityUtils.getSubject().logout();
System.out.println("登出");
}
}
3.shiro 中出现 does not support authentication token
/**
* 大坑!,必须重写此方法,不然Shiro会报错
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
public class Myfilter{
private JwtUtils jwtUtils;
public LoginFilter(ApplicationContext context) {
this.util = context.getBean(MyJWTUtil.class);
}
}