Spring gateway配置Spring Security实现统一权限验证与授权

在使用Spring Cloud 进行微服务,分布式开发时,网关是请求的第一入口,所以一般把客户端请求的权限验证统一放在网关进行认证与鉴权。因为Spring Cloud Gateway使用是基于WebFlux与Netty开发的,所以与传统的Servlet方式不同。而且网关一般不会直接请求数据库,不提供用户管理服务,所以如果想在网关处进行登陆验证与授权就需要做一些额外的开发了。

需求设求

众所周知,一切架构都必须按需求来设计,万能构架基本上是不存在的,即使是像Spring Security安全架构也只是实现了一个能用方式,并不是放之四海而皆准的,但是一个构架的良好扩展性是必须的,可以让使用者按照自己的需要进行扩展使用。所以为了说明本示例的实现,先假定这样一个需求
1,需要有一个Web网关服务进行权限统一认证
2,网关后面有一个用户管理服务,负责用户账号的管理
3,网关后面还存在其它的服务,但是这些服务需要认证成功之后才能访问
4,需要支持同一个请求可以被多个角色访问

服务搭建请参考源码 https://gitee.com/wgslucky/Spring-Gateway-Security

主要技能点说明

修改默认登陆页面

在项目中添加完spring security依赖之后,如果不添加任何额外的配置,这时不管发送任何请求,都会跳到spring security提供的默认登陆页面。这显然不是我们想要的,那么第一步就是要显示自定义的登陆页面。
在Spring Gateway 网关项目中添加Security的配置,如下面代码所示:

@EnableWebFluxSecurity
public class WebSecurityConfig {
    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        RedirectServerAuthenticationEntryPoint loginPoint = new RedirectServerAuthenticationEntryPoint("/xinyue-server-a/account/index");
        http.authorizeExchange().pathMatchers("/xinyue-server-a/easyui/**","/xinyue-server-a/js/**","/xinyue-server-a/account/index","/xinyue-server-a/account/login").permitAll()    
        .and().formLogin().loginPage("/xinyue-server-a/account/authen").authenticationEntryPoint(loginPoint)
        .and().authorizeExchange().anyExchange().authenticated()
        .and().csrf().disable();
        SecurityWebFilterChain chain = http.build();
        return chain;
    }
}

这里有一个容易出现理解错误的地址,网上有好多示例说是直接只配置loginPage("/my/login")即可,这样配置的话,需要你的登陆页面,和提交登陆信息的form的action都必须是一致的,只不过,一个是get方式请求/my/login,一个是post方式请求/my/login,但是大多数据情况下,我们的登陆页面地址,和登陆form的action地址是分离的,所以需要按我上面的方式进行配置才可以。

 http.authorizeExchange().pathMatchers("/xinyue-server-a/easyui/**","/xinyue-server-a/js/**","/xinyue-server-a/account/index","/xinyue-server-a/account/login").permitAll()    

这个配置表示这些请求都不做验证,直接放过。

.and().formLogin().loginPage("/xinyue-server-a/account/authen").authenticationEntryPoint(loginPoint)

这段配置表示需要认证的请求是/xinyue-server-a/account/authen(对手正常的Springmvc 服务来说,这个应该是登陆时form的action请求地址),如果没有认证,跳转到loginPoint设置的地址:/xinyue-server-a/account/index,即登陆页面。

 .and().authorizeExchange().anyExchange().authenticated()

这段配置表示其它请求都必须是认证(登陆成功)之后才可以访问。

Spring Cloud Gateway 认证方式

如果是微服务模式,在Spring cloud gateway网关处进行用户认证与授权有两种方式:

1,在Spring Cloud Gateway服务这里添加数据库访问,直接检测登陆信息是否正确,如果正确,再给此用户授权。
2,在网关后面专门的认证服务进行登陆信息认证,如果登陆成功,在返回的Header中添加用户认证与授权需要的信息,然后在网关处理再完成认证与授权

Ajax Post登陆与认证

本示例采用第二种方式,首先是客户端向xinyue-server-a服务发送登陆请求,如下面代码所示:

