在微服务架构下,由于所有的微服务都被隐藏在网关之后,使得网关成为后端访问的唯一入口。基于这样一种架构,认证授权服务直接整合到网关中,就能很好的处理单点登录,权限控制这类问题。
在本例中,网关使用的是zuul,安全框架使用的是security。
大致的逻辑是这样的:

  1. 通过一个过滤器拦截所有请求。对于未登录用户直接放行交由Security配置的访问规则过滤,如果需要登录才能访问的,那么直接跳转到登录页面。而登录的用户会携带一个token,此时过滤器对token进行判断,如果没有过期,则取出token中id字段,然后去缓存中间件查询用户详细信息,封装为一个携带了用户名、密码、以及权限信息的认证数据对象,交给security进行后续比对。对于比对通过权限符合的用户,允许访问接口,而比对不通过的用户,则交由相应处理器处理。
  2. 权限信息动态加载的问题:在容器初始化的时候,从数据库加载uri及对应访问角色信息,这些信息维护在内存中,以Map形式存在,通过将这个数据源加入到security,实现权限的动态维护。如果需要运行时更新uri对应访问角色信息,只需要提供接口修改这个Map接口。需要注意,一旦权限信息可以动态修改,那么就可能带来一定的安全问题,这是需要权衡的。

好了,现在开始代码部分:
首先是依赖,此处用到了security依赖,以及jwt用于token生成:

  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--JWT-->
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt</artifactId>
  <version>0.9.1</version>
</dependency>

生成token的工具类:

import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

import javax.servlet.http.HttpServletRequest;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

public class JwtUtilHelper {

    private static final Log LOG = LogFactory.getLog(JwtUtilHelper.class);
	/**
	 * 用户名键值
	 */
	public static final String USERNAME = "username";
	/**
	 * 过期时间
	 */
	private Long EXPIRATION_TIME;
	/**
	 * 私钥
	 */
    private String SECRET;
    /**
     * token前缀
     */
    private final String TOKEN_PREFIX = "Bearer";
    /**
     * 请求头名称
     */
    private final String HEADER_STRING = "Authorization";
    
    private final SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:ss:mm");

    public JwtUtilHelper(String secret, long expire) {
        this.EXPIRATION_TIME = expire;
        this.SECRET = secret;
        LOG.info("the jwtUtilHelper is started,and the expire is"+expire);
    }
    /**
     * 生成Token
     * @param claims 需要保存在token的参数,如用户名username,用户ID
     * @return
     */
    public Map<String, String> generateToken(Map<String, Object> claims) {
        Calendar c = Calendar.getInstance();
        c.setTime(new Date());
        c.add(Calendar.SECOND, EXPIRATION_TIME.intValue());
        Date d = c.getTime();
        String jwt = Jwts.builder()
                .setClaims(claims)
                .setExpiration(d)
                .signWith(SignatureAlgorithm.HS512, SECRET)
                .compact();
        Map<String, String> map = new HashMap<String, String>();
        map.put("token",TOKEN_PREFIX + " " + jwt);
        map.put("token-type", TOKEN_PREFIX);
        map.put("expire-time",format.format(d));
        return map;
    }
    /**
     * 解析token
     * @param request
     * @return
     */
    public Map<String, Object> validateTokenAndGetClaims(HttpServletRequest request) {
        String token = request.getHeader(HEADER_STRING);
        if (token == null) {
            return null;
        } 
        Map<String, Object> body = Jwts.parser()
                .setSigningKey(SECRET)
                .parseClaimsJws(token.replace(TOKEN_PREFIX, ""))
                .getBody();
        return body;
    }
}

通过配置密钥和过期时间初始化Jwt工具类

@Configuration
public class JwtConfig {

    /**
     * 密钥
     */
    private final String secret = "A0B1C2D3E4F5G6H7I8J9KALBMCNDOEPFQ0R1S2T3U4V5W6X7Y8Z9";
    /**
     * 过期时间
     */
    private final long expire = 600L;

    @Bean
    public JwtUtilHelper jwtHelperBean() {
        return new JwtUtilHelper(secret, expire);
    }
}

准备工作完成后,就可以开始按流程编码了,先是过滤器对各种访问的处理:

/**
 * Token验证
 * @author GrainRain
 */
@Component
public class JwtAuthorizationTokenFilter extends OncePerRequestFilter{

	private JwtUtilHelper jwtUtilHelper;
	@Autowired
	public void setJwtUtilHelper(JwtUtilHelper jwtUtilHelper){
		this.jwtUtilHelper = jwtUtilHelper;
	}

