1 授权模式的选择
本项目选择密码模式,原因如下, 同一个企业内部的不同产品要使用本企业的 oAuth2.0 体系。在有些情况下,产品希望能够定制化授权页面。由于是同个企业,不需要向用户展示“xxx将获取以下权限”等字样并询问用户的授权意向,而只需进行用户的身份认证即可。这个时候,由具体的产品团队开发定制化的授权界面,接收用户输入账号密码,并直接传递给鉴权服务器进行授权即可。
2 资源服务器
网关这里是担任资源服务器的角色,因为网关是微服务资源访问的统一入口,所以在这里做资源访问的统一鉴权是再合适不过。
2.1 pom依赖
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
2.2 配置文件
spring
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: ${
auth-server}/api/v1/oidc/jwk
# oAuth2配置
auth-server: http://10.128.34.17:8858/sso
security:
oauth2:
client:
user-authorization-uri: ${
auth-server}/oauth/authorize
access-token-uri: ${
auth-server}/oauth/token
user-info-uri: ${
auth-server}/api/v1/user/info
resource:
jwt:
key-uri: ${
auth-server}/oauth/token_key
user-api-uri: ${
auth-server}/api/v1/resource/res/user/api/query
sso:
login-path: /login
2.3 鉴权管理器
鉴权管理器是作为资源服务器验证是否有权访问资源的裁决者,核心部分的功能先已通过注释形式进行说明,后面再对具体形式补充。
@Component
public class CustomAuthorizationManager implements AccessDecisionManager {
protected final Logger logger = LoggerFactory.getLogger(this.getClass());
@Autowired
UserResourceService userResourceService;
@Override
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
AntPathMatcher pathMatcher = new AntPathMatcher();
pathMatcher.setCaseSensitive(false); // 忽略大小写
//1 获取当前请求路径
String path = ((FilterInvocation) object).getRequestUrl();
//2 忽略url请放在此处进行过滤放行
if (path.contains("login") || path.contains("captcha") || path.contains("logout")
||path.contains("refreshToken")) {
return;
}
//3 token为空拒绝访问
String token = OAuth2Utils.getToken();
if (StrUtil.isBlank(token)) {
throw new BadCredentialsException(AuthRespCodeEnum.RESPONSE_50001201.getMessage());
}
if (authentication.isAuthenticated()) {
//4 查询当前用户拥有的API权限
if (userResourceService == null) {
userResourceService = (UserResourceService) SpringContextHolder.getBean("userResourceService");
}
UserApiQueryParam queryParam = new UserApiQueryParam();
List<UserApi> userApis = userResourceService.getUserApi(queryParam);
//模拟
UserApi api = new UserApi();
api.setUri("/stars/device/query");
userApis = new ArrayList<>();
userApis.add(api);
if (!CollectionUtils.isEmpty(userApis)) {
for (UserApi userApi : userApis) {
if (pathMatcher.match(userApi.getUri(), path)) {
return;
}
}
}
}
throw new AccessDeniedException(AuthRespCodeEnum.RESPONSE_50001221.getMessage());
}
@Override
public boolean supports(ConfigAttribute configAttribute) {
return true;
}
@Override
public boolean supports(Class<?> aClass) {
return true;
}
}
第1、2、3处只是做些基础访问判断,不做过多的说明。
第4处从认证服务器获取资源权限数据,根据请求路径去匹配resourceRolesMap的资url(Ant Path匹配规则)。注意: 这里我们可以自定义一套RBAC关系来做限权。
从认证平台获取用户权限如下:
2.4 资源服务器配置
这里做的工作是将鉴权管理器AuthorizationManager配置到资源服务器、请求白名单放行、无权访问和无效token的自定义异常响应。配置类基本上都是约定俗成那一套,核心功能和注意的细节点通过注释说明。
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Resource
private WhiteListConfig whiteListConfig;
@Override
public void configure(HttpSecurity http) throws Exception {
http.oauth2ResourceServer().jwt()
.jwtAuthenticationConverter(jwtAuthenticationConverter());
http.exceptionHandling()
.accessDeniedHandler(new CustomAccessDeniedHandler())
.authenticationEntryPoint(new CustomAuthExceptionEntryPoint())
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers(whiteListConfig.getUrls() == null ? new String[]{
"/login"} : ArrayUtil.toArray(whiteListConfig.getUrls(), String.class)).permitAll()
.anyRequest().authenticated()
.accessDecisionManager(new CustomAuthorizationManager())
.and().csrf().disable();
}
@Override
public void configure(ResourceServerSecurityConfigurer resource) {
//自定义异常加进去
resource.authenticationEntryPoint(new CustomAuthExceptionEntryPoint())
.accessDeniedHandler(new CustomAccessDeniedHandler());
}
/***
* 定义JwtAccessTokenConverter
* @return
*/
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstants.AUTHORITY_PREFIX);
jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstants.JWT_AUTHORITIES_KEY);
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
}
2.5 自定义异常
用户认证和授权按自定义格式返回异常。
2.5.1 CustomAccessDeniedHandler
@Component("customAccessDeniedHandler")
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
Map<String, Object> map = new HashMap<String, Object>();
map.put("code", AuthRespCodeEnum.RESPONSE_50001221.getCode());//401
map.put("message", AuthRespCodeEnum.RESPONSE_50001221.getMessage());
map.put("success", false);
ObjectMapper mapper = new ObjectMapper();
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write(mapper.writeValueAsString(map));
}
}
2.5.2 CustomAccessDeniedHandler
@Component("customAuthExceptionEntryPoint")
public class CustomAuthExceptionEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws ServletException {
Map<String, Object> map = new HashMap<String, Object>();
Throwable cause = authException.getCause();
if (cause instanceof InvalidTokenException) {
map.put("code", AuthRespCodeEnum.RESPONSE_50001224.getCode());//401
map.put("message", AuthRespCodeEnum.RESPONSE_50001224.getMessage());
} else {
map.put("code", AuthRespCodeEnum.RESPONSE_50001201.getCode());//401
map.put("message", AuthRespCodeEnum.RESPONSE_50001201.getMessage());
}
map.put("success", false);
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
try {
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getOutputStream(), map);
} catch (Exception e) {
throw new ServletException();
}
}
}
3 注销使JWT失效
JWT最大的一个优势在于它是无状态的,自身包含了认证鉴权所需要的所有信息,服务器端无需对其存储,从而给服务器减少了存储开销。但是无状态引出的问题也是可想而知的,它无法作废未过期的JWT,只能由手动使token作废了。我们可以采取黑名单方式。
3.1 黑名单方式
注销登录时,缓存JWT至Redis,且缓存有效时间设置为JWT的有效期,请求资源时判断是否存在缓存的黑名单中,存在则拒绝访问。
3.2 黑名单方式实现
3.2.1 星辰服务退出登录接口
登出接口/stars/account/logout的主要逻辑把JWT添加至Redis黑名单缓存中,但没必要把整个JWT字符串都存储下来,JWT的载体中有个jti(JWT ID)字段声明为JWT提供了唯一的标识符。JWT解析的结构如下:
/**
* 退出登录
*
* @return 退出登录结果
*/
@Override
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
public CloudwalkResult<Boolean> logout() throws ServiceException {
try {
String accessToken = WebUtils.getToken();
CloudwalkResult result = null;
if (StringUtils.isNotEmpty(accessToken)) {
// 查询用户信息
Map<String, Object> params = new HashMap<String, Object>();
Map<String, Object> header = new HashMap<String, Object>();
header.put(AuthConstants.JWT_TOKEN_HEADER, accessToken);
String resp = HttpClientUtils.httpPostRequest(oAuth2Properties.getUserLogoutUri(), header, params, "utf-8");
if (StringUtils.isNotEmpty(resp)) {
result = JsonUtils.toObj(resp, CloudwalkResult.class);
if (result != null && result.isSuccess()) {
JSONObject jsonObject = WebUtils.getJwtPayload();
String jti = jsonObject.getStr("jti"); // JWT唯一标识
long exp = jsonObject.getLong("exp"); // JWT过期时间戳
long currentTimeSeconds = System.currentTimeMillis() / 1000;
if (exp < currentTimeSeconds) {
// token已过期,无需加入黑名单
return CloudwalkResult.success(true);
}
redisManager.set(AuthConstants.TOKEN_BLACKLIST_PREFIX + jti, null, (exp - currentTimeSeconds));
return CloudwalkResult.success(true);
}
}
}
return CloudwalkResult.fail(AuthRespCodeEnum.RESPONSE_50001223.getCode(), AuthRespCodeEnum.RESPONSE_50001223.getMessage());
} catch (Exception e) {
logger.error("用户登出失败,原因:" + e);
throw new ServiceException(e);
}
}
3.2.2 网关的全局过滤器
从请求头提取JWT,解析出唯一标识jti,然后判断该标识是否存在黑名单列表里,如果是直接返回响应用户未登陆的提示信息。
@Component
public class UserFilter extends ZuulFilter {
protected final Logger logger = LoggerFactory.getLogger(this.getClass());
@Autowired
private RedisTemplate redisTemplate;
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public int filterOrder() {
return FORM_BODY_WRAPPER_FILTER_ORDER - 1;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
return this.loginValidate();
}
private Object loginValidate() {
RequestContext ctx = RequestContext.getCurrentContext();
// 先判断路径,如果是登录不需要校验
String url = ctx.getRequest().getRequestURI();
Boolean flag = Pattern.matches(".*login|.*token|.*captcha|.*logout", url);
logger.info("是否为登录或获取验证码:" + flag);
if (flag) {
return null;
}
String token = OAuth2Utils.getToken();
logger.info("请求中的token为:" + token);
if (StringUtils.isNotBlank(token)) {
try {
// 解析JWT获取jti,以jti为key判断redis的黑名单列表是否存在,存在拦截响应token失效
token = token.replace(AuthConstants.JWT_TOKEN_PREFIX, Strings.EMPTY);
JWSObject jwsObject = null;
jwsObject = JWSObject.parse(token);
String payload = jwsObject.getPayload().toString();
JSONObject jsonObject = JSONUtil.parseObj(payload);
String jti = jsonObject.getStr("jti");
Boolean isBlack = redisTemplate.hasKey(AuthConstants.TOKEN_BLACKLIST_PREFIX + jti);
if (isBlack) {
ctx.setSendZuulResponse(false);
ctx.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
ctx.setResponseBody(
JSON.toJSONString(CloudwalkResult.fail(AuthRespCodeEnum.RESPONSE_50001201.getCode(),
AuthRespCodeEnum.RESPONSE_50001201.getMessage())));
ctx.getResponse().setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
return null;
}
// 存在token且不是黑名单,request写入JWT的载体信息
ctx.addZuulRequestHeader(AuthConstants.JWT_PAYLOAD_KEY, payload);
} catch (Exception e) {
logger.info("token校验异常:" + e + ":" + token);
ctx.setSendZuulResponse(false);
ctx.setResponseStatusCode(401);
ctx.setResponseBody(JsonUtils.toJson(CloudwalkResult.fail(GatewayCommonRespCodeEnum.RESPONSE_SYSTEM_ERROR.getCode(),
GatewayCommonRespCodeEnum.RESPONSE_SYSTEM_ERROR.getMessage())));
ctx.getResponse().setContentType("application/json;charset=UTF-8");
return null;
}
return null;
}
ctx.setSendZuulResponse(false);
ctx.setResponseStatusCode(401);
ctx.setResponseBody(JsonUtils.toJson(CloudwalkResult.fail(AuthRespCodeEnum.RESPONSE_50001201.getCode(),
AuthRespCodeEnum.RESPONSE_50001201.getMessage())));
ctx.getResponse().setContentType("application/json;charset=UTF-8");
return null;
}
}
4 刷新token
刷新token有两种方案
方案一:
浏览器起一个定时轮询任务,每次在access_token过期之前刷新。
方案二:
请求时返回access_token过期的异常时,浏览器发出一次使用refresh_token换取access_token的请求,获取到新的access_token之后,重试因access_token过期而失败的请求。
方案比较:
第一种方案实现简单,但在access_token过期之前刷新,那些旧access_token依然能够有效访问,如果使用黑名单的方式限制这些就的access_token无疑是在浪费资源。
第二种方案是在access_token已经失效的情况下才去刷新便不会有上面的问题,但是它会多出来一次请求,而且实现起来考虑的问题相较下比较多,例如在token刷新阶段后面来的请求如何处理,等获取到新的access_token之后怎么重新重试这些请求。
总结:第一种方案实现简单;第二种方案更为严谨,过期续期不会造成已被刷掉的access_token还有效;总之两者都是可行方案,我更倾向于第二种,下面介绍第二种方式的实现。
4.1 实现
前后端配合利用双token刷新实现JWT续期的功能需求,后端抛出token过期异常,前端捕获之后调用刷新token请求,成功则完成续期,失败(一般指refresh_token也过期了)则需要重新登录。
4.1.1 后端
后端部分这里工作是在网关cloudwalk-server-gateway鉴定access_token过期时抛出一个自定义异常提供给前端判定,如下图所示:
星辰服务提供一个刷新token的接口:
@Override
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
public CloudwalkResult<CwArRefreshTokenResult> refreshToken(CwArRefreshTokenParam param) throws ServiceException {
try {
Map<String, Object> params = new HashMap<String, Object>();
Map<String, Object> header = new HashMap<String, Object>();
header.put(AuthConstants.JWT_TOKEN_HEADER, AuthConstants.CLIENT_BASIC_PREFIX + getClientBasic());
String resp = HttpClientUtils.httpPostRequest(oAuth2Properties.getAccessTokenUri() + "?grant_type=" + AuthorizationGrantEnum.REFRESH.getGrantType()
+ "&refresh_token=" + param.getRefreshToken(),
header, params, "utf-8");
if (StringUtils.isNotEmpty(resp)) {
CwArRefreshTokenResult result = JsonUtils.toObj(resp, CwArRefreshTokenResult.class);
if (result != null && StringUtils.isNotEmpty(result.getAccess_token())) {
return CloudwalkResult.success(result);
}
}
return CloudwalkResult.fail(AuthRespCodeEnum.RESPONSE_REFRESH_TOKEN_FAIL.getCode(), AuthRespCodeEnum.RESPONSE_REFRESH_TOKEN_FAIL.getMessage());
} catch (Exception e) {
logger.info("刷新token异常,无效", e);
throw new ServiceException(cn.cloudwalk.client.account.common.constant.RespCodeConstant.WRONG_TOKEN, this.getMessage(cn.cloudwalk.client.account.common.constant.RespCodeConstant.WRONG_TOKEN));
}
}
4.1.2 前端
请求响应拦截添加令牌过期处理
在判断响应结果是token过期时,执行刷新令牌方法覆盖本地的token。
在刷新期间需做到两点,一是避免重复刷新,二是请求重试,为了满足以上两点添加了两个关键变量:
refreshing----刷新标识
在第一次access_token过期请求失败时,调用刷新token请求时开启此标识,标识当前正在刷新中,避免后续请求因token失效重复刷新。
waitQueue----请求等待队列
当执行刷新token期间时,需要把后来的请求先缓存到等待队列,在刷新token成功时,重新执行等待队列的请求即可。
let refreshing = false,// 正在刷新标识,避免重复刷新
waitQueue = [] // 请求等待队列
service.interceptors.response.use(
response => {
const {
code, msg, data} = response.data
if (code !== '00000') {
if (code === 'A0230') {
// access_token过期 使用refresh_token刷新换取access_token
const config = response.config
if (refreshing == false) {
refreshing = true
const refreshToken = getRefreshToken()
return store.dispatch('user/refreshToken', refreshToken).then((token) => {
config.headers['Authorization'] = 'Bearer ' + token
config.baseURL = '' // 请求重试时,url已包含baseURL
waitQueue.forEach(callback => callback(token)) // 已刷新token,所有队列中的请求重试
waitQueue = []
return service(config)
}).catch(() => {
// refresh_token也过期,直接跳转登录页面重新登录
MessageBox.confirm('当前页面已失效,请重新登录', '确认退出', {
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
store.dispatch('user/resetToken').then(() => {
location.reload()
})
})
}).finally(() => {
refreshing = false
})
} else {
// 正在刷新token,返回未执行resolve的Promise,刷新token执行回调
return new Promise((resolve => {
waitQueue.push((token) => {
config.headers['Authorization'] = 'Bearer ' + token
config.baseURL = '' // 请求重试时,url已包含baseURL
resolve(service(config))
})
}))
}
} else {
Message({
message: msg || '系统出错',
type: 'error',
duration: 5 * 1000
})
}
}
return {
code, msg, data}
},
error => {
return Promise.reject(error)
}
)
5 白名单
5.1 配置文件
# 配置白名单路径(无需登录)
whitelist:
urls:
- "/stars/account/login/**"
- "/stars/account/captcha/**"
- "/stars/stars/account/refreshToken"
5.2 ResourceServerConfig配置
6 演示
6.1 不携带token直接访问资源
6.2 携带假的token直接访问资源
6.3 用户不输入验证码登录
6.4 获取验证码
6.5 携带验证码,但是用户名或密码错误
6.6 携带验证码,且用户名和密码正确
6.7 携带登录token,访问资源
6.8 携带登录token,访问没有权限的资源
6.9 用户登出之后,再访问资源