项目采用spring boot 为基础微服务框架 spring security为安全控制框架 同时基础前后端分离的设计使用JWT来支持Token相关需求,但是了spring security并不支持这种无状态凭证,但是项目又需要用到这种无状态的身份认证及权限鉴定,所有有了如下方案。
方案大概流程如下图:
鉴权服务只负责发放和解析Token,任何一个服务的相关接口被访问前(包括内部服务访问和外部访问)都会先将Token传递给鉴权服务进行解析,当前服务拿到解析后的数据后注入到当前安全上下文后续的流程就交给spring security框架来处理了,具体的实现见下文。
为了实现上述方案,我们需要指定spring security拦截器的顺序通过查看文档得到下图
其中SecurityContextPersistenceFilter很关键,这个拦截器负责从SecurityContextRepository中加载SecurityContext和保存SecurityContext,所以我们在这个地方实现SecurityContext注入是最好的,但是分析发现默认SecurityContextRepository的实现只有HttpSessionSecurityContextRepository和NullSecurityContextRepository,显然既然要无状态HttpSessionSecurityContextRepository不可以,NullSecurityContextRepository就是啥也不做所以也没用,那只能自己实现一个RemoteSecurityContextRepository
public class RemoteSecurityContextRepository implements SecurityContextRepository {
private boolean isContext = false;
@Override
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
String token = Optional.ofNullable(requestResponseHolder.getRequest().getHeader(TokenConst.HEADER_STRING)).orElse("");
JwtAuthenticationToken authentication = null;
//TODO 这里实现从远程接口回去Token解析内容
SecurityContextImpl context = new SecurityContextImpl();
context.setAuthentication(authentication);
isContext = true;
return context;
}
@Override
public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
//不用保存
}
@Override
public boolean containsContext(HttpServletRequest request) {
return isContext;
}
}
JwtAuthenticationToken 只要继承AbstractAuthenticationToken就好了可以自己随意添加属性。接下来将我们自己实现的RemoteSecurityContextRepository配置到框架内:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationProvider JwtAuthenticationProvider;
@Autowired
private SecurityContextRepository jwtSecurityContextRepository;
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.csrf().disable().sessionManagement().disable().formLogin().disable().cors().disable();
httpSecurity.securityContext().securityContextRepository(jwtSecurityContextRepository);
httpSecurity.authorizeRequests()
.antMatchers("/v2/api-docs").hasAuthority("ROLE_DEV")
.antMatchers("/monitor").hasAuthority("ROLE_MONITOR")
.antMatchers("/api/private/**", "/api/protected/**").authenticated()
.and().authenticationProvider(JwtAuthenticationProvider)
.exceptionHandling().authenticationEntryPoint(new JwtAuthenticationEntryPoint());
}
}
jwtSecurityContextRepository就是RemoteSecurityContextRepository的实例bean,这样当需要被验证接口请求到达时就会进行Token解析然后讲返回的信息添加到安全上下文,后续spring security会进行接管,但是这样就够了吗?显然不行。
如果只进行到毫无意外spring security会一律返回http 403 ,为什么了?在JwtAuthenticationToken中有一个很关键的属性叫Authenticated,当Authenticated的值是true时表示当前安全上下文是经过验证的后续只需要进行权限判断(AccessDecisionManager),但是这个时候我们还不清楚请求所携带的身份信息是否合法,是否过期,那怎么办了?这就需要配置AuthenticationProvider实例并且将Authenticated设置为false。
当然框架默认提供的AuthenticationProvider实例所支持的功能也是够的但是不能满足细化的控制结果要求,所以我们自己实现一个
public class JwtAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (ObjectUtil.isEmptyObject(authentication)) {
throw new AuthenticationCredentialsNotFoundException("");
}
JwtAuthenticationToken jwtToken = (JwtAuthenticationToken) authentication;
//非法证书 返回403
if (!jwtToken.isCredentialsAuthorized()) {
throw new BadCredentialsException("");
}
//凭证过期 从新登陆
if (jwtToken.isCredentialsExpired()) {
throw new CredentialsExpiredException("");
}
//账户异常 从新登陆
if (!jwtToken.isAccountNormal()) {
throw new CredentialsExpiredException("");
}
jwtToken.setAuthenticated(true);
return jwtToken;
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.isAssignableFrom(JwtAuthenticationToken.class);
}
}
这样我们就可以针对不同的情况进行不同的返回,但是到这你会发现不管抛出什么异常,spring security都只给http 403 ,这显然不行,那怎么办了,通过阅读文档发现有一个属性在干这个事AuthenticationEntryPoint,通过该属性设置的bean会接管所有验证阶段抛出的异常,我们就可以自定义实现一个类并设置来达到针对不同的异常进行不同的返回。
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
if (authException instanceof CredentialsExpiredException) {
response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
} else if (authException instanceof BadCredentialsException) {
response.sendError(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase());
} else if (authException instanceof AuthenticationCredentialsNotFoundException) {
response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
} else if (authException instanceof AuthenticationCredentialsNotFoundException) {
response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
} else {
log.error(authException.getLocalizedMessage(), authException);
response.sendError(HttpStatus.INTERNAL_SERVER_ERROR.value(), HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase());
}
}
}
做到这里就完全实现了jwt和spring security的结合,从而实现权限控制