Springboot+jwt+shiro实现前后端分离权限验证

1.基本结构

首先新建了用户、权限和角色表
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
之后创建DAO层、service、controller层对应代码,此处省略就不一一列出了

2、增加maven依赖

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.4.0</version>
</dependency>
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>1.3.2</version>
</dependency>
<!-- shiro-redis缓存插件 -->
<dependency>
    <groupId>org.crazycake</groupId>
    <artifactId>shiro-redis</artifactId>
    <version>2.4.2.1-RELEASE</version>
</dependency>

3、配置JWT

创建jwtUtils,实现一个简单的JWT加密、检验

public class JWTUtils {
    private static final long EXPIRE_TIME = 10 * 60 * 1000;//十分钟过期
    private static final String TOKEN_SECRET = "mywords";  //密钥盐

    /**
     * 签名生成
     *
     * @param user
     * @return
     */
    public static String sign(User user) {
        String token = null;
        try {
            Date expiresAt = new Date(System.currentTimeMillis() + EXPIRE_TIME);
            token = JWT.create().withIssuer("words")
                    .withClaim("username", user.getUsername())
                    .withAudience(user.getId().toString())
                    .withExpiresAt(expiresAt)
                    //使用HMAC256加密
                    .sign(Algorithm.HMAC256(TOKEN_SECRET));
        } catch (Exception e) {
            e.printStackTrace();
        }
        return token;
    }

