为什么使用jwt?
在原先dubbo+zookeeper项目中,web模块只暴露Restful接口,各服务模块只暴露duboo接口,此时用户登录后由web项目进行token的鉴权和验证,并通过dubbo的隐式传参将sessionID传递给dubbo服务模块, 拦截器再根据sessionID从Redis中获取用户信息设置到当前线程
然鹅,在springcloud中,各个微服务直接暴露的是restful接口,此时如何让各个微服务获取到当前用户信息呢?最佳的方式就是token了,token作为BS之间的会话标识(一般是原生随机token),同时也可以作为信息的载体传递一些自定义信息(jwt, 即Json web token)。
为了能更清楚的了解本文,需要对spring-security-oauth 及 jwt有一定了解,本文只关注用户信息传递这一块
认证服务器
认证服务器配置AuthorizationServerConfigurerAdapter
@Configuration
@PropertySource({"classpath:application.yml"})
@EnableAuthorizationServer
public class AuthServerConfiguration extends AuthorizationServerConfigurerAdapter {
@Autowired
AuthenticationManager authenticationManager;
@Autowired
TokenStore tokenStore;
@Autowired
JwtAccessTokenConverter jwtAccessTokenConverter;
@Autowired
ApprovalStore approvalStore;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource());
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager)
.tokenStore(tokenStore)
.accessTokenConverter(jwtAccessTokenConverter)
.approvalStore(approvalStore)
.allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST);
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
//允许表单认证
security
.tokenKeyAccess("permitAll()") //url:/oauth/token_key,exposes public key for token verification if using JWT tokens
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();
}
@Bean
@Primary
@ConfigurationProperties("ms-sql.datasource")
public DataSource dataSource() {
return new DriverManagerDataSource();
}
@Bean
public ApprovalStore approvalStore() {
return new JdbcApprovalStore(dataSource());
}
/**
* 使用 Jwt token
* @param accessTokenConverter
* @return
*/
@Bean
public TokenStore tokenStore(@Autowired JwtAccessTokenConverter accessTokenConverter) {
return new JwtTokenStore(accessTokenConverter);
}
/**
* token 转换器,加入对称秘钥,使用自定tokenEnhancer
* @return
*/
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new CustomJwtAccessTokenConverter();
converter.setSigningKey("secretKey");
return converter;
}
}
自定义token转换器
CustomJwtAccessTokenConverter
/**
* 对JwtAccessTokenConverter 的 enhance进行重写,加入自定义的信息
*
* @author wangqichang
* @since 2019/4/26
*/
public class CustomJwtAccessTokenConverter extends JwtAccessTokenConverter {
private static final String BEARER_PRIFIX = "bearer ";
//这个是token增强器,想让jwt token携带额外的信息在这里处理
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
if (accessToken instanceof DefaultOAuth2AccessToken) {
Object principal = authentication.getPrincipal();
//这个principal是当时登录后存到securiy的东东,一般是用户实体,自己debug一下就知道了
if (principal instanceof OAuthUser) {
OAuthUser user = (OAuthUser) principal;
HashMap<String, Object> map = new HashMap<>();
//jwt默认已经自带用户名,无需再次加入
map.put("nick_name", user.getUsernickname());
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(map);
}
}
return super.enhance(accessToken, authentication);
}
//主要是资源服务器解析时一定要有bearer这个头才认为是一个oauth请求,但不知道为啥指定jwt后这个头就不见了,特意加上去
@Override
protected String encode(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
return BEARER_PRIFIX + super.encode(accessToken, authentication);
}
}
此时按照固定格式访问授权服务器token接口获取token,如图,可以获取到jwt格式的token,并且额外信息nick_name也已经添加
直接解析jwt字符串可以获取到以下信息,即用户名和授权信息
资源服务器如何鉴权?
只需要指定和授权服务器一模一样的token store 和token converter
在securiy的过滤器中OAuth2AuthenticationProcessingFilter
会从token中获取相关信息进行鉴权
源码:
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
boolean debug = logger.isDebugEnabled();
HttpServletRequest request = (HttpServletRequest)req;
HttpServletResponse response = (HttpServletResponse)res;
try {
//这里从请求中获取到Authorization的 header
Authentication authentication = this.tokenExtractor.extract(request);
if (authentication == null) {
if (this.stateless && this.isAuthenticated()) {
if (debug) {
logger.debug("Clearing security context.");
}
SecurityContextHolder.clearContext();
}
if (debug) {
logger.debug("No token in request, will continue chain.");
}
} else {
request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE, authentication.getPrincipal());
if (authentication instanceof AbstractAuthenticationToken) {
AbstractAuthenticationToken needsDetails = (AbstractAuthenticationToken)authentication;
needsDetails.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
//这里将调用当前设定的token Store 和 converter 将字符串token转成Authentication
Authentication authResult = this.authenticationManager.authenticate(authentication);
if (debug) {
logger.debug("Authentication success: " + authResult);
}
this.eventPublisher.publishAuthenticationSuccess(authResult);
//认证成功后,这里将安全上下文设置用户信息,一般包含用户名和权限。额外信息需要自定义处理,security不会帮你处理的
SecurityContextHolder.getContext().setAuthentication(authResult);
}
} catch (OAuth2Exception var9) {
SecurityContextHolder.clearContext();
if (debug) {
logger.debug("Authentication request failed: " + var9);
}
this.eventPublisher.publishAuthenticationFailure(new BadCredentialsException(var9.getMessage(), var9), new PreAuthenticatedAuthenticationToken("access-token", "N/A"));
this.authenticationEntryPoint.commence(request, response, new InsufficientAuthenticationException(var9.getMessage(), var9));
return;
}
chain.doFilter(request, response);
}
注意,资源服务器主要配置在
ResourceServerConfigurerAdapter
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceConfig extends ResourceServerConfigurerAdapter {
@Autowired
TokenStore tokenStore;
@Autowired
JwtAccessTokenConverter jwtAccessTokenConverter;
@Bean
public TokenStore tokenStore(@Autowired JwtAccessTokenConverter accessTokenConverter) {
return new JwtTokenStore(accessTokenConverter);
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("secretKey");
return converter;
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenStore(tokenStore);
resources.tokenServices(defaultTokenServices);
super.configure(resources);
}
//其他资源不做限制,让security直接放行,这样只限制注解了的方法
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/**").permitAll();
super.configure(http);
}
}
微服务获取jwttoken中的用户信息,两种方式,使用security上下文可以直接获取当前用户名和权限,另一种自定义拦截器获取额外信息。
这个就简单了,获取header头解析验证token
然后获取之前从授权服务器中的添加的 nick_name的额外信息放入线程变量
/**
* 用户信息拦截器
* 从Header中取出jwttoken,并获取其中的用户名设置到用户信息上下文线程变量中
*
* @author wangqichang
* @since 2019/4/24
*/
public class UserInfoInterceptor implements HandlerInterceptor {
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String authorization = request.getHeader("Authorization");
authorization = StrUtil.removePrefix(authorization, "bearer ");
if (StrUtil.isNotBlank(authorization)) {
Jwt decode = JwtHelper.decode(authorization);
//验签
// Jwt secretKey = JwtHelper.decodeAndVerify(authorization, new MacSigner("secretKey"));
String claims = decode.getClaims();
HashMap<String, Object> hashMap = objectMapper.readValue(claims, HashMap.class);
Object userName = hashMap.get("user_name");
Object nickName = hashMap.get("nick_name");
Object authorities = hashMap.get("authorities");
UserContext.setUserInfo(UserInfo.builder().userName((String) userName).nickName((String) nickName).build());
}
return true;
}
}
其中用户上下文类
/**
* @author wangqichang
* @since 2019/4/24
*/
public class UserContext {
private static ThreadLocal<UserInfo> threadLocal = new ThreadLocal<>();
public static UserInfo current() {
return threadLocal.get();
}
public static String currentUserName() {
UserInfo userInfo = threadLocal.get();
if (ObjectUtil.isNotNull(userInfo)) {
return userInfo.getUserName();
}
return null;
}
public static void setUserInfo(UserInfo userInfo) {
threadLocal.set(userInfo);
}
}
启动拦截器注册webmvc配置类
/**
* @author wangqichang
* @since 2019/4/25
*/
@Configuration
public class CustomWebMvcConfig extends WebMvcConfigurationSupport {
/**
* 注册用户信息拦截器
*
* @param registry
*/
@Override
protected void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new UserInfoInterceptor()).addPathPatterns("/**");
super.addInterceptors(registry);
}
/**
* 当自定义webmvc配置后,swagger无法读取到静态Resouce资源,需要手动添加
*
* @param registry
*/
@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("swagger-ui.html")
.addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/");
super.addResourceHandlers(registry);
}
}
在controller中获取用户信息如图
自定义Oauth异常信息
在默认的认证异常如图
假设我们做了全局异常处理,前端希望在token过期时做统一的登录跳转如何做?
实现AuthenticationEntryPoint
接口重写commence
方法即可
注意,直接抛出异常并不会走@RestControllerAdvice
, 因为在这里是response直接返回,并没有使用到Controller处理
/**
* @author wangqichang
* @since 2019/4/30
*/
public class CustomOAuth2AuthenticationEntryPoint extends AbstractOAuth2SecurityExceptionHandler implements AuthenticationEntryPoint {
@Autowired
private ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
Response<Object> build = Response.builder().respCode(StatusCode.TOKEN_ERR).respDesc(authException.getMessage()).build();
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write(objectMapper.writeValueAsString(build));
}
@ConditionalOnMissingBean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
}
此时返回我自定义的Response对象,如图