一、认证流程

  1. 账号密码登录,后端使用shiro认证
  2. 根据账号密码生成jwt,并放入请求头后传给前端,前端将jwt存入localStorage
  3. 登录成功后的操作通过JWTRealm来进行认证,如果jwt出错则无法操作

    二、代码实现

    2.1 项目结构

    image.png

    2.2 导入依赖

    1. <dependency>
    2. <groupId>org.apache.shiro</groupId>
    3. <artifactId>shiro-spring-boot-web-starter</artifactId>
    4. <version>1.7.1</version>
    5. </dependency>

2.3 新建Realm,根据目前的场景,需要两个Realm

2.3.1 DbRealm

与数据库交互,认证信息直接使用UsernamePasswordToken

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.woniuxy.ticket.auth.entity.User;
import com.woniuxy.ticket.auth.mapper.UserMapper;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;

/***
 *自定义Realm,用于与数据库交互
 */
public class DbRealm extends AuthorizingRealm {
    @Autowired
    private UserMapper userMapper;

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return null;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        QueryWrapper queryWrapper = new QueryWrapper();
        queryWrapper.eq("account", upToken.getUsername());
        User user = userMapper.selectOne(queryWrapper);
        if (user == null) {
            throw new UnknownAccountException("账号:" + upToken.getUsername() + "不存在");
        }
        return new SimpleAuthenticationInfo(user, user.getPassword(), getName());
    }

    //交给合适的Realm认证
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof UsernamePasswordToken;
    }
}

2.3.2 JwtRealm

直接校验JWT是否合法,认证信息直接在JWT中,所以先自定义Token接收该JWT
JwtToken

import org.apache.shiro.authc.AuthenticationToken;

/**
 * 自定义Token,用于封装JWT
 */
public class JwtToken implements AuthenticationToken {
    private String jwt;

    public JwtToken(String jwt) {
        this.jwt = jwt;
    }

    @Override
    public Object getPrincipal() {
        return jwt;
    }

    @Override
    public Object getCredentials() {
        return jwt;
    }
}

JWTRealm

import com.woniuxy.ticket.auth.jwt.Audience;
import com.woniuxy.ticket.auth.jwt.JwtUtil;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;

public class JwtRealm extends AuthorizingRealm {
    @Autowired
    private Audience audience;

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return null;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        JwtToken jwtToken = (JwtToken) token;
        String jwt = jwtToken.getPrincipal().toString();
        if (!JwtUtil.parseJwt(jwt, audience.getBase64Secret())) {
            throw new AuthenticationException("校验JWT异常");
        }
        return new SimpleAuthenticationInfo(jwt, jwt, getName());
    }

    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }
}

2.4 自定义过滤器,用于对请求进行认证校验

JwtFilter

import com.fasterxml.jackson.databind.ObjectMapper;
import com.woniuxy.ticket.commons.entity.ResponseResult;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;

/**
 * 自定义过滤器,用于JWT认证的校验
 *
 */
@Slf4j
public class JwtFilter extends AuthenticatingFilter {
    @Override
    protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
        String jwtToken = ((HttpServletRequest) request).getHeader("jwt");
        return new JwtToken(jwtToken);
    }

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        if (this.isLoginRequest(request, response))
            return true;
        boolean allowed = false;
        try {
            allowed = executeLogin(request, response);
        } catch (IllegalStateException e) { // not found any token
            log.error("Not found any token");
        } catch (Exception e) {
            log.error("Error occurs when login", e);
        }
        return allowed || super.isPermissive(mappedValue);
    }

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        ResponseResult<Void> result = new ResponseResult<>(403, "会话失效,重新登录");
        ObjectMapper objectMapper = new ObjectMapper();
        response.getWriter().write(objectMapper.writeValueAsString(result));
        return false;
    }
}

2.5 ShiroConfig

将所有的配置集中实现

@Configuration
public class ShiroConfig {
    @Bean
    public Realm dbRealm() {
        return new DbRealm();
    }
    @Bean
    public Realm jwtRealm() {
        return new JwtRealm();
    }

    @Bean
    public DefaultWebSecurityManager securityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealms(Arrays.asList(dbRealm(),jwtRealm()));
        return securityManager;
    }

    @Bean
    public JwtFilter jwtFilter() {
        return new JwtFilter();
    }

    //核心Bean
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean() {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager());

        //增加自定义的过滤器
        Map<String, Filter> filterMap = new HashMap<>();
        filterMap.put("jwt", new JwtFilter());
        shiroFilterFactoryBean.setFilters(filterMap);

        //配置请求的URL与过滤器的关系
        Map<String, String> map = new LinkedHashMap<>();
        map.put("/manager/login", "anon");
        map.put("/menu/get","jwt");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
        return shiroFilterFactoryBean;
    }

}

