SpringBoot+JWT微服务实现接口之间相互调用的鉴权

1.说明

1.1 背景

慢慢构造一个微型的商城demo。使用的技术栈是SpringBoot+SpringCloud; 各个服务间的接口调用是有权限验证的。每一个请求头包含token,经过token来 校验该请求是否合法java

1.2 使用技术

  • springBoot 基础框架redis

  • SpringCloudspring

    • eureka 服务与服务的注册中心
    • Feign 负责服务间的调用
    • zuul 向外暴露的服务网关
  • Spring Security 安全框架sql

  • ORM数据库

    • mybatis plus 对mybatis的进一步封装
  • redis 应用缓存、接口数据缓存json

  • zookeeper 注册中心缓存

  • rabbitMQ 消息队列安全

  • JWT 结合Spring Security使用,实现服务之间的鉴权。Spring Security负责请求的过滤拦截以及赋权, JWT负责判断该token是否过时session

1.3 项目结构

├── README.md
├── demo-cache 缓存模块
├── demo-common 公共模块,包括切面,token认证等一些公共方法
├── demo-eureka 注册中心
├── demo-gateway 网关
├── demo-message 消息模块(kafka)
├── demo-parent.iml
├── demo-product 产品模块
├── demo-user 用户模块
├── demo.sql 初始化sql
└── pom.xml

1.4 JWT认证

1.4.1 JWT的认证流程

token生成和校验的简易流程