<script type="text/javascript">
	function postAjax(url, json, success) {
		$.ajax({
			type : "POST",
			url : url,
			data : JSON.stringify(json),
			dataType : "json",
			contentType : "application/json",
			success : function(data) {
				if (data.code == 0) {
					success(data);
				} else {
					alert("服务器异常,请联系开发者");
				}
			},
			error : function(data) {
				alert(url + "请求错误:" + JSON.stringify(data));
			}
		});
	}
	function submitForm() {
		$("#errorTips").html("");
		var username = $("#username").val();
		var password = $("#password").val();
		var url = "/xinyue-server-a/account/login";
		var json = {
			"username" : username,
			"password" : password
		};
		postAjax(
				url,
				json,
				function(data) {
					if (data.code == 0) { //如果登陆成功,发送认证请求
						var authUrl = "/xinyue-server-a/account/authen";
						var param = {};
						postAjax(
								authUrl,
								param,
								function(data) {
									if (data.code == 0) {//认证成功之后,跳转请求
										window.location.href = "/xinyue-server-a/account/main";
									} else {
										$("#errorTips").html(data.msg);
									}
								});
					} else {
						$("#errorTips").html(data.msg);
					}
				});
	}
</script>

这里使用ajax post方式向服务端发送登陆请求,如果登陆成功,然后再发送认证请求,在网关处完成认证。

登陆成功之后,返回用户信息,缓存在网关session中

在本示例的源码中,在xinyue-server-a服务中模拟用户登陆成功,并返回此登陆用户的信息,主要是权限信息,如下面代码所示:

    @RequestMapping("login")
    @ResponseBody
    public Object login(HttpServletResponse response) {
        JSONObject userInfo = new JSONObject();
        userInfo.put("username", "xinyues");
        List<String> roles = new ArrayList<>();
        roles.add("Admin");
        roles.add("Dev");
        userInfo.put("roles", roles);//添加角色信息
        response.addHeader("AccountInfo", userInfo.toJSONString());//将信息放入响应的包头
        
        JSONObject result = new JSONObject();
        result.put("code", 0);
        return result;
    }

然后在网关处添加过滤器,拦截登陆请求的响应信息,如下面代码所示:

@Service
public class AuthenticationGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {
    private Logger logger = LoggerFactory.getLogger(AuthenticationGatewayFilterFactory.class);
    @Override
    public GatewayFilter apply(Object config) {
       
        return (exchange, chain) -> chain.filter(exchange).then(Mono.fromRunnable(() -> {
                List<String> gmAccountInfoJsons = exchange.getResponse().getHeaders().get("AccountInfo");
                exchange.getResponse().getHeaders().remove("AccountInfo");//移除包头中的用户信息不需要返回给客户端
                if(gmAccountInfoJsons != null && gmAccountInfoJsons.size() > 0) {
                    String gmAccountInfoJson = gmAccountInfoJsons.get(0);//获取header中的用户信息
                    //将信息放在session中,在后面认证的请求中使用
                    exchange.getSession().block().getAttributes().put("AccountInfo", gmAccountInfoJson);
                }
                logger.debug("登陆返回信息:{}",gmAccountInfoJsons);
        }));
    }
}

请求认证过滤器,AuthenticationWebFilter

当有请求过来时,AuthenticationWebFilter用来拦截认证请求,如果客户端是认证请求的话,在这里实现对此客户端的认证,一般来说拦截的是登陆form中的action地址,可以从form提交的数据中获取用户名和密码,然后使用用户和密码进行用户验证。但是本示例中并没有使用form提交登陆,而是使用Ajax Post方式在网关后面的xinyue-server-a服务中进行的登陆验证。在AuthenticationWebFilter中可以看到,如果是认证请求的话,需要使用.flatMap( matchResult -> this.authenticationConverter.convert(exchange))方式从认证请求获取认证需要的信息,默认是获取登陆的用户名和密码。但是我们在上面已经将登陆信息存在session中了,所示需要重新提供一个authenticationConverter类,如下面代码所示:


public class XinyueAuthenticationConverter extends ServerFormLoginAuthenticationConverter{
    @Override
    public Mono<Authentication> convert(ServerWebExchange exchange) {
       //从session中获取登陆用户信息
       String value = exchange.getSession().block().getAttribute("AccountInfo");
       if(value == null) {
           return Mono.empty();
       } else {
           List<SimpleGrantedAuthority> simpleGrantedAuthorities = new ArrayList<>();
           //获取权限信息
           List<String> roels = JSON.parseObject(value).getJSONArray("roles").toJavaList(String.class);
               roels.forEach(role->{
                  //这里必须添加前缀,参考:AuthorityReactiveAuthorizationManager.hasRole(role)
                   SimpleGrantedAuthority auth = new SimpleGrantedAuthority("ROLE_" + role);
                   simpleGrantedAuthorities.add(auth);
               });
            //添加用户信息到spring security之中。
           XinyueAccountAuthentication  xinyueAccountAuthentication = new XinyueAccountAuthentication(null, value, simpleGrantedAuthorities);
           return Mono.just(xinyueAccountAuthentication);
       }
    }
}

然后将XinyueAuthenticationConverter添加到WebSecurityConfig配置中(完成代码请参考源码)

        SecurityWebFilterChain chain = http.build();
        Iterator<WebFilter>  weIterable = chain.getWebFilters().toIterable().iterator();
        while(weIterable.hasNext()) {
            WebFilter f = weIterable.next();
            if(f instanceof AuthenticationWebFilter) {
              AuthenticationWebFilter webFilter = (AuthenticationWebFilter) f;
              //将自定义的AuthenticationConverter添加到过滤器中
              webFilter.setServerAuthenticationConverter(new XinyueAuthenticationConverter());      
            }
        }

然后添加认证成功操作,如下面代码所示:

    @Bean
    public ReactiveAuthenticationManager reactiveAuthenticationManager() {
        return new ReactiveAuthenticationManagerAdapter((authentication)->{
            if(authentication instanceof XinyueAccountAuthentication) {
                XinyueAccountAuthentication gmAccountAuthentication = (XinyueAccountAuthentication) authentication;
                if(gmAccountAuthentication.getPrincipal() != null) {
                    authentication.setAuthenticated(true);
                    return authentication;
                } else {
                    return authentication;
                }
            } else {
                return authentication;
            }
        });
    }

到此,就可以算是认证成功了,登陆成功之后,就会跳到转到主页面了。

请求权限验证

一般来说,在管理系统中,用户拥有不同的角色,不同的角色拥有不同的权限,那么在收到请求的时候,就需要在网关验证当前用户是否拥有访问这个请求的权限,或是否是某一个角色,如果是才能进行访问,否则返回用户权限不足,拒绝访问。
现在给下面这个请求配置必须拥有Manager权限才可以访问

.and().authorizeExchange().pathMatchers("/xinyue-server-a/account/main").hasRole("Manager")

如果这个时候再登陆,会发现服务器返回Access Denied,如果配置为Dev权限

.and().authorizeExchange().pathMatchers("/xinyue-server-a/account/main").hasRole("Dev")

因为此用户拥有Dev权限(模拟账号),所以可以正常访问。

多个角色判断

目前Spring Security提供的模式是一个请求配置一个角色,有些复杂的系统,要求一个请求的访问权限可以被多个角色共同拥有。这就需要我们自定义一个权限的验证了。
比如添加如下配置:

.and().authorizeExchange().pathMatchers("/xinyue-server-a/account/main").access(new XinyueReactiveAuthorizationManager("Manager", "Dev"))

表示这个请求需要Manager或Dev其中一个角色就可以访问。
然后在XinyueReactiveAuthorizationManager中实现权限验证判断,详细请考参源码

    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> authentication, AuthorizationContext object) {
        return authentication
                .filter(a -> a.isAuthenticated())
                .flatMapIterable( a -> a.getAuthorities())
                .map( g-> g.getAuthority())
                .any(c->{
                    //检测权限是否匹配
                    String[] roles = c.split(",");
                    for(String role:roles) {
                        if(authorities.contains(role)) {
                            return true;
                        }
                    }
                    return false;
                })
                .map( hasAuthority -> new AuthorizationDecision(hasAuthority))
                .defaultIfEmpty(new AuthorizationDecision(false));
    }

到此,Spring Cloud Gateway + Spring Security配置完毕,在实际应用中,可以根据自己的需求再进行适当的封装。欢迎关注公众号交流。

源码地址:https://gitee.com/wgslucky/Spring-Gateway-Security
更多技术干货:http://www.xinyues.com

posted @ 2019-12-02 16:25  王广帅  阅读(42736)  评论(2编辑  收藏  举报