2.6 在多Realm环境中,默认不会抛出特定的异常

需要新建一个类继承ModularRealmAuthenticator并重写其中doMultiRealmAuthentication方法,并且重新在ShiroConfig中的SecurityManager进行设置

2.6.1 新建MyRealmAuthenticator

import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.pam.AuthenticationStrategy;
import org.apache.shiro.authc.pam.ModularRealmAuthenticator;
import org.apache.shiro.realm.Realm;

import java.util.Collection;

@Slf4j
public class MyRealmAuthenticator extends ModularRealmAuthenticator {
    @Override
    protected AuthenticationInfo doMultiRealmAuthentication(Collection<Realm> realms, AuthenticationToken token) {
        AuthenticationStrategy strategy = getAuthenticationStrategy();
        AuthenticationInfo aggregate = strategy.beforeAllAttempts(realms, token);
        if (log.isTraceEnabled()) {
            log.trace("Iterating through {} realms for PAM authentication", realms.size());
        }
        AuthenticationException authenticationException = null;
        for (Realm realm : realms) {
            aggregate = strategy.beforeAttempt(realm, token, aggregate);
            if (realm.supports(token)) {
                log.trace("Attempting to authenticate token [{}] using realm [{}]", token, realm);
                AuthenticationInfo info = null;
                try {
                    info = realm.getAuthenticationInfo(token);
                } catch (AuthenticationException e) {
                    authenticationException = e;
                    if (log.isDebugEnabled()) {
                        String msg = "Realm [" + realm + "] threw an exception during a multi-realm authentication attempt:";
                        log.debug(msg, e);
                    }
                }
                aggregate = strategy.afterAttempt(realm, token, info, aggregate, authenticationException);
            } else {
                log.debug("Realm [{}] does not support token {}.  Skipping realm.", realm, token);
            }
        }
        //新增的代码
        if (authenticationException != null) {
            throw authenticationException;
        }
        aggregate = strategy.afterAllAttempts(token, aggregate);
        return aggregate;
    }
}

2.6.2 修改ShiroConfig

import com.woniuxy.ticket.auth.shiro.*;
import org.apache.shiro.authc.pam.FirstSuccessfulStrategy;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.servlet.Filter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

@Configuration
public class ShiroConfig {
    @Bean
    public DbRealm dbRealm() {
        return new DbRealm();
    }
    @Bean
    public Realm jwtRealm() {
        return new JwtRealm();
    }

    @Bean
    public DefaultWebSecurityManager securityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//        securityManager.setRealm(dbRealm());
        //多个Realm需要设置,不然无法捕获账号不存在和密码错误异常
        //需要新建一个类继承ModularRealmAuthenticator并重写doMultiRealmAuthentication方法
        MyRealmAuthenticator authenticator = new MyRealmAuthenticator();
        authenticator.setRealms(Arrays.asList(dbRealm(),jwtRealm()));
        authenticator.setAuthenticationStrategy(new FirstSuccessfulStrategy());
        securityManager.setAuthenticator(authenticator);

        //设置授权器为DbRealm
        securityManager.setAuthorizer(dbRealm());
        return securityManager;
    }

    @Bean
    public JwtFilter jwtFilter() {
        return new JwtFilter();
    }

    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean() {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager());

        //增加自定义的过滤器
        Map<String, Filter> filterMap = new HashMap<>();
        filterMap.put("jwt", jwtFilter());
        shiroFilterFactoryBean.setFilters(filterMap);

        Map<String, String> map = new LinkedHashMap<>();
        map.put("/user/login", "anon");
        map.put("/user/getPerms","jwt");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
        return shiroFilterFactoryBean;
    }

}

2.7 逻辑实现

2.7.1 实体类**

UserRoleDto

@Data
public class UserRoleDto implements Serializable {
    private Integer id;

    private String account;

    private String password;

    private Integer rid;

    //状态:0启用,1禁用
    private Integer status;

    private RolePermsDto rolePermsDto;

    private static final long serialVersionUID = 1L;
}

RolePermsDto

@Data
public class RolePermsDto implements Serializable {
    private Integer id;

    private String name;

    private List<Perms> perms;

    private static final long serialVersionUID = 1L;

}

Perms

@Data
public class Perms implements Serializable {

    private Integer id;

    private String name;

    private String code;

    private String link;

    private Integer parentId;

    private String type;

    private String status;

    private static final long serialVersionUID = 1L;
}

2.7.2 UserMapper和UserMapper.xml

@Mapper
@Repository
public interface UserMapper extends BaseMapper<User> {
    //通过用户id获取角色及其权限
    UserRoleDto findUserPerms(@Param("id") int id);

