使用场景:
app程序为提高安全性,使用oauth2进行授权,授权方式采用password方式,IOS和Android在获取token时使用同一个用户名/密码(未加密)。
存在问题:
app与本公司服务记性交互,通过https可以认为数据不会被劫持,单通过手机本地启动代理可以清晰明了的看见授权凭证,安全性降低,并且在同一个授权凭证大量并发时,会导致统一时间出现两个不同的access_token,也就表示会有一部分人在刚拿到access_token后就失效了。此种情况只会出现在创建access_token或是通过refresh_token获取access_token时才会出现,并且如果access_token有效时间长或是并发量小,此种问题不会频发出现。
解决方案:
1、app端请求控制并发,也就是顺序请求,如果客户量大,还有会出现上述问题(公司以前的处理方式)。此方式并不推荐,能短时间解决问题,但是会引入诸多性能问题,如限制了客户端的开发模式,降低了客户端响应效率。
2、引入锁,从根本上解决并发导致的问题,后面着重说明怎么实现。
实现方式:
1、先解决密码加密问题,一般采用RSA加密,客户端加密后的密文,到服务端是如何解析,何时解析才能保证后面的身份验证正常进行,代码如下
public class CustDaoAuthenticationProvider extends DaoAuthenticationProvider {
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
if(authentication.getDetails() != null && authentication.getDetails() instanceof HashMap<?, ?>) {
Map<String, String> map = (Map<String, String>) authentication.getDetails();
//对密码进行RSA解密
String pwd = authentication.getCredentials().toString();
pwd = ValidateUtil.decodePasswd(pwd);
authentication = new UsernamePasswordAuthenticationToken(authentication.getPrincipal(), pwd);
((AbstractAuthenticationToken) authentication).setDetails(map);
}
super.additionalAuthenticationChecks(userDetails, authentication);
}
}
2、引入锁解决并发问题
自定义TokenStore及实现
public interface CustTokenStore extends TokenStore {
/**
* @Method_Name :callCustStoreAccessToken
* @param token
* @param authentication
* @return void
* @Creation Date :2018/7/9
* @Author :zc.ding
void callCustStoreAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication);
}
CustJdbcTokenStroe继承默认的JdbcTokenStore
/**
* 解决获取access_token并发问题
*
* @author zc.ding
* @create 2018/7/8
*/
public class CustJdbcTokenStore extends JdbcTokenStore implements CustTokenStore{
private static final Log LOG = LogFactory.getLog(CustJdbcTokenStore.class);
private static final String DEFAULT_ACCESS_TOKEN_STATEMENT = "insert into oauth_access_token (token_id, token, authentication_id, user_name, client_id, authentication, refresh_token) values (?, ?, ?, ?, ?, ?, ?)";
private static final String DEFAULT_ACCESS_TOKEN_FROM_AUTHENTICATION_SELECT_STATEMENT = "select token_id, token from oauth_access_token where authentication_id = ?";
private AuthenticationKeyGenerator authenticationKeyGenerator = new DefaultAuthenticationKeyGenerator();
private final JdbcTemplate jdbcTemplateTmp;
public CustJdbcTokenStore(DataSource dataSource) {
super(dataSource);
this.jdbcTemplateTmp = new JdbcTemplate(dataSource);
super.setInsertAccessTokenSql(DEFAULT_ACCESS_TOKEN_STATEMENT);
}
@Override
public OAuth2AccessToken getAccessToken(OAuth2Authentication authentication) {
OAuth2AccessToken accessToken = null;
String key = authenticationKeyGenerator.extractKey(authentication);
try {
accessToken = jdbcTemplateTmp.queryForObject(DEFAULT_ACCESS_TOKEN_FROM_AUTHENTICATION_SELECT_STATEMENT,
new RowMapper<OAuth2AccessToken>() {
@Override
public OAuth2AccessToken mapRow(ResultSet rs, int rowNum) throws SQLException {
return deserializeAccessToken(rs.getBytes(2));
}
}, key);
}
catch (EmptyResultDataAccessException e) {
if (LOG.isDebugEnabled()) {
LOG.debug("Failed to find access token for authentication " + authentication);
}
}
catch (IllegalArgumentException e) {
LOG.error("Could not extract access token for authentication " + authentication, e);
}
if (accessToken != null) {
OAuth2Authentication authenticationTmp = readAuthentication(accessToken.getValue());
if(authenticationTmp == null || !key.equals(authenticationKeyGenerator.extractKey(authenticationTmp))){
removeAccessToken(accessToken.getValue());
// Keep the store consistent (maybe the same user is represented by this authentication but the details have
// changed)
storeAccessToken(accessToken, authentication);
}
}
return accessToken;
}
@Override
public void storeAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {
//重写后不需要处理任何事情,全部交给callCustStoreAccessToken来处理
}
@Override
public void callCustStoreAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication){
super.storeAccessToken(token, authentication);
}
}
自定义CustTokenServies继承DefaultTokenServices只更新生成access_token的部分,添加锁。
/**
* 在createAccessToken加上分布式锁解决获取access_token并发问题
*
* @author zc.ding
* @create 2018/7/9
*/
public class CustTokenServices extends DefaultTokenServices {
private CustTokenStore tokenStore;
private TokenEnhancer accessTokenEnhancer;
@Transactional
@Override
public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException {
//先从数据库检索是否已经存在access_token
OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication);
OAuth2RefreshToken refreshToken = null;
//判断是否拿到了有效access_token
if (existingAccessToken != null) {
if (existingAccessToken.isExpired()) {
//如果access_token已经过期,那么删除当前access_token对应的数据
if (existingAccessToken.getRefreshToken() != null) {
refreshToken = existingAccessToken.getRefreshToken();
// The token store could remove the refresh token when the
// access token is removed, but we want to
// be sure...
tokenStore.removeRefreshToken(refreshToken);
}
tokenStore.removeAccessToken(existingAccessToken);
}
else {
// Re-store the access token in case the authentication has changed
//关闭accessToken刷新操作,防止数据库并发时死锁
// tokenStore.storeAccessToken(existingAccessToken, authentication);
return existingAccessToken;
}
}
// Only create a new refresh token if there wasn't an existing one
// associated with an expired access token.
// Clients might be holding existing refresh tokens, so we re-use it in
// the case that the old access token
// expired.
if (refreshToken == null) {
refreshToken = createRefreshToken(authentication);
}
// But the refresh token itself might need to be re-issued if it has
// expired.
else if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
ExpiringOAuth2RefreshToken expiring = (ExpiringOAuth2RefreshToken) refreshToken;
if (System.currentTimeMillis() > expiring.getExpiration().getTime()) {
refreshToken = createRefreshToken(authentication);
}
}
//执行到此处,要么是没有拿到有效access_token,要么是access_token已经过期,在从缓存中获取access_token,若果没有,通过分布式锁创建access_token对象
//redis中存储的是access_token默认过期时间与数据库中存储的过期时间一致,这里做了双检索,增加了程序的健壮性
OAuth2AccessToken accessToken = null;
AuthenticationKeyGenerator authenticationKeyGenerator = new DefaultAuthenticationKeyGenerator();
String key = authenticationKeyGenerator.extractKey(authentication);
String accessTokenKey = "ACCESS_TOKEN:" + key;
//判断redis中是否存在有效的access_token
accessToken = this.getExistAccessToken(accessTokenKey);
if(accessToken != null){
return accessToken;
}
//创建access_token创建时并发锁
String lockKey = "LOCK_ACCESS_TOKEN_" + key;
JedisClusterLock lock = new JedisClusterLock();
try {
if (lock.lock(lockKey)) {
accessToken = getExistAccessToken(accessTokenKey);
if (accessToken != null) {
return accessToken;
}else{
accessToken = createAccessToken(authentication, refreshToken);
//调用重写存储access_token的方法
// tokenStore.storeAccessToken(accessToken, authentication);
tokenStore.callCustStoreAccessToken(accessToken, authentication);
// In case it was modified
refreshToken = accessToken.getRefreshToken();
if (refreshToken != null) {
tokenStore.storeRefreshToken(refreshToken, authentication);
}
JedisClusterUtils.setAsJson(accessTokenKey, accessToken);
JedisClusterUtils.setExpireTime(accessTokenKey, accessToken.getExpiresIn());
}
}
}catch (Exception e){
e.printStackTrace();
}finally {
lock.freeLock(lockKey);
}
return accessToken;
}
private OAuth2RefreshToken createRefreshToken(OAuth2Authentication authentication) {
if (!isSupportRefreshToken(authentication.getOAuth2Request())) {
return null;
}
int validitySeconds = getRefreshTokenValiditySeconds(authentication.getOAuth2Request());
String value = UUID.randomUUID().toString();
if (validitySeconds > 0) {
return new DefaultExpiringOAuth2RefreshToken(value, new Date(System.currentTimeMillis()
+ (validitySeconds * 1000L)));
}
return new DefaultOAuth2RefreshToken(value);
}
private OAuth2AccessToken createAccessToken(OAuth2Authentication authentication, OAuth2RefreshToken refreshToken) {
DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(UUID.randomUUID().toString());
int validitySeconds = getAccessTokenValiditySeconds(authentication.getOAuth2Request());
if (validitySeconds > 0) {
token.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L)));
}
token.setRefreshToken(refreshToken);
token.setScope(authentication.getOAuth2Request().getScope());
return accessTokenEnhancer != null ? accessTokenEnhancer.enhance(token, authentication) : token;
}
/**
* 从redis获取有效的access_token
* @Method_Name :getExistAccessToken
* @param accessTokenKey
* @return org.springframework.security.oauth2.common.OAuth2AccessToken
* @Creation Date :2018/7/9
* @Author :zc.ding
*/
private OAuth2AccessToken getExistAccessToken(String accessTokenKey){
OAuth2AccessToken accessTokenTmp = JedisClusterUtils.getObjectForJson(accessTokenKey, DefaultOAuth2AccessToken.class);
long time = JedisClusterUtils.getRemainTime(accessTokenKey);
if(accessTokenTmp != null && JedisClusterUtils.getRemainTime(accessTokenKey) > 0){
((DefaultOAuth2AccessToken) accessTokenTmp).setExpiration(new Date(System.currentTimeMillis() + (time * 1000L)));
return accessTokenTmp;
}
return null;
}
// @Override
public void setTokenStore(CustTokenStore tokenStore) {
this.tokenStore = tokenStore;
super.setTokenStore(tokenStore);
}
public void setAccessTokenEnhancer(TokenEnhancer accessTokenEnhancer) {
this.accessTokenEnhancer = accessTokenEnhancer;
}
@Override
public void setClientDetailsService(ClientDetailsService clientDetailsService) {
super.setClientDetailsService(clientDetailsService);
}
}
实现原理,就是保证在同一位置来执行access_token存储的操作,上述只是一种实现方式,第二种实现方式是只重写JdbcTokenStore的storeAccessToken方法,在存储的过程添加锁。
3、在oauth2的启动配置中,加载自定义配置
/**
* @Description : 授权服务器
* @Project : hk-api-services
* @Program Name : com.hongkun.finance.api.oauth.OAuth2ServerConfig.java
* @Author : zc.ding
*/
@Configuration
@EnableAuthorizationServer
protected static class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
@Autowired
private DataSource dataSource;
@Autowired
private CustTokenStore tokenStore;
@Autowired
private CustTokenServices custTokenServices;
@Autowired
private JdbcClientDetailsService jdbcClientDetailsService;
/**
* authenticationManagerBean在SecurityConfig完成初始化
*/
@Autowired
@Qualifier("authenticationManagerBean")
private AuthenticationManager authenticationManager;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(this.jdbcClientDetailsService);
}
/**
* @Description : token存储介质,可自定义
* @Method_Name : tokenStore
* @return : TokenStore
* @Creation Date : 2018年3月20日 下午4:20:42
* @Author : zc.ding
*/
@Bean
public CustTokenStore tokenStore() {
return new CustJdbcTokenStore(dataSource);
}
/**
* @Description : clinetDetail维护实现类,可自定义
* @Method_Name : jdbcClientDetailsService
* @return : JdbcClientDetailsService
* @Creation Date : 2018年3月20日 下午4:18:11
* @Author : zc.ding
*/
@Bean
public JdbcClientDetailsService jdbcClientDetailsService() {
return new JdbcClientDetailsService(dataSource);
}
/**
* 用于解决获取access_token并发问题
* @Method_Name :custTokenServices
*
* @return com.hongkun.finance.api.oauth.extend.CustTokenServices
* @Creation Date :2018/7/9
* @Author :zc.ding
*/
@Bean
public CustTokenServices custTokenServices(){
CustTokenServices custTokenServices = new CustTokenServices();
custTokenServices.setTokenStore(tokenStore);
custTokenServices.setClientDetailsService(jdbcClientDetailsService);
return custTokenServices;
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
//由于加载顺序问题,此处需要设置下面属性
custTokenServices.setClientDetailsService(jdbcClientDetailsService);
custTokenServices.setTokenEnhancer(endpoints.getTokenEnhancer());
custTokenServices.setTokenStore(tokenStore);
endpoints.tokenStore(tokenStore).authenticationManager(authenticationManager)
// 支持POST和GET请求,生产环境支持POST请求, HttpMethod.GET
.allowedTokenEndpointRequestMethods(HttpMethod.POST).tokenServices(custTokenServices);
}
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
// 允许security和aouth使用相同身份验证
oauthServer.allowFormAuthenticationForClients();
}
}
至此oauth2密码(password)授权模式,单用户并发问题已经彻底解决。
tips:如果是自己的APP,可以将access_token的有限期设置长一些,不要占用更多的资源来处理token。