1、前后端分离项目,前端Vue,后端SpringBoot,需要实现后端鉴权;
2、当前端发送请求之后,判断是否登录或用户是否具有相应权限;
3、使用Token实现,不使用Session。
1、前端Vue的axios设置请求拦截器,在请求头中添加token;
2、后端自定义JWT拦截器,继承BasicHttpAuthenticationFilter,
如果请求头中含有token,则进行认证鉴权操作;
如果请求头中不含有token,禁止访问需要登录之后才能访问的资源;
3、ShiroFilterFactoryBean中自定义拦截url,排除不需要验证的界面(如login、404、401等),其余请求的url,一律拦截至我们自定义的JWTFilter;
4、因为使用JWT验证身份,不存在Session,若没有使用缓冲池,每次鉴权都需要执行登录操作,重新获取身份信息,鉴权前执行登录操作;
5、调用自定义Realm中的doGetAuthorizationInfo,从数据库中获取该用户所对应角色或权限,进行鉴权。
授权流程

流程如下:
- 首先调用 Subject.isPermitted*/hasRole*接口,其会委托给 SecurityManager,而 SecurityManager 接着会委托给 Authorizer;
- Authorizer 是真正的授权者,如果我们调用如 isPermitted(“user:view”),其首先会通过 PermissionResolver 把字符串转换成相应的 Permission 实例;
- 在进行授权之前,其会调用相应的 Realm 获取 Subject 相应的角色/权限用于匹配传入的角色/权限;
- Authorizer 会判断 Realm 的角色/权限是否和传入的匹配,如果有多个 Realm,会委托给 ModularRealmAuthorizer 进行循环判断,如果匹配如 isPermitted*/hasRole* 会返回 true,否则返回 false 表示授权失败。
首先是pom.xml
-
- <dependency>
- <groupId>org.apache.shirogroupId>
- <artifactId>shiro-spring-boot-starterartifactId>
- <version>1.6.0version>
- dependency>
-
- <dependency>
- <groupId>io.jsonwebtokengroupId>
- <artifactId>jjwtartifactId>
- <version>0.9.1version>
- dependency>
JWTtoken的加密、解析和check已经在我的其他博客写过
1、自定义JWTToken,继承自AuthenticationToke
JWTToken差不多就是Shiro用户名密码的载体。因为我们是前后端分离,服务器无需保存用户状态,简单的实现下AuthenticationToken接口即可,因为项目使用了MD5加密,数据库存储的密码是加盐之后的密码,此处还要保存一下加盐之后的密码,将getCredentials返回为这个加盐之后的密码,因为授权还需要进行一次登录操作,此处又不能使用明文密码,所有只能将加密后的密码再使用相同的盐再加密,再将“加密之后的密码”按照相同的方法加密之后(此处有点绕嘴),再放入SimpleAuthenticationInfo中,在执行Subject.login()。(有更好的方法可以分享一下)
- public class JWTToken implements AuthenticationToken {
-
- private String token;
- // 用户密码(数据库里存储的用户加密之后的密码)
- private String credentials;
-
-
- // 有参构造
- public JWTToken(String token) {
- this.token = token;
- }
-
- // 无参构造
- public JWTToken() {
- }
-
- @Override
- public Object getPrincipal() {
- return this.token;
- }
-
- @Override
- public Object getCredentials() {
- return this.credentials;
- }
-
- public void setCredentials(String credentials) {
- this.credentials = credentials;
- }
- }
2、自定义JWT拦截器
我们使用的是 shiro 默认的权限拦截 Filter,而因为JWT的整合,我们需要自定义自己的过滤器 JWTFilter,JWTFilter 继承了 BasicHttpAuthenticationFilter,并部分方法进行了重写。所有的请求都会先经过Filter,所以我们继承官方的BasicHttpAuthenticationFilter,并且重写鉴权的方法。代码执行的流程:
preHandle -> isAccessAllowed -> isLoginAttempt -> executeLogin
- public class JWTFilter extends BasicHttpAuthenticationFilter {
-
- /**
- * 最后始终返回true
- * @param request
- * @param response
- * @param mappedValue
- * @return
- */
- @Override
- protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
- System.out.println("JWTFilter启动");
-
- //指定请求不经过该过滤器
- if(((HttpServletRequest) request).getRequestURI().endsWith("login")){
- return true;
- }
- // 判断请求的请求头是否带token属性,也就是判断用户是否一定登录
- if(isLoginAttempt(request, response)){
- // 如果请求头中包含token,则执行executeLogin方法进行登入操作,检查token是否正确
- System.out.println("用户已经登录");
- try {
- return executeLogin(request, response);
- }catch (Exception e){
- e.printStackTrace();
- }
- }else {
- System.out.println("用户没有登录");
- try {
- Result result = new Result(Code.TOKEN_INVALID, null, "Token为空!");
- this.returnErrorMsg(response, result);
- }catch (IOException e){
- e.printStackTrace();
- }
- }
-
- return false;
- }
-
- /**
- * 判断用户是否想要登录
- * 在请求头中检查是否有token就行
- * @param request
- * @param response
- * @return
- */
- @Override
- protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
- // System.out.println("判断用户是否想要登入");
- HttpServletRequest httpServletRequest = (HttpServletRequest) request;
- return null != httpServletRequest.getHeader("token");
- }
-
- /**
- * 执行登录
- * @param request
- * @param response
- * @return
- * @throws Exception
- */
- @Override
- protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
- System.out.println("执行登录");
- HttpServletRequest httpServletRequest = (HttpServletRequest) request;
- String token = httpServletRequest.getHeader("token");
- try{
- getSubject(request, response).login(new JWTToken(token));
- return true;
- }catch (Exception e){
- e.printStackTrace();
- Result result = new Result(Code.TOKEN_INVALID, null, "Token失效!");
- this.returnErrorMsg(response, result);
- return false;
- }
- }
-
- /**
- * 返回给前端自定义错误信息
- * @param response
- * @param result
- * @throws IOException
- */
- private void returnErrorMsg(ServletResponse response,Result result) throws IOException {
- //响应token为空
- response.setContentType("application/json;charset=UTF-8");
- response.setCharacterEncoding("UTF-8");
- //清空第一次流响应的内容
- response.resetBuffer();
- //转成json格式
- ObjectMapper object = new ObjectMapper();
- String asString = object.writeValueAsString(result);
- response.getWriter().println(asString);
- }
-
- /**
- * 因为前后端分离,需对跨域提供支持
- * @param request
- * @param response
- * @return
- * @throws Exception
- */
- @Override
- protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
- // System.out.println("跨域支持");
- HttpServletRequest httpServletRequest = (HttpServletRequest) request;
- HttpServletResponse httpServletResponse = (HttpServletResponse) response;
-
- httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
- httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
- httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
- // 跨域时会首先发送一个 option请求,这里我们给 option请求直接返回正常状态
- if(httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())){
- httpServletResponse.setStatus(HttpStatus.OK.value());
- return false;
- }
- return super.preHandle(request, response);
- }
- }
上面代码的大致分析:
①、拦截所有的请求,先通过Interceptor解决跨域请求问题;
②、isAccessAllowed方法启动,启动之后,调用isLoginAttempt方法;
③、isLoginAttempt是判断用户是想要执行登录操作还是要授权登录,判断的依据就是请求头里面是否含有token;
④、如果请求头里面含有token,就说明用户已经登录,那就再执行一次登录,用于鉴权;如果没有登录,那就返回前端Result;
3、Shiro全局配置类中增加自定义jwtFilter过滤器,用来拦截并处理携带JWT token的请求
- /**
- * Shiro配置类
- */
- @Configuration
- public class ShiroConfig {
-
- /*
- * 倒序配置
- * 1、先自定义过滤器 MyRealm
- * 2、创建第二个DefaultWebSecurityManager,将MyRealm注入
- * 3、装配第三个ShiroFilterFactoryBean,将DefaultWebSecurityManager注入,并注入认证及授权规则
- * */
-
- //3、装配ShiroFilterFactoryBean,并将 DefaultWebSecurityManager 注入到 ShiroFilterFactoryBean 中
- @Bean
- public ShiroFilterFactoryBean factoryBean(DefaultWebSecurityManager manager){
- ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
- factoryBean.setSecurityManager(manager);//将 DefaultWebSecurityManager 注入到 ShiroFilterFactoryBean 中
- // 自定义拦截url
- Map
filterMap = new LinkedHashMap<>(); - filterMap.put("jwt", new JWTFilter());
- factoryBean.setFilters(filterMap);
- //添加默认过滤器
- //表示指定登录页面
- factoryBean.setLoginUrl("/login");
- //未授权页面
- //factoryBean.setUnauthorizedUrl("/unauthorized");
- //拦截器, 配置不会被拦截的链接 顺序判断
- Map
filterChainDefinitionMap = new LinkedHashMap<>(); - //所有匿名用户均可访问到Controller层的该方法下
-
- filterChainDefinitionMap.put("/login", "anon");
- filterChainDefinitionMap.put("/checkToken", "anon");
-
- //user表示配置记住我或认证通过可以访问的地址
- //filterChainDefinitionMap.put("/remember", "user");
- //filterChainDefinitionMap.put("/logout", "logout");
-
- //authc:所有url都必须认证通过才可以访问; anon:所有url都都可以匿名访问
- //filterChainDefinitionMap.put("/**", "authc");
-
- //所有url都必须认证通过jwt过滤器才可以访问
- filterChainDefinitionMap.put("/**", "jwt");
- factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
- //注入认证及授权规则
- return factoryBean;
- }
-
- //2、创建DefaultWebSecurityManager ,并且将 MyRealm 注入到 DefaultWebSecurityManager bean 中
- @Bean
- public DefaultWebSecurityManager manager(MyRealm myRealm){
- DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
- manager.setRealm(myRealm);//将自定义的 MyRealm 注入到 DefaultWebSecurityManager bean 中
- /*
- * 关闭shiro自带的session
- * 用了jwt的访问认证,所以要把默认session支持关掉
- * 即不保存用户登录状态,保证每次请求都重新认证
- */
- DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
- DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
- defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
- subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
- manager.setSubjectDAO(subjectDAO);
- return manager;
- }
-
- //1、自定义过滤器Realm
- @Bean
- public MyRealm myRealm(@Qualifier("hashedCredentialsMatcher") HashedCredentialsMatcher matcher){
- MyRealm myRealm = new MyRealm();
- // 密码匹配器
- myRealm.setCredentialsMatcher(matcher);
- return myRealm;
- }
-
- /**
- * 密码匹配器
- * @return HashedCredentialsMatcher
- */
- @Bean("hashedCredentialsMatcher")
- public HashedCredentialsMatcher hashedCredentialsMatcher(){
- HashedCredentialsMatcher matcher = new HashedCredentialsMatcher();
- // 设置哈希算法名称
- matcher.setHashAlgorithmName("MD5");
- // 设置哈希迭代次数
- matcher.setHashIterations(1024);
- // 设置存储凭证(true:十六进制编码,false:base64)
- matcher.setStoredCredentialsHexEncoded(true);
-
- return matcher;
- }
-
- /**
- * SpringShiroFilter首先注册到spring容器
- * 然后被包装成FilterRegistrationBean
- * 最后通过FilterRegistrationBean注册到servlet容器
- * @return
- */
- @Bean
- public FilterRegistrationBean delegatingFilterProxy() {
- FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
- DelegatingFilterProxy proxy = new DelegatingFilterProxy();
- proxy.setTargetFilterLifecycle(true);
- proxy.setTargetBeanName("factoryBean");
- filterRegistrationBean.setFilter(proxy);
- return filterRegistrationBean;
- }
-
- /**
- * 注解访问授权动态拦截,
- * 不然不会执行Realm中的doGetAuthenticationInfo
- * @param manager
- * @return
- */
- @Bean
- public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor( SecurityManager manager){
- AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
- authorizationAttributeSourceAdvisor.setSecurityManager(manager);
- return authorizationAttributeSourceAdvisor;
- }
- }
注意点1、关闭Shiro自带的session;
因为用了jwt的访问认证,所以要把默认session支持关掉。即不保存用户登录状态,保证每次请求都重新认证。
-
- DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
- DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
- defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
- subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
- defaultWebSecurityManager.setSubjectDAO(subjectDAO);
4、配置Realm
- /**
- * Shiro自定义Realm
- */
- public class MyRealm extends AuthorizingRealm {
-
- @Autowired
- private UserService userService;
-
- /**
- * 授权
- * @param principalCollection
- * @return
- */
- @Override
- protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
- System.out.println("获取权限信息");
- //1.获取身份信息
- User pricipal = (User) principalCollection.getPrimaryPrincipal();
-
- //2.根据身份获取、角色权限等信息
- ArrayList
roles = new ArrayList<>(); - roles = ...自己Service获取Role的方法...;
- // System.out.println("用户所具有角色:"+roles);
- ArrayList
permissions = new ArrayList<>(); - permissions = ...自己Service获取Permissions的方法...;
- System.out.println("用户所具有权限:"+permissions);
-
- //3.将角色、权限添加到授权信息里面
- SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
- info.addRoles(roles);
- info.addStringPermissions(permissions);
- return info;
- }
-
- /**
- * 认证
- * @param authenticationToken
- * @return
- * @throws AuthenticationException
- *
- * 客户端传来的 username 和 password 会自动封装到 token,先根据 username 进行查询,
- * 如果返回 null,则表示用户名错误,直接 return null 即可,Shiro 会自动抛出 UnknownAccountException 异常。
- * 如果返回不为 null,则表示用户名正确,再验证密码,直接返回 SimpleAuthenticationInfo 对象即可,
- * 如果密码验证成功,Shiro 认证通过,否则返回 IncorrectCredentialsException 异常。
- */
- @Override
- protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
- //System.out.println("启动认证");
- String userId;
- // 如果这个token是来自JWTToken,说明此次登录目的是授权验证的登录
- if(authenticationToken instanceof JWTToken){
- JWTToken token = (JWTToken) authenticationToken;
- userId = ...自己解析token获取ID的方法...;
- User user = userService.getByUsername(token.getUsername());
- token.setCredentials(user.getPassword());
- if(user != null && user.getStatus().equals(1)){
- // 用数据库中已经加密的密码,再用数据库中对应的盐值加密一次
- String pwd2 = SaltUtil.encryption(user.getPassword(), user.getSalt());
- // 参数列表(实体信息,密码,盐值,realm名称)
- return new SimpleAuthenticationInfo(user,pwd2, ByteSource.Util.bytes(user.getSalt()),getName());
- }else{
- // 此操作是用户真正执行登录操作
- UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
- //获取存放到数据库中的实体类
- User user = userService.getByUsername(token.getUsername());
- //System.out.println(user);
- if(user != null && user.getStatus().equals(1)){
- // 参数列表(实体信息,密码,盐值,realm名称)
- return new SimpleAuthenticationInfo(user,user.getPassword(), ByteSource.Util.bytes(user.getSalt()),getName());
- }
- }
-
- return null;
- }
- }
4、再需要鉴权的Controller上面,添加注释就可以啦
- //需要user角色
- @RequiresRoles("user")
-
- //必须同时属于user和admin角色
- @RequiresRoles({"user","admin"})
- //用户具有index:menu权限才可访问
- @RequiresPermissions("index:menu")
1、目前没有使用缓存,每次鉴权都要去数据库查Role、查Permissions;
2、鉴权时段的登录操作,因为token中不能封装明文密码,又不能移除加密器,所以要对数据库中已经加密的密码,在此基础上再把这个密码进行一次加密,出现了不必要的资源浪费;
3、虽然实现功能,但是实现过程复杂;
最后,感谢“桃子屁屁”的大力支持。