1.4.2 代码实现

  • SpringSecurity配置
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //private BCryptPasswordEncoder bCryptPasswordEncoder;
    @Autowired
    private UserDetailsService userDetailService;

    public SecurityConfig() {
        super();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {

        // auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder);
        // ***注入自定义的provider ***,在权限验证的时候后实际走的是 CustomAuthenticationProvider.authenticate
        auth.authenticationProvider(new CustomAuthenticationProvider(userDetailService, bCryptPasswordEncoder()));
        auth.userDetailsService(userDetailService).passwordEncoder(bCryptPasswordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        //禁用 csrf
        http.cors().and().csrf().disable().authorizeRequests()
                //容许如下请求
                .antMatchers("/login/**").permitAll()
                // 全部请求须要身份认证
                .anyRequest().authenticated()
                .and()
                // authenticationManager() 从IOC容器中获取。实际就是用户自定义注入的 CustomAuthenticationProvider
                //拦截登陆操做,
                .addFilterBefore(new JWTLoginFilter("/login", authenticationManager()), UsernamePasswordAuthenticationFilter.class)
                //拦截每个请求,验证token是否有效
                .addFilterBefore(new JWTAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);
    }
}
  • 登陆过滤拦截

这里须要注意的是,获取登陆名、密码默认是经过get方式获取的,并且键名也是必定的 若是是从json中获取还须要将request的请求参数转换为json数据mybatis

/**
 * token信息是存放在request的ThreadLocal里面的。当当前的request销毁,它会存放到session;因此在集群环境中,须要设置session同步;
 *
 *
 *
 * 拦截用户额login操做。继承 UsernamePasswordAuthenticationFilter
 * 默认会执行方法:attemptAuthentication。
 * authenticationManager,在SercurityConfig中自定义的方法。auth.authenticationProvider(new CustomAuthenticationProvider(userDetailsService, bCryptPasswordEncoder()));
 * 表示login这个请求的权限验证会走 CustomAuthenticationProvider.authenticate这个方法
 *      在此方法的逻辑是 setDetails(request, authenticationToken); 调用用户注入的UserDetailsService
 *          1.UserDetailsService的做用调用loadByusername的经过惟一标识获取到用户的权限及密码信息。
 *          2.再调用 authenticationManager.authenticate(authenticationToken)-->实际调用的是CustomAuthenticationProvider.authenticate 来判断参数中的密码与数据库中存储的密码是否相同
 */
public class JWTLoginFilter extends UsernamePasswordAuthenticationFilter {


    private AuthenticationManager authenticationManager;

    /**
     *
     * @param url 拦截的登录URL地址
     * @param authenticationManager
     */
    public JWTLoginFilter(String url, AuthenticationManager authenticationManager) {

        super();
        //new AntPathRequestMatcher("/login", "POST"))
        this.authenticationManager = authenticationManager;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {


        //获得用户登录信息,并封装到 Authentication 中,供自定义用户组件使用.
        String username = request.getParameter("username");
        String password = request.getParameter("password");

        if (username == null) {
            username = "";
        }

        if (password == null) {
            password = "";
        }

        username = username.trim();

//        GrantedAuthorityImpl
        ArrayList<GrantedAuthority> authorities = new ArrayList<>();

        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password, authorities);
// Allow subclasses to set the "details" property
        setDetails(request, authenticationToken);
        //实际调用CustomAuthenticationProvider.authenicate()方法
        return authenticationManager.authenticate(authenticationToken);
    }


    /**
     * 登录成功后,此方法会被调用,所以咱们能够在次方法中生成token,并返回给客户端
     * 
     * @param request
     * @param response
     * @param chain
     * @param authResult
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain, Authentication authResult) {
        TokenAuthenticationService.addAuthenticatiotoHttpHeader(response,authResult);

    }

}
  • 自定义的权限解析器

主要功能看类注释

/**
 * 自定义权限生成器
 * JWTLoginFilter拦截后调用 CustomAuthenticationProvider.authenticate()方法。
 *      1.调用自定义的UserDetailsService来判断用户传进来的密码与数据库中是否一致。
 *      2.登陆成功默认调用 JWTLoginFilter.successfulAuthentication()来生成对应的token
 *
 *
 */
public class CustomAuthenticationProvider implements AuthenticationProvider {

    //构造方法传进来
    private UserDetailsService userDetailsService;
    private BCryptPasswordEncoder bCryptPasswordEncoder;

    public CustomAuthenticationProvider(UserDetailsService userDetailsService, BCryptPasswordEncoder bCryptPasswordEncoder) {
        this.userDetailsService = userDetailsService;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
    }

    /**
     * 是否能够提供输入类型的认证服务
     * <p>
     * 若是这个AuthenticationProvider支持指定的身份验证对象,那么返回true。
     * 返回true并不能保证身份验证提供者可以对身份验证类的实例进行身份验证。
     * 它只是代表它能够支持对它进行更深刻的评估。身份验证提供者仍然能够从身份验证(身份验证)方法返回null,
     * 以代表应该尝试另外一个身份验证提供者。在运行时管理器的运行时,能够选择具备执行身份验证的身份验证提供者。
     *
     * @param authentication
     * @return
     */
    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }

    /**
     * 验证登陆信息,若登录成功,设置 Authentication
     *
     * @param authentication
     * @return 一个彻底通过身份验证的对象,包括凭证。
     * 若是AuthenticationProvider没法支持已经过的身份验证对象的身份验证,则可能返回null。
     * 在这种状况下,将会尝试支持下一个身份验证类的验证提供者。
     * @throws UsernameNotFoundException
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws UsernameNotFoundException {
        // 获取认证的用户名 & 密码
        String username = authentication.getName();
        String password = authentication.getCredentials().toString();

        //经过用户名从数据库中查询该用户
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        //判断密码(这里是md5加密方式)是否正确
        String dbPassword = userDetails.getPassword();
        String encoderPassword = DigestUtils.md5DigestAsHex(password.getBytes());

        if (!dbPassword.equals(encoderPassword)) {
            throw new UsernameNotFoundException("密码错误");
        }

        // 还能够从数据库中查出该用户所拥有的权限,设置到 authorities 中去,这里模拟数据库查询.
        ArrayList<GrantedAuthority> authorities = new ArrayList<>();
//        authorities.add(new GrantedAuthorityImpl("ADMIN"));

        Authentication auth = new UsernamePasswordAuthenticationToken(username, password, authorities);

        return auth;

    }

}
  • Token生成与token的校验
/**
     * 将jwt token 写入header头部
     * 添加token刷新机制,当token还有30s过时的时候主动刷新token并存放到response的header中
     * 为了安全起见,这里的token应该使用非对称加密,返回到客户端,当客户端下次再请求的时候拿着解密后的token来请求
     * @param response
     * @param authentication
     */
    public static void addAuthenticatiotoHttpHeader(HttpServletResponse response, Authentication authentication) {

        //生成 jwt

        Claims claims = (Claims) Jwts.claims().put("aName", "aValue");

        String token = Jwts.builder()
                //生成token的时候能够把自定义数据加进去,好比用户权限
                .claim(AUTHORITIES, "ROLE_ADMIN,AUTH_WRITE")
                .setSubject(authentication.getName())
//                .setClaims(claims)
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATIONTIME))
                .signWith(SignatureAlgorithm.HS512, SECRET)
                .compact();

        //把token设置到响应头中去
        response.addHeader(HEADER_STRING, TOKEN_PREFIX + token);

    }

        /**
         * 从请求头中解析出 Authentication
         * @param request
         * @return
         */
        public static Authentication getAuthentication(HttpServletRequest request, HttpServletResponse response) {
            // 从Header中拿到token
            String token = request.getHeader(HEADER_STRING);
            if(token==null){
                return null;
    
            }
    
            //在JWT的playload中,包含了token的过时时间、权限等信息。若是token过时在parseClaimsJws方法会抛出 ExpiredJwtException
    
            Claims claims = null;
            try {
                claims = Jwts.parser().setSigningKey(SECRET)
                        .parseClaimsJws(token.replace(TOKEN_PREFIX, ""))
                        .getBody();
            } catch (Exception e) {
                return null;
            }
    
    
    
            String auth = (String)claims.get(AUTHORITIES);
    
            // 获得 权限(角色)
            List<GrantedAuthority> authorities =  AuthorityUtils.
                    commaSeparatedStringToAuthorityList((String) claims.get(AUTHORITIES));
    
            //获得用户名
            String username = claims.getSubject();
    
            //获得过时时间
            Date expiration = claims.getExpiration();
            long expirationTime = expiration.getTime();
    
            //判断是否过时
    //        Date now = new Date();
    
    //        if (now.getTime() > expiration.getTime()) {
    //
    //            throw new UsernameNotFoundException("该帐号已过时,请从新登录");
    //        }
            //自动刷新token机制,若是token的有效时间还剩下30s,自动刷新token并将token返回出去并写到request中
            long currentTimeMillis = System.currentTimeMillis();
            if (expirationTime - currentTimeMillis <= TOKEN_FLUSH_SEC) {
                try {
                    token = flushToken(claims);
                    //把token设置到响应头中去
                    response.addHeader(HEADER_STRING, token);
                } catch (Exception e) {
                    e.printStackTrace();
                }
    
            }
    
            if (StringUtils.isEmpty(username)) {
                //return new UsernamePasswordAuthenticationToken(username, null, authorities);
                throw new UsernameNotFoundException("该帐号已过时,请从新登录");
            }
    
            //能够将用户的帐号、权限等信息缓存到request中,当请求到了具体的controller的时候,须要这些参数的时候从request中再把它拿出来便可
            request.setAttribute("userCode", username);
    
            return new UsernamePasswordAuthenticationToken(username, null, authorities);
        }

