我们之前的做的微服务项目,网关鉴权选择的都是JWT方案。这次的项目因为一些原因,选择cookie的方式。从前端的角度看,从传统的前后分离登录方案迁移到现有微服务平台,不需要任何改动。
大致思路很简单,springcloud gateway结合spring security和ldap做登录验证,登录成功后,将用户基本信息,例如userId,userName,权限等解析出来,通过http header传给后端微服务。
主要的三个依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-ldap</artifactId>
</dependency>
1. LDAP验证
首先配置ldap验证。ContextSource和LdapTemplate和传统项目方式一样,没什么特殊的地方,但是在webflux下,需要配置一下ReactiveAuthenticationManager才能实现ldap验证。
@Bean
ReactiveAuthenticationManager authenticationManager(
BaseLdapPathContextSource contextSource,
LdapCustomAuthoritiesPopulator populator,
MoreUserDetailsContextMapper moreUserDetailsContextMapper) {
BindAuthenticator ba = new BindAuthenticator(contextSource);
ba.setUserDnPatterns(new String[] { "cn={0},ou=people" } );
LdapCustomPrivilegeAuthenticationProvider lap = new LdapCustomPrivilegeAuthenticationProvider(ba, populator);
// 出于性能的考虑,在LdapAuthoritiesPopulator里,获取权限的同时,check了用户是否在系统中存在,这个方法同时会
// 获取用户的权限,为了少一次调用,扩展创建用户的方法,将权限和用户在本地系统的id等信息全部写入session
lap.setUserDetailsContextMapper(moreUserDetailsContextMapper);
AuthenticationManager am = new ProviderManager(Arrays.asList(lap));
return new ReactiveAuthenticationManagerAdapter(am);
}
@Bean
public LdapContextSource contextSource() {
LdapContextSource contextSource = new LdapContextSource();
Map<String, Object> config = new HashMap();
contextSource.setUrl("ldap://url.example.com:389/");
contextSource.setBase("DC=example,DC=com");
contextSource.setUserDn("xxx@example.com");
contextSource.setPassword("123456");
config.put("java.naming.ldap.attributes.binary", "objectGUID");
contextSource.setPooled(false);
contextSource.setBaseEnvironmentProperties(config);
contextSource.afterPropertiesSet();
return contextSource;
}
@Bean
public LdapTemplate ldapTemplate() {
return new LdapTemplate(contextSource());
}
LdapPrivilegeAuthenticationProvider使用了自定以的实现方式,主要因为前端传过来的密码经过AES加密,spring security不支持AES加密,所以需要自己实现。这里使用AES的原因是ldap进行用户名和密码的验证时在ldap服务端,而ldap服务器不支持加密的密码,需要我们程序将密码解密,通过明文传递。
public class LdapCustomPrivilegeAuthenticationProvider extends LdapAuthenticationProvider {
public LdapCustomPrivilegeAuthenticationProvider(LdapAuthenticator authenticator, LdapAuthoritiesPopulator authoritiesPopulator) {
super(authenticator, authoritiesPopulator);
}
@Override
protected DirContextOperations doAuthentication(UsernamePasswordAuthenticationToken authentication) {
String password = (String) authentication.getCredentials();
try {
String[] ps = password.split("\\.");
password = AesUtils.getInstance().decrypt(ps[0], ps[1]);
} catch (Exception e) {
throw new BadCredentialsException("密码错误!");
}
return super.doAuthentication(new UsernamePasswordAuthenticationToken(authentication.getPrincipal(), password));
}
}
LdapAuthoritiesPopulator和UserDetailsContextMapper也是自定义实现,前者用来获取用户权限,后者主要用来构建用户信息,框架原生的实现功能太简单了,不能适应需求。
@Component
public class LdapCustomAuthoritiesPopulator implements LdapAuthoritiesPopulator {
@DubboReference
private UserService userService;
@Override
public Collection<? extends GrantedAuthority> getGrantedAuthorities(DirContextOperations dirContextOperations, String username) {
// 这个地方正常的操作应该是获取用户权限,但是这里需要一次rpc
// 后面需要用户id和用户姓名的信息还要一次rpc,所以把获取权限的操作滞后
// 放在MoreUserDetailsContextMapper里
return null;
}
}
@Component
public class MoreUserDetailsContextMapper extends LdapUserDetailsMapper {
@Autowired
private UserService userService;
@Override
public UserDetails mapUserFromContext(DirContextOperations ctx, String username, Collection<? extends GrantedAuthority> authorities) {
UserTo user = userService.getUser(username);
if (user == null || user.getUserId() == null) {
throw new BadCredentialsException("用户不存在当前系统,请联系管理员!");
}
List<GrantedAuthority> ret = new ArrayList<>(user.getPrivileges().size());
for (PrivilegeTo privilege : user.getPrivileges()) {
SimpleGrantedAuthority sga = new SimpleGrantedAuthority(privilege.getPrivilegeCode());
ret.add(sga);
}
UserDetailInfo udi = new UserDetailInfo();
udi.setUserId(user.getUserId());
udi.setUserNickName(user.getUserNickName());
udi.setAuthorities(ret);
return udi;
}
}
MoreUserDetailsContextMapper 主要用来验证用户是否在当前系统中,因为ldap是整个企业的用户验证,ldap验证通过并不等于可以登录当前系统,需要管理员将指定人员加入当前系统才能登录。mapUserFromContext方法返回一个自定义的UserDetails对象,扩展了userId和权限等字段,后面网关会将信息转发至后台微服务。
至此,已经实现通过LDAP验证用户名密码功能。
2. 前后分离的Session验证
Spring Security默认情况下,检查到请求没有登录会重定向到框架里实现的登录页面,在前后分离的情况,都是通过ajax请求进行登录,最好还是以json的形式返回,所以需要自定义session失效,登录成功和登录失败的行为。
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
final UrlBasedCorsConfigurationSource urlBasedCorsConfigurationSource = new UrlBasedCorsConfigurationSource();
final CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowCredentials(true);
corsConfiguration.addAllowedOriginPattern("*");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
urlBasedCorsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration);
http.csrf().disable()
.cors().configurationSource(urlBasedCorsConfigurationSource)
.and()
.authorizeExchange(exchanges -> exchanges
.anyExchange().authenticated()
)
.formLogin().loginPage("/api/login")
.requiresAuthenticationMatcher(new PathPatternParserServerWebExchangeMatcher("/api/login"))
.authenticationEntryPoint(new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED))
.authenticationSuccessHandler(new JsonAuthenticationSuccessHandler())
.authenticationFailureHandler(new JsonAuthenticationFailureHandler()).and();
return http.build();
}
这里主要配置登录相关的自定义行为,跨域的代码也在这里。
public class JsonAuthenticationFailureHandler implements ServerAuthenticationFailureHandler {
@Override
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange, AuthenticationException exception) {
ServerHttpResponse response = webFilterExchange.getExchange().getResponse();
response.setStatusCode(HttpStatus.OK);
ResultBO ret = new ResultBO();
ret.setSuccess(false);
ret.setErrorMessage("用户名或密码错误!");
String body = JSONObject.toJSONString(ret);
DataBuffer buffer = null;
try {
buffer = response.bufferFactory().wrap(body.getBytes("UTF-8"));
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return response.writeWith(Mono.just(buffer));
}
}
public class JsonAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler {
@Override
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) {
ServerHttpResponse response = webFilterExchange.getExchange().getResponse();
response.setStatusCode(HttpStatus.OK);
ResultBO ret = new ResultBO();
String body = JSONObject.toJSONString(ret);
DataBuffer buffer = null;
try {
buffer = response.bufferFactory().wrap(body.getBytes("UTF-8"));
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return response.writeWith(Mono.just(buffer));
}
}
至此,登录相关的功能已经完成。当请求没有登录信息的时候,返回Http 401给前端。
3. 流量转发
网关的主要功能之一就是流量转发,这里出来基本的转发功能外,还需要将session中的用户信息转发给后端微服务。
public class CookieAuthenticationFilter implements GlobalFilter, Ordered {
private final DataBufferFactory factory = new DefaultDataBufferFactory();
@Resource
private ObjectMapper objectMapper;
@DubboReference
private LoginService loginService;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
AtomicReference<String> userName = new AtomicReference<String>();
AtomicReference<Integer> userId = new AtomicReference<Integer>();
List<String> auth = new CopyOnWriteArrayList<String>();
exchange.getSession().flatMap(webSession -> {
SecurityContextImpl context = (SecurityContextImpl) webSession.getAttribute("SPRING_SECURITY_CONTEXT");
UserDetailInfo principal =
(UserDetailInfo) context.getAuthentication().getPrincipal();
return Mono.just(principal);
}).subscribe(e -> {
userName.set(e.getUserNickName());
userId.set(e.getUserId());
for (GrantedAuthority authority : e.getAuthorities()) {
auth.add(authority.getAuthority());
}
});
Gson gson = new Gson();
//认证通过
ServerHttpRequest request = exchange.getRequest().mutate()
.header("userId",String.valueOf(userId.get()))
.header("UserName", userName.get())
.header("auth", gson.toJson(auth))
.build();
return chain.filter(
exchange.mutate()
.request(request).build());
}
private Mono<Void> unauthorized(ServerWebExchange serverWebExchange, String message) {
ServerHttpResponse response = serverWebExchange.getResponse();
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return serverWebExchange.getResponse().setComplete();
}
@Override
public int getOrder() {
return -200;
}
}
4. 跨域问题
见第2部分。