前后端分离项目与传统的一体式项目有所不同,用户安全认证的方式不太一样,前后端分离项目不能像一体式项目那样使用session认证,所以一般使用token认证,具体原理很简单,客户端每次请求服务器的时候都会带上token密钥,服务器识别认证token后,即可识别身份并响应数据。废话不多说,直接上代码。
主要代码:
一、JwtUtil
Jwt即java web token,是比较成熟的token方案,JwtUtil里面有生成token的方法、校验token是否有效是否过期的方法、以及通过token获取解析值的方法,这里我们可以设置token的有效时间。

@Component
public class JwtUtil {



    /**
     * JWT认证过期时间 EXPIRE_TIME 分钟
     */
    private static final long EXPIRE_TIME = 30*1000;

    /**
     * 校验token是否正确
     *
     * @param token  密钥
     * @param secret 用户的密码
     * @return 是否正确
     */
    public static boolean verify(String token, String username, String secret) {
        try {
            //根据密码生成JWT效验器
            Algorithm algorithm = Algorithm.HMAC256(secret);
            JWTVerifier verifier = JWT.require(algorithm)
                    .withClaim("username", username)
                    .build();
            //效验TOKEN
            DecodedJWT jwt = verifier.verify(token);
            System.out.println("登录认证成功!");
            return true;
        } catch (Exception exception) {
            System.out.println("JwtUtil登录认证失败!");
            return false;
        }
    }

    /**
     * 获得token中的信息无需secret解密也能获得
     *
     * @return token中包含的用户名
     */
    public static String getUsername(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    /**
     * 生成token签名EXPIRE_TIME 分钟后过期
     *
     * @param username 用户名(电话号码)
     * @param secret   用户的密码
     * @return 加密的token
     */
    public static String sign(String username, String secret) {
        long nowTime = System.currentTimeMillis();
        Date date = new Date(nowTime + EXPIRE_TIME);


        Algorithm algorithm = Algorithm.HMAC256(secret);
        // 附带username信息
        return JWT.create()
                .withClaim("username", username)
                .withClaim("currentTimeMillis", String.valueOf(nowTime))
                .withExpiresAt(date)
                .sign(algorithm);
    }

    public static String getClaim(String token, String claim) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim(claim).asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }
}

二、shiroConfig
shiroConfig基本变动不大,需要注意以下几点:1.需要设置关闭shiro自带的session;2.需要设置我们自己的身份认证过滤器MyFilter类(后面会介绍),关于MyFilter类也是前后端分离使用token认证的核心,不在使用shiro自带的authc过滤器(传统项目session校验使用的就是authc过滤器)了;3.realm不要设置缓存,传统一体式项目我们使用的用户名和密码校验,因为一个用户的用户名和密码不会经常改变,所以我们可以设置缓存,改变密码后需要更新缓存,而前后端分离项目使用的是经常改变的token,所以认证不能设置缓存,而权限可以单独设置@cache缓存,在后面代码UserServiceImpl中有所体现。

@Configuration
public class shiroConfig {
    //创建shirofilter,负责拦截所有请求
    @Bean
    public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager defaultWebSecurityManager){
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(defaultWebSecurityManager);
        Map<String, Filter> filters = new HashMap<>();
        filters.put("auth", new MyFilter());
        shiroFilterFactoryBean.setFilters(filters);
        //配置系统受限资源
        //配置系统公共资源
        Map<String,String> map  = new HashMap<>();
        //顺序无关
        map.put("/user/login","anon");
        map.put("/**","auth");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(map);

        return shiroFilterFactoryBean;
    }

    //创建shiro安全管理器
    @Bean
    public DefaultWebSecurityManager getDefaultWebSecurityManager(Realm realm){
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();

        //关闭shiro自带的session
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        defaultWebSecurityManager.setSubjectDAO(subjectDAO);
        //配置realm
        defaultWebSecurityManager.setRealm(realm);
        return defaultWebSecurityManager;
    }

    //创建自定义realm
    @Bean
    public Realm getRealm(){
        CustomerRealm customerRealm = new CustomerRealm();
        //设置缓存管理器
        customerRealm.setCachingEnabled(false);
        customerRealm.setAuthenticationCachingEnabled(true);
        customerRealm.setAuthenticationCacheName("AuthenticationCache");
        customerRealm.setAuthorizationCachingEnabled(true);
        customerRealm.setAuthorizationCacheName("AuthorizationCache");

        return customerRealm;
    }

}