    //通过用户id获取角色及其菜单
    UserRoleDto findUserMenus(@Param("id") int id);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.woniuxy.ticket.auth.mapper.UserMapper">

    <!-- 查询用户权限 -->
    <select id="findUserPerms" resultMap="user_role_perms_dto_map">
        select id,account,rid from user where id = #{id} and status = 0
    </select>
    <resultMap id="user_role_perms_dto_map" type="UserRoleDto">
        <id column="id" property="id"></id>
        <result column="rid" property="rid"></result>
        <collection property="rolePermsDto" column="rid" select="findRolePermsDtoByRid"></collection>
    </resultMap>
    <select id="findRolePermsDtoByRid" resultMap="role_perms_dto">
        select * from role where id = #{rid}
    </select>
    <resultMap id="role_perms_dto" type="RolePermsDto">
        <id column="id" property="id"></id>
        <collection property="perms" column="id" select="findPermsByRid"></collection>
    </resultMap>
    <select id="findPermsByRid" resultType="Perms">
        select * from role_perms rp,perms p where rp.rid = #{id} and rp.pid = p.id and p.type = 'a'
    </select>

    <!-- 查询用户菜单 -->
    <select id="findUserMenus" resultMap="user_role_menus_dto_map">
        select id,account,rid from user where id = #{id} and status = 0
    </select>
    <resultMap id="user_role_menus_dto_map" type="UserRoleDto">
        <id column="id" property="id"></id>
        <result column="rid" property="rid"></result>
        <collection property="rolePermsDto" column="rid" select="findRoleMenusDtoByRid"></collection>
    </resultMap>
    <select id="findRoleMenusDtoByRid" resultMap="role_menus_dto">
        select * from role where id = #{rid}
    </select>
    <resultMap id="role_menus_dto" type="RolePermsDto">
        <id column="id" property="id"></id>
        <collection property="perms" column="id" select="findMenusByRid"></collection>
    </resultMap>
    <select id="findMenusByRid" resultType="Perms">
        select * from role_perms rp,perms p where rp.rid = #{id} and rp.pid = p.id and p.type = 'm'
    </select>
</mapper>

2.7.3 UserService和UserServiceImpl

UserService

public interface UserService extends IService<User> {
    //通过用户id获取角色及其权限
    UserRoleDto findUserPerms(int id);

    //通过用户id获取角色及其权限
    UserRoleDto findUserMenus(int id);
}

UserServiceImpl

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

    @Resource
    private UserMapper userMapper;

    @Override
    public UserRoleDto findUserPerms(int id) {
        return userMapper.findUserPerms(id);
    }

    @Override
    public UserRoleDto findUserMenus(int id) {
        return userMapper.findUserMenus(id);
    }
}

2.7.4 UserController

import com.woniuxy.ticket.auth.entity.User;
import com.woniuxy.ticket.auth.entity.dto.UserRoleDto;
import com.woniuxy.ticket.auth.jwt.Audience;
import com.woniuxy.ticket.auth.jwt.JwtUtil;
import com.woniuxy.ticket.auth.service.UserService;
import com.woniuxy.ticket.commons.entity.ResponseResult;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.UnknownAccountException;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.subject.Subject;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;

@RestController
@RequestMapping("user")
public class UserController {

    @Resource
    private Audience audience;

    @Resource
    private UserService userService;

    @PostMapping("/login")
    public ResponseResult login(User user, HttpServletResponse response) {
        UsernamePasswordToken token = new UsernamePasswordToken(user.getAccount(),user.getPassword());

        Subject subject = SecurityUtils.getSubject();

        try{
            subject.login(token);
        }catch (UnknownAccountException e){
            return new ResponseResult(404,"账号不存在");
        }catch (IncorrectCredentialsException e){
            return new ResponseResult(504,"密码不正确");
        }
        catch (AuthenticationException e){
            return new ResponseResult(505,"登录异常");
        }
        User user1 = (User) subject.getPrincipal();
        String jwt = JwtUtil.createJWT(user1.getId(),user1.getAccount(),audience);
        response.setHeader("Authorization", jwt);

        return new ResponseResult().success();
    }

    @PostMapping("/getPerms")
    public ResponseResult getPerms(@RequestHeader("jwt") String jwt){
        if(JwtUtil.parseJwt(jwt,audience.getBase64Secret())){
            int id = JwtUtil.getUserId(jwt,audience.getBase64Secret());
            UserRoleDto userRoleDto = userService.findUserPerms(id);
            return new ResponseResult().success(userRoleDto);
        }
        return new ResponseResult().error();
    }