    /**
     * 获取用户名
     *
     * @param token token中包含了用户名
     * @return
     */
    public static String getUsername(String token) {
        try {
            JWTVerifier verifier = JWT.require(Algorithm.HMAC256(TOKEN_SECRET)).withIssuer("words").build();
            DecodedJWT jwt = verifier.verify(token);
            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 获取id
     *
     * @param token
     * @return
     */
    public static String getId(String token) {
        try {
            JWTVerifier verifier = JWT.require(Algorithm.HMAC256(TOKEN_SECRET)).withIssuer("words").build();
            DecodedJWT jwt = verifier.verify(token);
            return jwt.getAudience().get(0);
        } catch (JWTDecodeException e) {
            e.printStackTrace();
            return null;
        }
    }
    /**
     * 签名验证
     *
     * @param token
     * @return
     */
    public static boolean verify(String token) {
        try {
            JWTVerifier verifier = JWT.require(Algorithm.HMAC256(TOKEN_SECRET)).withIssuer("words").build();
            DecodedJWT jwt = verifier.verify(token);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
}

自定义异常
为了能够抛出不同情况的异常,对权限异常进行统一化管理,所以我自己写了一个自定义权限异常

@Getter
public class AuthException extends RuntimeException {
    private Integer code;

    /**
     * 使用已有的错误类型
     * @param authEnum 枚举类中的错误类型
     */
    public AuthException(AuthEnum authEnum){
        super(authEnum.getMsg());
        this.code = authEnum.getCode();
    }

    /**
     * 自定义错误类型
     * @param code 自定义的错误码
     * @param msg 自定义的错误提示
     */
    public AuthException(Integer code, String msg){
        super(msg);
        this.code = code;
    }
}

枚举类:

@Getter
@AllArgsConstructor
public enum AuthEnum {
    /**
     * 错误类型
     */
    TOKEN_FAIL_STATE(100, "token已失效!"),
    ROLE_HAVE_NO_PERMISSION(101, "用户无权限"),
    NO_ROLE_CONFIGER(102, "用户无角色"),
    NO_User_NULL(103, "用户状态异常"),
    NO_LOGIN(104, "用户未登录!"),
    NO_NULL_TOKEN(105, "token不能为空!"),
    ILLEGAL_LOGIN(202, "非法请求!");

    /**
     * 错误码
     */
    private int code;

    /**
     * 提示信息
     */
    private String msg;
}

4、配置 Shiro

4.1实现JWTToken

JWTToken 差不多就是 Shiro 用户名密码的载体。因为我们是前后端分离,服务器无需保存用户状态,所以不需要 RememberMe 这类功能,我们简单的实现下 AuthenticationToken 接口即可。因为 token 自己已经包含了用户名等信息,所以这里我就弄了一个字段。如果你喜欢钻研,可以看看官方的 UsernamePasswordToken 是如何实现的。

public class JWTToken implements AuthenticationToken {

    // 密钥
    private String token;

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

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

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

4.2、自定义realm

自定义Realm,继承AuthorizingRealm,完成访问授权和登录认证
doGetAuthenticationInfo:登录认证,获取用户信息、校验
doGetAuthorizationInfo:权限信息认证(包括角色以及权限)是用户访问controller的时候才进行验证(redis存储的此处权限信息)

@Service
@Slf4j
public class MyRealm extends AuthorizingRealm {
    @Autowired
    private RedisUtils redisUtils;

    /**
     * 大坑!,必须重写此方法,不然Shiro会报错
     */
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JWTToken;
    }

    //授权
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        //获取到安全数据
        LoginUser user = (LoginUser)principalCollection.getPrimaryPrincipal();
        Set<String> authNames = (Set<String>)user.getAuths().get("authNames");
        //构造权限数据
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        info.addStringPermissions(authNames);
        return info;
    }

    //认证
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
        String token = (String) auth.getCredentials();
        if (token == null) {
            throw new AuthException(AuthEnum.NO_NULL_TOKEN);
        }
        if (!jwtTokenRefresh(token)) {
            throw new AuthException(AuthEnum.TOKEN_FAIL_STATE);
        }
        LoginUser loginUser = checkUserToken(token);
        return new SimpleAuthenticationInfo(loginUser, token, this.getName());
    }

    /**
     * 检查token的有效性
     *
     * @param token
     * @return
     */
    public LoginUser checkUserToken(String token) {
        //用户登录时已经判断过用户名和密码,所以此处直接从redis获取
        String userJson = redisUtils.get("user:" + token);
        if(StringUtil.isNullOrEmpty(userJson)){
            throw new AuthException(AuthEnum.TOKEN_FAIL_STATE);
        }
        User user = JacksonUtils.deserialize(userJson, User.class);
        //从redis中取出当前权限
        String authJson = redisUtils.get("authName" + user.getId());
        Set<String> authNames = JacksonUtils.deserialize(authJson, HashSet.class);
        LoginUser loginUser = new LoginUser(user.getUsername(),authNames);
        return loginUser;
    }


    /**
     * JWTToken刷新生命周期 (实现: 用户在线操作不掉线功能)
     * 1、登录成功后将用户的JWT生成的Token作为k、v存储到cache缓存里面(这时候k、v值一样),缓存有效期设置为Jwt有效时间的2倍
     * 2、当该用户再次请求时,通过JWTFilter层层校验之后会进入到doGetAuthenticationInfo进行身份验证
     * 3、当该用户这次请求jwt生成的token值已经超时,但该token对应cache中的k还是存在,则表示该用户一直在操作只是JWT的token失效了,
     * 程序会给token对应的k映射的v值重新生成JWTToken并覆盖v值,该缓存生命周期重新计算
     * 4、当该用户这次请求jwt在生成的token值已经超时,并在cache中不存在对应的k,则表示该用户账户空闲超时,返回用户信息已失效,请重新登录。
     * 注意: 前端请求Header中设置Authorization保持不变,校验有效性以缓存中的token为准。
     * 用户过期时间 = Jwt有效时间 * 2。
     *
     * @param token
     * @return
     */
    public Boolean jwtTokenRefresh(String token) {
        String tokenRedis = redisUtils.get("user_token:" + token);
        String userJson = redisUtils.get("user:" + token);
        if (!StringUtil.isNullOrEmpty(tokenRedis) && !StringUtil.isNullOrEmpty(userJson)) {
            //检验redis是否失效,如果超时证明一直在操作
            if (!JWTUtils.verify(tokenRedis)) {
                User user = JacksonUtils.deserialize(userJson, User.class);
                String newToken = JWTUtils.sign(user);
                //时间设置为二十分钟
                redisUtils.setForTimeCustom("user_token:" + token, newToken, 20, TimeUnit.MINUTES);
                redisUtils.setForTimeCustom("user:" + token, userJson, 20, TimeUnit.MINUTES);
                log.info("------用户更新token------");
            }
            return true;
        }
        return false;
    }
}

4.3、重写 Filter
所有的请求都会先经过 Filter,所以我们继承官方的 BasicHttpAuthenticationFilter ,并且重写鉴权的方法。

代码的执行流程 preHandle -> isAccessAllowed -> isLoginAttempt -> executeLogin 。

public class JWTFilter extends BasicHttpAuthenticationFilter {
    /**
     * 判断用户是否想要登录
     * 检查请求头里是否带有Authorization字段
     *
     * @param request
     * @param response
     * @return
     */
    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        HttpServletRequest req = (HttpServletRequest)request;
        String authorization = req.getHeader("Authorization");
        return authorization != null;
    }

    /**
     * 登陆拦截
     *
     * @param request
     * @param response
     * @return
     * @throws Exception
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest req = (HttpServletRequest)request;
        String authorization = req.getHeader("Authorization");
        JWTToken token = new JWTToken(authorization);
        //提交给realm进行判断,如果错误将会直接抛出异常
        getSubject(request,response).login(token);
        //如果没有抛出异常则代表登陆成功,返回true
        return true;
    }

    /**
     * 设置返回接口为true是为了判断用户和游客看到的内容不同
     * 如果我们返回false,那么请求将会直接被拦截,用户看不到任何东西
     * 所以我们返回true,controller层可以通过subject.isAuthenticated()判断用户是否登入
     * 如果有些资源只有登上用户才能访问,我们只需要在方法上面加上 @RequiresAuthentication 注解即可
     * 但是这样做有一个缺点,就是不能够对GET,POST等请求进行分别过滤鉴权(因为我们重写了官方的方法),但实际上对应用影响不大
     *
     * @param request
     * @param response
     * @param mappedValue
     * @return
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        if(isLoginAttempt(request,response)){//有token
            try {
                executeLogin(request,response);
                return true;
            }catch (Exception e){
                //抛出token失效异常
                throw new AuthException(AuthEnum.TOKEN_FAIL_STATE);
            }
        }else {
            throw new AuthException(AuthEnum.NO_NULL_TOKEN);
        }
    }

    /**
     * 对跨域提供支持
     *
     * @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);
    }
}

4.4、配置shiro
上面对于数据源之类的还有认证授权都完事了,然后就将这些东西注入到Shiro的配置里面,并且标记上@Configuration注入到spring容器就生效了。

@Configuration
@Slf4j
public class ShiroConfig {
    @Value("${spring.redis.host}")
    private String host;
    @Value("${spring.redis.port}")
    private int port;
    @Value("${spring.redis.password}")
    private String password;


    /**
     * 创建安全管理器
     *
     * @param realm
     * @return
     */
    @Bean("securityManager")
    public DefaultWebSecurityManager getManager(MyRealm realm) {
        DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
        //注入自己的realm
        manager.setRealm(realm);
        //自定义缓存实现,使用redis
        manager.setCacheManager(cacheManager());
        //自定义session管理,使用redis
        manager.setSessionManager(sessionManager());
        return manager;
    }

    /**
     * 配置shiro的过滤器工厂
     */
    @Bean("shiroFilter")
    public ShiroFilterFactoryBean factory(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
        //添加自己的过滤器并取名为jwt
        Map<String, Filter> filterMap = new HashMap<>();
        filterMap.put("jwt", new JWTFilter());
        factoryBean.setFilters(filterMap);

        factoryBean.setSecurityManager(securityManager);
        //无权限跳转
        factoryBean.setUnauthorizedUrl("/authority/unauth");
        /*
         * 自定义url规则
         */
        Map<String, String> filterRuleMap = new HashMap<>();
        // 所有请求通过我们自己的JWT Filter
        filterRuleMap.put("/**", "jwt");
        filterRuleMap.put("/user/login", "anon");
        filterRuleMap.put("/user/register", "anon");
        //注册
        //authc -- 认证之后访问(登录)
        //filterRuleMap.put("/**","authc");
        factoryBean.setFilterChainDefinitionMap(filterRuleMap);
        return factoryBean;
    }

    /**
     * 下面的代码是添加注解支持
     */
    @Bean
    @DependsOn("lifecycleBeanPostProcessor")
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        // 强制使用cglib,防止重复代理和可能引起代理出错的问题
        // https://zhuanlan.zhihu.com/p/29161098
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }

