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
})
相關文章
相關標籤/搜索