    @PostMapping("/getMenus")
    public ResponseResult getMenus(@RequestHeader("jwt") String jwt){
        if(JwtUtil.parseJwt(jwt,audience.getBase64Secret())){
            int id = JwtUtil.getUserId(jwt,audience.getBase64Secret());
            UserRoleDto userRoleDto = userService.findUserMenus(id);
            return new ResponseResult().success(userRoleDto);
        }
        return new ResponseResult().error();
    }

}

2.7.5 前端页面

login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="jquery.min.js"></script>
    <script>
        $(function () {
            $("#loginBtn").click(function () {
                let data = {
                    "account": $("#account").val(),
                    "password": $("#password").val()
                }
                $.post("http://localhost:9006/user/login", data, function (r, n, xhq) {
                    console.log(r);
                    if (r.statusCode === 200) {
                        localStorage.setItem("jwt", xhq.getResponseHeader("Authorization"));
                        location.href = "index.html";
                    } else if (r.statusCode === 404) {
                        alert(r.message);
                    } else if (r.statusCode === 504) {
                        alert(r.message);
                    } else if (r.statusCode === 505) {
                        alert(r.message);
                    }
                })
            })
        })
    </script>
</head>
<body>
    <input type="text" id="account"><br>
    <input type="text" id="password"><br>
    <input type="button" value="登录" id="loginBtn">
</body>
</html>

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="jquery.min.js"></script>
    <script>
        $(function(){
            $("#permsBtn").click(function(){
                $.ajax({
                    type: "post",
                    url: "http://localhost:9006/user/getPerms",
                    headers: {'jwt':localStorage.getItem("jwt")},
                    success: function (r) {
                        console.log(r);
                    }
                });
            });
            $("#menusBtn").click(function(){
                $.ajax({
                    type: "post",
                    url: "http://localhost:9006/user/getMenus",
                    headers: {'jwt':localStorage.getItem("jwt")},
                    success: function (r) {
                        console.log(r);
                    }
                });
            });
        })
    </script>
</head>
<body>
    首页
    <button id="permsBtn">获取权限</button>
    <button id="menusBtn">获取菜单</button>
</body>
</html>

2.8 测试

2.8.1 账号不存在

image.png

2.8.2 密码错误

image.png

2.8.3 正确登录后跳转

image.png

2.8.4 获取权限

image.png

2.8.5 获取菜单

image.png

2.8.6 如果不登录直接通过url访问获取权限接口

image.png

2.9 其他问题

2.9.1 跨域问题

跨域:在服务器上使用一个Filter,其中设置响应头,要将其配置到前面
TicketCrossFilter

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 设置跨域的过滤器
 */
public class TicketCrossFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;
        String origin = req.getHeader("Origin");
        if (!org.springframework.util.StringUtils.isEmpty(origin)) {
            // 带cookie的时候,origin必须是全匹配,不能使用*
            res.addHeader("Access-Control-Allow-Origin", origin);
        }
        res.addHeader("Access-Control-Allow-Methods", "*");
        String headers = req.getHeader("Access-Control-Request-Headers");
        // 支持所有自定义头
        if (!org.springframework.util.StringUtils.isEmpty(headers)) {
            res.addHeader("Access-Control-Allow-Headers", headers);
        }
        res.addHeader("Access-Control-Max-Age", "3600");
        res.addHeader("Access-Control-Allow-Credentials", "false");

        String exposeHeaders = "access-control-expose-headers";
        // if (!res.containsHeader(exposeHeaders))
        res.setHeader(exposeHeaders, "*");
        // 处理options请求
        if (req.getMethod().toUpperCase().equals("OPTIONS")) {
            return;
        }
        chain.doFilter(request, response);
    }
}

启动类中注入过滤器

@SpringBootApplication
@MapperScan("com.woniuxy.ticket.auth.mapper")
public class TicketAuthApplication {

    public static void main(String[] args) {
        SpringApplication.run(TicketAuthApplication.class, args);
    }

    @Bean
    public FilterRegistrationBean<TicketCrossFilter> registrationFilterBean() {
        FilterRegistrationBean<TicketCrossFilter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new TicketCrossFilter());
        filterRegistrationBean.addUrlPatterns("/*");
        filterRegistrationBean.setOrder(0);
        filterRegistrationBean.setEnabled(true);
        return filterRegistrationBean;
    }
}

2.9.2 SpringBoot和JSON格式解析的错误

可能会出现以下报错:
no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)
这是因为SpringBoot对Json格式的解析,Springboot中有一套对Json格式输出的严格控制—JackSon
问题解决的办法其实就是差在配置文件中:<将jackSon关闭即可>

spring:
  jackson:
    serialization:
      FAIL_ON_EMPTY_BEANS: false