    /**
     * 管理shiro的bean生命周期
     *
     * @return
     */
    @Bean
    public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }

    /**
     * 注解访问授权动态拦截,不然不会执行doGetAuthenticationInfo
     *
     * @param securityManager
     * @return
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }
    /**
     * 使用Redis作为缓存需要shiro重写cache、cacheManager、SessionDAO
     * 1.redis的控制器,操作redis
     */
    public RedisManager redisManager() {
        RedisManager redisManager = new RedisManager();
        redisManager.setHost(host);
        redisManager.setPort(port);
        redisManager.setPassword(password);
        // 配置过期时间
        redisManager.setExpire(1800);
        return redisManager;
    }

    /**
     * 2.sessionDao
     */
    public RedisSessionDAO redisSessionDAO() {
        RedisSessionDAO sessionDAO = new RedisSessionDAO();
        sessionDAO.setRedisManager(redisManager());
        return sessionDAO;
    }

    /**
     * 3.会话管理器
     */
    public DefaultWebSessionManager sessionManager() {
        DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
        sessionManager.setSessionDAO(redisSessionDAO());
        return sessionManager;
    }

    /**
     * 缓存管理器
     *
     * @return
     */
    public RedisCacheManager cacheManager() {
        RedisCacheManager redisCacheManager = new RedisCacheManager();
        redisCacheManager.setRedisManager(redisManager());
        return redisCacheManager;
    }
}