三、编写自己的token来继承UsernamePasswordToken
由于我们使用shiro认证的时候已经不在是使用username和password,而是通过Jwt用username和password来生成的token进行认证,所以我们需要自己来写UsernamePasswordToken,以便subject.login(token)

public class JwtToken extends UsernamePasswordToken {
    private String token;

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

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

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

四、MyFilter
我们编写自己的Filter需要继承BasicHttpAuthenticationFilter(shiro的身份认证过滤器),我们需要重写executeLogin和isAccessAllowed,在executeLogin中,通过ServletRequest直接获取token,生成JwtToken,来进行getSubject(request, response).login(jwtToken)操作,实际上就是subject.login(token),最终会调用自定义realm中的doGetAuthenticationInfo方法。
我们在Filter中如果认证失败,可以直接设置返回需要的内容response.getWriter().print(…);

@Component
public class MyFilter extends BasicHttpAuthenticationFilter {


    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader("Token");
        JwtToken jwtToken = new JwtToken(token);
        // 提交给realm进行登入,如果错误他会抛出异常并被捕获
        try {
            getSubject(request, response).login(jwtToken);
            // 如果没有抛出异常则代表登入成功,返回true
            return true;
        } catch (AuthenticationException e) {
            response.setCharacterEncoding("utf-8");
            response.getWriter().print("error");
            return false;
        }

    }



    /**
     * 执行登录认证
     *
     * @param request
     * @param response
     * @param mappedValue
     * @return
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        try {
            return executeLogin(request, response);
            // return true;有一篇博客这里直接返回true是不正确的,在这里我特别指出一下
        } catch (Exception e) {
            System.out.println("JwtFilter过滤认证失败!");
            return false;
        }
    }


    /**
     * 对跨域提供支持
     * @param request
     * @param response
     * @return
     * @throws Exception
     */
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        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);
    }
}

五、CustomerRealm
自定义Realm,doGetAuthenticationInfo中,核心是我们先通过token获取username,通过username再查询数据库来获取password,最后调用JwtUtil.verify(token, username, user.getPassword()方法,来判断token是否匹配是否过期

public class CustomerRealm extends AuthorizingRealm {

    @Autowired
    @Lazy
    private UserService userService;


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

    //授权

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {

        //shiro中Principal存储的是字符串
 //       String primaryPrincipal = (String) principalCollection.getPrimaryPrincipal();
//       User user = userService.getUserByPrincipal(primaryPrincipal);

        //也可以直接存储user对象,以便在程序中直接获取使用,但是AuthenticationInfo在存储的时候也需要做响应的处理
        String username = JwtUtil.getUsername(principalCollection.toString());
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        List<String> pers = userService.getPers();
        for (String per : pers) {
            authorizationInfo.addStringPermission(per);
        }
        return authorizationInfo;
    }

    //认证
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        String token = (String) authenticationToken.getPrincipal();
        // 解密获得username,用于和数据库进行对比
        String username = null;
        try {
            //这里工具类没有处理空指针等异常这里处理一下(这里处理科学一些)
            username = JwtUtil.getUsername(token);
        } catch (Exception e) {
            throw new AuthenticationException("heard的token拼写错误或者值为空");
        }
        if (username == null) {
            System.out.println("token无效(空''或者null都不行!)");
            throw new AuthenticationException("token无效");
        }
        User user = userService.getUserByUsername(username);
        if (user == null) {
            System.out.println("用户不存在!)");
            throw new AuthenticationException("用户不存在!");
        }
        if (!JwtUtil.verify(token, username, user.getPassword())) {
            System.out.println("用户名或密码错误(token无效或者与登录者不匹配)!");
            throw new AuthenticationException("用户名或密码错误(token无效或者与登录者不匹配)!");
        }
        return new SimpleAuthenticationInfo(token,token,this.getName());
    }
}

