慢慢构造一个微型的商城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 })