先来演示一波,再进行讲解
(1)不携带token访问资源
注销之后,携带当前的token无法访问资源。
(6)登录后再次访问资源
登录获得新的token,通过新的token可以访问资源。
(7)获取当前用户
查看当前用户信息,包含其拥有的权限
本项目选择密码模式,原因如下, 同一个企业内部的不同产品要使用本企业的 oAuth2.0 体系。在有些情况下,产品希望能够定制化授权页面。由于是同个企业,不需要向用户展示“xxx将获取以下权限”等字样并询问用户的授权意向,而只需进行用户的身份认证即可。这个时候,由具体的产品团队开发定制化的授权界面,接收用户输入账号密码,并直接传递给鉴权服务器进行授权即可。
方案1:认证服务器进行授权管理
方案2:重新定义授权管理器,在资源服务器完成授权
本项目选择的是方案1,方案2也较简单,不过要维护RBAC表。
配置OAuth2认证允许接入的客户端的信息,因为接入OAuth2认证服务器首先人家得认可你这个客户端。
在表 oauth_client_details 中增加一条客户端配置记录,需要设置的字段如下:
认证服务器可以初始化一部分,同时,也可以通过接口调用的方式添加。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId
</dependency>
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: ${auth-server}/getPublicKey
auth-server: http://localhost:8080
security:
oauth2:
client:
client-id: client
client-secret: secret
access-token-uri: ${auth-server}/oauth/token
user-authorization-uri: ${auth-server}/oauth/authorize
resource:
token-info-uri: ${auth-server}/oauth/check_token
user-info-uri: ${auth-server}/me
sso:
login-path: /login
附:默认的端点 URL
创建一个类继承 ResourceServerConfigurerAdapter 并添加相关注解:
@Configuration
@EnableResourceServer:资源服务器
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true):全局方法拦截
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
@Resource
private WhiteListConfig whiteListConfig;
@Override
public void configure(HttpSecurity http) throws Exception {
http.oauth2ResourceServer().jwt()
.jwtAuthenticationConverter(jwtAuthenticationConverter());
http.exceptionHandling()
.accessDeniedHandler(accessDeniedHandler())
.authenticationEntryPoint(authenticationEntryPoint())
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers(ArrayUtil.toArray(whiteListConfig.getUrls(), String.class)).permitAll()
.anyRequest().authenticated();
}
/**
* 未授权
*
* @return
*/
@Bean
AccessDeniedHandler accessDeniedHandler() {
return (request, response, e) -> {
WebUtils.writeFailedToResponse(response, ResultCode.ACCESS_UNAUTHORIZED);
};
}
/**
* token无效或者已过期自定义响应
*/
@Bean
AuthenticationEntryPoint authenticationEntryPoint() {
return (request, response, e) -> {
WebUtils.writeFailedToResponse(response, ResultCode.TOKEN_INVALID_OR_EXPIRED);
};
}
/***
* 定义JJwtAccessTokenConverter
* @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;
}
}
4.5.1 自定义异常
当发生异常的时候,可返回自定义的消息体。
/**
* 未授权
*
* @return
*/
@Bean
AccessDeniedHandler accessDeniedHandler() {
return (request, response, e) -> {
WebUtils.writeFailedToResponse(response, ResultCode.ACCESS_UNAUTHORIZED);
};
}
/**
* token无效或者已过期自定义响应
*/
@Bean
AuthenticationEntryPoint authenticationEntryPoint() {
return (request, response, e) -> {
WebUtils.writeFailedToResponse(response, ResultCode.TOKEN_INVALID_OR_EXPIRED);
};
}
4.5.2 白名单
对于登录和登出的接口,直接放行。
WhiteListConfig:
/**
* 白名单配置
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "whitelist")
public class WhiteListConfig {
private List<String> urls;
}
application.yml:
whitelist:
urls:
- "/api/login"
- "/api/logout"
4.5.3 token转换
把jwt的Claim中的authorities加入,这样就可以重新定义权限管理器了。
/***
* 定义JJwtAccessTokenConverter
* @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;
}
登录就是携带用户名,密码访问${auth-server}/oauth/token,获取token。
/**
* 登录
*
* @return
*/
@PostMapping("/login")
public String login(@RequestBody PlatUserLoginParam loginParam) throws Exception {
Map<String, Object> params = new HashMap<String, Object>();
Map<String, Object> header = new HashMap<String, Object>();
String clientId = oAuth2Properties.getClientId();
String clientSecret = oAuth2Properties.getClientSecret();
byte[] bytes = (clientId + ":" + clientSecret).getBytes("utf-8");
String encode = new BASE64Encoder().encode(bytes);
header.put(AuthConstants.JWT_TOKEN_HEADER, "Basic " + encode);
String response = HttpClientUtils.httpPostRequest(oAuth2Properties.getAccessTokenUri() + "?grant_type=" + loginParam.getGrantType()
+ "&username=" + loginParam.getUserName() + "&password=" + loginParam.getPassword(), header, params, "utf-8");
JSONObject jsonObject = JSONUtil.parseObj(response);
String accessToken = null;
if (jsonObject != null) {
accessToken = jsonObject.getStr("access_token");
}
return accessToken;
}
注销
以下就JWT在某些场景需要失效的简单方案整理如下:
/**
* 注销
*
* @return
*/
@DeleteMapping("/logout")
public Result logout() {
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 Result.success();
}
redisTemplate.opsForValue().set(AuthConstants.TOKEN_BLACKLIST_PREFIX + jti, null, (exp - currentTimeSeconds), TimeUnit.SECONDS);
return Result.success();
}
从请求头提取JWT,解析出唯一标识jti,然后判断该标识是否存在黑名单列表里,如果是直接返回响应token失效的提示信息。
/**
* 全局过滤器
*/
@Component
@Slf4j
public class AuthFilter extends ZuulFilter {
@Autowired
private RedisTemplate redisTemplate;
/**
* 拦截器执行顺序
*
* @return
*/
@Override
public int filterOrder() {
return FORM_BODY_WRAPPER_FILTER_ORDER - 1;
}
/**
* 拦截器类型
*
* @return
*/
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
HttpServletResponse response = ctx.getResponse();
// 无token放行
String token = request.getHeader(AuthConstants.JWT_TOKEN_HEADER);
if (StrUtil.isBlank(token)) {
return null;
}
// 解析JWT获取jti,以jti为key判断redis的黑名单列表是否存在,存在拦截响应token失效
token = token.replace(AuthConstants.JWT_TOKEN_PREFIX, Strings.EMPTY);
JWSObject jwsObject = null;
try {
jwsObject = JWSObject.parse(token);
} catch (ParseException e) {
e.printStackTrace();
}
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(GatewayCommonRespCodeEnum.RESPONSE_SERVICE_NOT_EXIST.getCode(),
GatewayCommonRespCodeEnum.RESPONSE_SERVICE_NOT_EXIST.getMessage())));
ctx.getResponse().setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
return null;
}
// 存在token且不是黑名单,request写入JWT的载体信息
ctx.addZuulRequestHeader(AuthConstants.JWT_PAYLOAD_KEY, payload);
return null;
}
}
/**
* 获取当前用户信息
*
* @return
*/
@PostMapping("/me")
public Result getCurrentUser() throws Exception {
Map<String, Object> params = new HashMap<String, Object>();
Map<String, Object> header = new HashMap<String, Object>();
HttpServletRequest request = getHttpServletRequest();
String token = request.getHeader(AuthConstants.JWT_TOKEN_HEADER);
header.put(AuthConstants.JWT_TOKEN_HEADER, token);
String response = HttpClientUtils.httpGetRequest(resourceServerProperties.getUserInfoUri(), header, params, "utf-8");
JSONObject jsonObject = JSONUtil.parseObj(response);
return Result.success(jsonObject);
}
理论背景: 在微服务项目 OAuth2实现微服务的统一认证的背景下,前端调用/oauth/token接口认证,在认证成功会返回两个令牌access_token和refresh_token,出于安全考虑access_token时效相较refresh_token短很多(access_token默认12小时,refresh_token默认30天)。当access_token过期或者将要过期时,需要拿refresh_token去刷新获取新的access_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还有效;总之两者都是可行方案,本项目采用第二种方案。
后端
后端部分这里唯一工作是在网关youlai-gateway鉴定access_token过期时抛出一个自定义异常提供给前端判定,如下图所示:
前端
1. OAuth2客户端设置
设置OAuth2客户端支持刷新模式,只有这样才能使用refresh_token刷新换取新的access_token。以及为了方便我们测试分别设置access_token和refresh_token的过期时间,因为默认的12小时和30天我们吃不消的;除此之外,还必须满足t(refresh_token) > 60s + t(access_token)的条件, refresh_token的时效大于access_token时效我们可以理解,那这个60s是怎么回事,别急还是先看实现,原因下文会说明。
{
"clientId": "youlai-mall-weapp",
"clientSecret": "123456",
"resourceIds": "",
"scope": "all",
"authorizedGrantTypes": "authorization_code,password,refresh_token,implicit",
"webServerRedirectUri": null,
"authorities": null,
"accessTokenValidity": 3600,
"refreshTokenValidity": 7200,
"additionalInformation": null,
"autoapprove": "true"
}
2 添加刷新令牌方法
设置了支持客户端刷新模式之后,在前端添加一个refreshToken方法,调用的接口和登录认证是同一个接口/oauth/token,只是参数授权方式grant_type的值由password切换到refresh_token,即密码模式切换到刷新模式,这个方法作用是在刷新token之后将新的token写入到localStorage覆盖旧的token。
3 请求响应拦截添加令牌过期处理
在判断响应结果是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)
}
)