	@Autowired
	private UserServiceImp jwtUserDetailsService;

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
		/**
		 * 解析token
		 */
		Map<String, Object> map = jwtUtilHelper.validateTokenAndGetClaims(request);
        if (map!=null&&map.size()>0) {
        	/**
        	 * 从token获取用户名
        	 */
        	String username = String.valueOf(map.get(JwtUtilHelper.USERNAME));
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                /**
                 * 获取用户信息
                 * 此处仅为示例,可进行更好的实现,如引入缓存中间件
                 */
            	UserDetails userDetails = this.jwtUserDetailsService.loadUserByUsername(username);
                if (userDetails!=null) {
                	/**
                	 * 设置账号密码
                	 */
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
		}
        filterChain.doFilter(request, response);
	}
}

加载用户信息的类,需要实现UserDetail接口,此处给出一个从数据库加载的简单实现:

/**
 * @author GrainRain
 * @date 2020/03/04 20:13
 **/
//获取用户认证详细信息的类,需要传入username,password,role
@Component
public class UserServiceImp implements UserDetailsService {
    @Autowired
    private UserMapper userMapper;

    @Autowired
    private PasswordEncoder passEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        cn.whitetown.domain.User u = userMapper.selectUserByName(username);
        Collection<GrantedAuthority> roles = new LinkedList<>();
        roles.add(new SimpleGrantedAuthority(u.getNickname()));
        /**
         * 这里实际上应该把密码加密存储在数据库,此处就不再需要再加密这一层了
         */
        String password = passEncoder.encode(u.getPassword()); 
        UserDetails user = new User(u.getUsername(),password,roles);
        return user;
    }

}

现在可以装配起来了,只有带着有权限的token用户访问,那么就可以正常获取数据:

/**
 * @author GrainRain
 * @date 2020/03/04
 **/
@EnableWebSecurity
@Configuration
public class DynSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Autowired
    private JwtAuthenticationAccessDeniedHandler jwtAuthenticationAccessDeniedHandler;
    
    @Autowired
    private JwtAuthorizationTokenFilter jwtAuthorizationTokenFilter;

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/favicon.ico");
    }

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

        http.csrf().disable().  //开启跨域
                sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);  //关闭session

        /**
         * 未登录用户处理
         */
        http.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint);
        http.exceptionHandling().accessDeniedHandler(jwtAuthenticationAccessDeniedHandler);

        // 防止iframe 造成跨域
        http.headers().frameOptions().disable();
        
        /**
         * 添加自定义的过滤器,实现资源权限动态管理
         */
        http.addFilterAfter(dynUrlInterceptor, FilterSecurityInterceptor.class);

        http.authorizeRequests().antMatchers("/css/**","/index").permitAll().   //访问不受限的页面

                anyRequest().permitAll().
                and().formLogin().loginProcessingUrl("/login").permitAll().
                successHandler(successLoginHandler).    //登录成功处理器
                and().logout().permitAll();    //退出登录访问的地址



        /**
         * 设置token解析过滤器在账号密码验证器之前
         */
        http.addFilterBefore(jwtAuthorizationTokenFilter, UsernamePasswordAuthenticationFilter.class);

    }

    //指定UserDetailService
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Autowired
    private DynUrlInterceptor dynUrlInterceptor;

    @Autowired
    private SuccessLoginHandler successLoginHandler;
}

以上可以看出,没登录的用户会交给一个为登录异常处理器处理,没有权限的访问交给异常访问处理器处理。以下是未登录处理器示例,无权限处理可以自己实现即可。

@Component
public class JwtAuthenticationEntryPoint  implements AuthenticationEntryPoint, Serializable {

    private static final long serialVersionUID = -8970718410437077606L;

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {

        response.sendRedirect(request.getContextPath()+"/login.html");
    }
}

通过以上处理,没有登录的用户会跳转登录页面,而登录成功的用户则会交给登录成功处理器处理,同样你也可以自行实现登录失败的处理器。

/**
 * @author GrainRain
 * @date 2020/03/05 10:23
 **/
@Component
public class SuccessLoginHandler implements AuthenticationSuccessHandler {

    private JwtUtilHelper jwtUtilHelper;

    @Autowired
    public void setJwtUtilHelper(JwtUtilHelper jwtUtilHelper){
        this.jwtUtilHelper = jwtUtilHelper;
    }

    //登录成后的处理器
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        //获取登录成功后的UserDetail对象
        User user = (User) authentication.getPrincipal();

        Map<String,Object> chaims = new HashMap<>();
        chaims.put("username",user.getUsername());
        //生成token