5、遇到的坑

5.1、上面的配置好了后,在方法上添加@RequiresPermissions注解,但是却一直报类型转换错误,找了一圈才发现原来是一个很基础的问题,我把@RequiresPermissions注解放到了@GetMapping注解上方,所以才会一直报类型转换异常
5.2、shiro整合缓存时一直获取不到.yml配置文件中的地址和端口号
大致原因是因为LifecycleBeanPostProcessor这个类,LifecycleBeanPostProcessor用于在实现了Initializable接口的Shiro bean初始化时调用Initializable接口回调,在实现了Destroyable接口的Shiro bean销毁时调用 Destroyable接口回调。而我在创建的EhCacheManager 正是实现了Initializable接口。
最后解决问题很简单,只要在创建LifecycleBeanPostProcessor的方法变为静态static方法就可以了。

  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
实现Spring BootJWT前后端分离的单点登录,您需要按照以下步骤进行: 1. 在后端实现JWT认证和授权 在后端,您需要使用Spring Security和JWT实现认证和授权。当用户登录成功时,使用JWT生成一个token并将其返回给前端。在后续请求中,前端需要将token作为Authorization Header发送到后端验证用户的身份和权限。 2. 在前端存储JWT token 在前端,您需要将JWT token存储在客户端(浏览器或移动应用程序)中。您可以使用localStorage或sessionStorage来存储JWT token。 3. 在前端实现单点登录 当用户访问其他资源(另一个前端应用或后端API)时,前端需要检查本地存储的JWT token是否存在。如果存在,则将其发送到后端进行验证。如果JWT token有效,则用户已经登录,可以访问资源。如果JWT token无效,则用户需要重新登录。 4. 共享JWT secret 要实现单点登录,您需要使用相同的JWT secret在所有应用程序之间共享JWT token。秘密应该足够强大,以防止未经授权的访问。 5. 解决JWT token过期问题 JWT token有一个过期时间,当过期时,用户需要重新登录。要解决这个问题,您可以使用refresh token或者每次用户访问时都重新生成一个新的JWT token。 以上是实现Spring BootJWT前后端分离的单点登录的大致步骤。实现单点登录需要考虑安全性、性能和用户体验等方面的问题。因此,您需要根据实际情况进行自定义和优化。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值