六、UserServiceImpl
由于我们的realm不能设置认证缓存,所以我们可以单独的设置获取权限的缓存

@Service("userService")
public class UserServiceImpl implements UserService {

    @Autowired
    private UserMapper userMapper;


    @Override
    public User getUserByUsername(String username) {
        return userMapper.getUserByUsername(username);
    }

   //获取权限,这里我为了简单测试,没有写参数
    @Override
    @Cacheable(value = "cache")
    public List<String> getPers() {
        System.out.println("请求了。。。。。。。。。。。。。");
        return userMapper.getPers();
    }
}

七、UserController
"/login"方法为公开方法,客户端登录后服务器返回token响应,其他方法为测试方法,都会被自定义Filter拦截进行认证。

@Controller
@RequestMapping("/user")
public class UserController {
    @Autowired
    private UserService userService;
    @RequestMapping("/login")
    @ResponseBody
    public String login(User user, HttpServletResponse response) throws Exception {
        String tokenStr = JwtUtil.sign(user.getUsername(), user.getPassword());
        JwtToken token = new JwtToken(tokenStr);
        Subject subject = SecurityUtils.getSubject();
        try {
            subject.login(token);
            System.out.println("认证成功");
            HttpServletResponse httpServletResponse = (HttpServletResponse) response;
            httpServletResponse.setHeader("token", tokenStr);
        }catch (Exception e){
            e.printStackTrace();
            System.out.println("认证失败");
        }
        return "success";
    }

    @RequestMapping("/test")
    @ResponseBody
    @RequiresPermissions("user:add:*")
    public String test() {
        return "success";
    }
}

后续改进:
一、安全性问题
这里我们使用了token进行登录认证,由于每次客户端请求服务器的时候都需要带上token,所以存在一定的风险被窃取,这样黑客极有可能拿着token来窃取服务器数据或者攻击,所以为了安全,token的过期时间往往设置的比较短,但是这样也带来一个问题,就是用户每过一段时间就需要重新登录认证,显然这样是不够友好的。
解决办法:服务器在生成token的时候也会生成一个refresh-token,并把refresh-token保存在redis中,并把这两个token都发送给客户端,客户端将token和refresh-token保存的浏览器内存里,在请求数据的时候只带上token,服务器检测token有效后还比对 Token 中的时间戳与缓存中的 RefreshToken 时间戳是否一致,一致后才能请求到数据。当发现token过期的时候,客户端会使用refresh-token来向服务器请求刷新token,服务器会生成新的token和refresh-token,刷新redis中的refresh-token,并把两个新的token发送给客户端,这样一来,refresh-token只在第一次和刷新token的时候才会进行传输,就降低了被窃取的风险,黑客即便是拿到了token,但如果没有拿到refresh-token,短时间内的数据丢失和破坏也是在有限范围。同时通过设置refresh-token,还会带来另一个好处,就是我们可以通过控制服务器redis中的refresh-token来间接控制jwt的token认证。、

二、用户登出的问题
当我们使用refresh-token的时候,我们可以通过控制redis中的refresh-token来控制用户是否登出(通过删除refresh-token来实现),如果我们一些简单的项目没有使用refresh-token怎么办呢?
解决办法:我们可以设置token的黑名单,比如登出的时候把token添加到黑名单中,并在我们自定义的Filter中的进行检测请求token是否在黑名单中,如果在黑名单中,就报错,提示登录失败。