        Map<String, String> tokenMap = jwtUtilHelper.generateToken(chaims);
        /**
         * 实际开发按统一格式返回前端
         */
        String returnData = tokenMap.toString();
        
        response.setContentType("application/json;charset=UTF-8");
        response.setHeader("Cache-Control","no-store, max-age=0, no-cache, must-revalidate");
        response.addHeader("Cache-Control", "post-check=0, pre-check=0");
        response.setHeader("Pragma", "no-cache");
        response.setStatus(200);    //登录成功,设置200

        PrintWriter out = response.getWriter();
        out.write(returnData);
        out.flush();
        out.close();
    }
}

以上认证授权的代码也就算完成了,只需要和zuul网关整合到一起,就可以实现对微服务的访问权限控制。
接下来看一看动态加载资源和对应角色信息的代码。关键点在于初始化资源数据的类:

/**
 * 自定义url与权限数据源
 * @author GrainRain
 * @date 2020/04/08
 **/
@Component
public class SecurityMetaDataSource implements FilterInvocationSecurityMetadataSource {

    private Map<RequestMatcher,Collection<ConfigAttribute>> securityMeta;
    private Map<String,RequestMatcher> requestMap;

    public SecurityMetaDataSource(){
        securityMeta = new HashMap<>();
        requestMap = new HashMap<>();
        //可以多种形式初始化
        //合理的做法可能是从数据库加载
        String uri = "/test01";
        AntPathRequestMatcher requestMatcher = new AntPathRequestMatcher(uri);
        ArrayList<ConfigAttribute> configAttributes = new ArrayList<>();
        configAttributes.add(new SecurityConfig("ROLE_USER"));
        configAttributes.add(new SecurityConfig("ROLE_ADMIN"));
        requestMap.put(uri,requestMatcher);
        securityMeta.put(requestMatcher,configAttributes);
    }

    @Override
    public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
        FilterInvocation fi = (FilterInvocation)o;
        HttpServletRequest request = fi.getRequest();

        /**
         * 面对大量的资源,每次访问都遍历map并不合理
         * 此处仅进行了简单优化,如果提升性能需要仔细考虑
         */

        RequestMatcher requestMatcher = requestMap.get(request.getRequestURI());


        if(requestMatcher!= null){
            System.out.println("请求进入::"+request.getRequestURI());
            return securityMeta.get(requestMatcher);
        }

        for(Map.Entry<RequestMatcher,Collection<ConfigAttribute>> entry: securityMeta.entrySet()){
            if(entry.getKey().matches(request)){
                return entry.getValue();
            }
        }
        return null;
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return FilterInvocation.class.isAssignableFrom(aClass);
    }

    Map<RequestMatcher, Collection<ConfigAttribute>> getSecurityMeta() {
        return securityMeta;
    }

    Map<String, RequestMatcher> getRequestMap() {
        return requestMap;
    }
}

通过security配置类配置的一个过滤器(翻阅本文security配置类代码),使得解析到的访问中角色信息会与getAllConfigAttributes()获取的角色进行比对,如果方法返回角色包含从请求解析出的角色,那么才会允许访问。

以下是两个类是仿照security写的拦截器和投票器,代码几乎照抄,只是将数据源替换成了我们自己加载的数据源信息。

/**
 * 自定义拦截器
 * 通过对自定义url资源进行拦截,只需动态维护url资源权限,即可动态配置资源权限
 * @author GrainRain
 * @date 2020/04/08
 **/
public class DynUrlInterceptor extends AbstractSecurityInterceptor implements Filter {

    //标记自定义的url
    private static final String FILTER_APPLIED = "__spring_security_filterSecurityInterceptor_filterApplied_dynamically";

    private FilterInvocationSecurityMetadataSource securityMetadataSource;
    private boolean observeOncePerRequest = true;

    public DynUrlInterceptor() {
    }

    public DynUrlInterceptor(FilterInvocationSecurityMetadataSource securityMetadataSource) {
        this.securityMetadataSource = securityMetadataSource;
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        FilterInvocation fi = new FilterInvocation(request, response, chain);
        invoke(fi);
    }

    @Override
    public void destroy() {

    }

    @Override
    public Class<?> getSecureObjectClass() {
        return FilterInvocation.class;
    }

    @Override
    public SecurityMetadataSource obtainSecurityMetadataSource() {
        return this.securityMetadataSource;
    }

    public FilterInvocationSecurityMetadataSource getSecurityMetadataSource() {
        return this.securityMetadataSource;
    }