1.5 注意点

  • 服务间调用如何将token设置到请求头中

服务间的调用时经过feign来作的,可是如何将token设置到feign的请求头里面。以下:

/**
 * @author: code4fun
 * @date: 2018/8/9:上午11:54
 * 处理Feign调用其余系统的时候,往请求头里面加上 token这个参数
 */
@Configuration //RequestInterceptor
public class FeginInterceptor implements RequestInterceptor {

    public static String TOKEN_HEADER = "token";

    @Override
    public void apply(RequestTemplate template) {
        template.header(TOKEN_HEADER, getHeaders(getHttpServletRequest()).get(TOKEN_HEADER));
    }

    private javax.servlet.http.HttpServletRequest getHttpServletRequest() {
        try {
//            RequestContextHolder.getRequestAttributes().
            return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        } catch (Exception e) {
            return null;
        }
    }

    private Map<String, String> getHeaders(javax.servlet.http.HttpServletRequest request) {
        Map<String, String> map = new LinkedHashMap<>();
        Enumeration<String> enumeration = request.getHeaderNames();
        while (enumeration.hasMoreElements()) {
            String key = enumeration.nextElement();
            String value = request.getHeader(key);
            map.put(key, value);
        }
        return map;
    }

}
  • Feign调用超时设置
#feign的调用超时设置
ribbon:
  ReadTimeout: 60000
  ConnectTimeout: 60000
  • 服务的application.ame命名规则 application.name的命名规则应当使用 - 来分割,若是使用 _ 的话,在feignclient端注入服务名的时候会爆unknow host id的异常

1.6 获取刷新后的token

这里使用的事aop(AfterReturning)的方式来拦截。具体定义以下

1.6.1 生命切面注解

/**
 * 自动刷新token注解
 * 写在每一个请求当方法上面,当token还有30秒过时的时候,刷新token并将token返回到返回参数列表中来
 * @author: code4fun
 * @date: 2018/9/1:下午5:17
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface FlushTokenAspect {
    String value() default "";
}
1.6.2 注解的具体实现
/**
 * token刷新注解实现
 * @author: code4fun
 * @date: 2018/9/1:下午5:21
 */
@Component
@Aspect
public class FlushTokenImpl {
    private Logger logger = LoggerFactory.getLogger(FlushTokenImpl.class);

    @Pointcut("@annotation(cn.com.demo.common.aop.token.FlushTokenAspect)")
    public void point(){

    }

    @Before("point()")
    public void doBefore(JoinPoint joinPoint) {
        logger.info("---------->shu前置通知");
    }


    /**
     * 拦截请求返回
     * 若是response的含有token(刷新后的token),将token拼接到返回体中
     * 这里返回的token最好使用非对称加密的方式。客户端拿到加密后的token解密完再来请求
     * @param obj
     */
    @AfterReturning(returning = "obj", pointcut = "point()")
    public void doAfterReturning(Object obj) {
        //获取当前的请求信息
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletResponse response = attributes.getResponse();
        //若是response的header中已经包含可token,说明这次请求token已经刷新,须要将token返回到客户端
        String token = response.getHeader("token");
        if (!StringUtils.isEmpty(token)) {

            ((ResponseBody) obj).setToken(token);
        }

    }
}

由于项目分模块设计的缘由,注解模块不在对应的业务层上面。因此应该要在对应的业务系统引入; 具体以下:

@Import({
        cn.com.demo.common.aop.token.FlushTokenImpl.class
})
相关文章
相关标签/搜索