慢慢構造一個微型的商城demo。使用的技術棧是SpringBoot+SpringCloud; 各個服務間的接口調用是有權限驗證的。每一個請求頭包含token,經過token來 校驗該請求是否合法java
springBoot 基礎框架redis
SpringCloudspring
Spring Security 安全框架sql
ORM數據庫
redis 應用緩存、接口數據緩存json
zookeeper 註冊中心緩存
rabbitMQ 消息隊列安全
JWT 結合Spring Security使用,實現服務之間的鑑權。Spring Security負責請求的過濾攔截以及賦權, JWT負責判斷該token是否過時session
├── 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
@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; } }
/** * 將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); }
服務間的調用時經過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的調用超時設置 ribbon: ReadTimeout: 60000 ConnectTimeout: 60000
這裏使用的事aop(AfterReturning)的方式來攔截。具體定義以下
/** * 自動刷新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 ""; }
/** * 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 })