    public void invoke(FilterInvocation fi) throws IOException, ServletException {

        if ((fi.getRequest() != null)
                && (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
                && observeOncePerRequest) {
            // filter already applied to this request and user wants us to observe
            // once-per-request handling, so don't re-do security checking
            fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
        }
        else {
            // first time this request being called, so perform security checking
            if (fi.getRequest() != null) {
                fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
            }

            InterceptorStatusToken token = super.beforeInvocation(fi);

            try {
                fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
            }
            finally {
                super.finallyInvocation(token);
            }

            super.afterInvocation(token, null);
        }
    }

    public void setSecurityMetadataSource(FilterInvocationSecurityMetadataSource securityMetadataSource) {
        this.securityMetadataSource = securityMetadataSource;
    }
}
/**
 *
 * @author taixian
 * @date 2020/04/08
 **/
public class DynAccessDecisionManager extends AbstractAccessDecisionManager {
    public DynAccessDecisionManager(List<AccessDecisionVoter<?>> decisionVoters) {
        super(decisionVoters);
    }

    @Override
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
        int deny = 0;

        for (AccessDecisionVoter voter : getDecisionVoters()) {
            int result = voter.vote(authentication, object, configAttributes);

            if (logger.isDebugEnabled()) {
                logger.debug("Voter: " + voter + ", returned: " + result);
            }

            switch (result) {
                case AccessDecisionVoter.ACCESS_GRANTED:
                    return;

                case AccessDecisionVoter.ACCESS_DENIED:
                    deny++;

                    break;

                default:
                    break;
            }
        }

        if (deny > 0) {
            throw new AccessDeniedException(messages.getMessage(
                    "AbstractAccessDecisionManager.accessDenied", "Access is denied"));
        }

        // To get this far, every AccessDecisionVoter abstained
        checkAllowIfAllAbstainDecisions();
    }
}

同样,将其初始化,这样security配置类对应的配置就可以生效了。

/**
 * 初始化配置
 * @author taixian
 * @date 2020/04/08
 **/
@Configuration
public class SecurityInitConfig {

    @Autowired
    private SecurityMetaDataSource securityMetaDataSource;

    @Bean
    public DynUrlInterceptor getInstance(){
    	//这里,用了我们自己定义的资源权限数据源
        DynUrlInterceptor dynUrlInterceptor = new DynUrlInterceptor(securityMetaDataSource);
        //配置RoleVoter决策
        List<AccessDecisionVoter<? extends Object>> decisionVoters = new ArrayList<AccessDecisionVoter<? extends Object>>();
        decisionVoters.add(new RoleVoter());

        dynUrlInterceptor.setAccessDecisionManager(new DynAccessDecisionManager(decisionVoters));
        return dynUrlInterceptor;
    }

    @Bean
    public Map<RequestMatcher, Collection<ConfigAttribute>> getSecurityMetaMap(){
        return securityMetaDataSource.getSecurityMeta();
    }

    @Bean
    public Map<String,RequestMatcher> getRequestMap(){
        return securityMetaDataSource.getRequestMap();
    }
}

到这一步就差不多了。如果要动态更新资源对应角色信息,那么只需要更新权限数据源中定义的Map即可。但是这么做是否合理是需要考虑的。

/**
 * 动态更新数据源的实现,读者可按自己的方式实现
 * @author taixian
 * @date 2020/04/18
 **/
@Service
public class SecurityServiceImp implements SecurityService{

    @Autowired
    private Map<String,RequestMatcher> requestMap;

    @Autowired
    private Map<RequestMatcher, Collection<ConfigAttribute>> securityMeta;

    @Override
    public void reloadMeta(String uri, String ro) {
        if(uri==null || "".equals(uri))
            throw new NullPointerException();
        if(!uri.startsWith("/")){
            uri = "/"+uri;
        }

        try {
            RequestMatcher requestMatcher = new AntPathRequestMatcher(uri);
            ArrayList<ConfigAttribute> configAttributes = new ArrayList<>();
            String[] roles = ro.split(",");
            for (String role : roles) {
                configAttributes.add(new SecurityConfig(role));
            }

            if (!uri.endsWith("/**")) {
                requestMap.put(uri, requestMatcher);
            }
            securityMeta.put(requestMatcher, configAttributes);
        }catch (SecurityNoDefineException e){
            throw new SecurityNoDefineException(e.getMessage());
        }
    }
}

总结:安全框架比较常用的有security和shiro,shiro做了更高阶的封装,相对也要容易一些,而security健壮性更强,提供了更灵活的实现方式,同样的编码难度也就相对更大一些。

以上实现,提供了security的常规用法。同时,通过自定义权限数据源,也可以非常灵活的实现资源权限动态控制。

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