SAP Commerce(Hybris)开发实战(二):登陆生成token问题
问题简述
最近处理Hybris框架标准的登陆功能,遇到一个问题:用两个不同的浏览器,同时登陆一个账号,会同时生成两个不同的token和refreshToken。
问题原因
解决了其实非常简单,就是Hybris的Employee表中,有一个禁用登陆的字段logindisabled,被设置为禁止登陆了,正常情况应该是设置为false。
Hybris登陆原理
在这里可以详细的说一下问题以及造成的原因,Hybris的标准登陆功能是,同一个人的账号,登陆以后会设置access_token,refresh_token,expired,分别为登陆token,刷新token和超时时间,这个三个字段都存在同一张表里,只要在登陆时间内,无论怎么登陆,都应该返回的是同一个token对象,也就是上面三个字段的值应该保持一致。
但是这次遇到的问题就是,用两个不同浏览器,登陆同一个账号,会产生不同的token。
这里再补充一下,问题背景,这里的登陆,是在完成了saml2.0的单点登陆的第二步:后端通过url跳转,携带code直接从前端进入的Hybris后台标准登陆方法。
之前一直在单纯的排查登陆问题,实际登陆没有任何问题,因为用了标准的源码,问题出在单点登陆的第一步,也就是通过saml登陆请求到后端以后,在这里改变了Employee表的logindisabled字段状态,从而导致存token对象的表中的指定数据被清空,从而导致第二步的标准登陆方法执行没有获取到用户的token,而是直接生成了一个新的token。
那问题的重点就在,改变了Employee表的一个字段状态,存储在表中token对象就被清空了呢?
原因如下:
public class UserAuthenticationTokensRemovePrepareInterceptor implements PrepareInterceptor<UserModel> {private final TimeService timeService;public UserAuthenticationTokensRemovePrepareInterceptor(TimeService timeService) {this.timeService = timeService;}public void onPrepare(UserModel userModel, InterceptorContext ctx) throws InterceptorException {if (userModel.isLoginDisabled() || this.isUserDeactivated(userModel)) {Collection<OAuthAccessTokenModel> tokensToRemove = userModel.getTokens();if (tokensToRemove != null) {tokensToRemove.forEach((token) -> ctx.registerElementFor(token, PersistenceOperation.DELETE));}userModel.setTokens(Collections.emptyList());}}
......
UserMoldel是Employee的父类
isLoginDisabled()方法就是判断字段loginDisabled的值。如果为true,就把user里面的token对象设置为空。
也就是后来登陆查询不到用户token对象的原因。
获取token对象
那Hybris是怎么获取当前已经登陆过的用户的token对象的呢?
主要是通过DefaultHybrisOpenIDTokenServices的createAccessToken()方法:
public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) {try {OAuth2AccessToken accessToken = super.createAccessToken(authentication);Set<String> scopes = OAuth2Utils.parseParameterList((String)authentication.getOAuth2Request().getRequestParameters().get("scope"));if (scopes.contains("openid")) {OAuthClientDetailsModel clientDetailsModel = this.getClientDetailsDao().findClientById(authentication.getOAuth2Request().getClientId());if (!(clientDetailsModel instanceof OpenIDClientDetailsModel)) {Logger var10000 = LOG;String var10001 = clientDetailsModel.getClientId();var10000.warn("OAuth2 error, wrong configuration - Client with ID " + var10001 + " is not instance of " + OpenIDClientDetailsModel.class.getName());throw new InvalidRequestException("Server error. Can't generate id_token.");} else {OpenIDClientDetailsModel openIDClientDetailsModel = (OpenIDClientDetailsModel)clientDetailsModel;List<String> externalScopes = null;if (openIDClientDetailsModel.getExternalScopeClaimName() != null) {externalScopes = this.externalScopesStrategy.getExternalScopes(clientDetailsModel, (String)authentication.getUserAuthentication().getPrincipal());LOG.debug("externalScopes: " + externalScopes);}IDTokenParameterData idtokenparam = this.initializeIdTokenParameters(openIDClientDetailsModel.getClientId());DefaultOAuth2AccessToken accessTokenIdToken = new DefaultOAuth2AccessToken(accessToken);String requestedScopes = (String)authentication.getOAuth2Request().getRequestParameters().get("scope");if (!StringUtils.isEmpty(requestedScopes) && requestedScopes.contains("openid")) {IdTokenHelper idTokenHelper = this.createIdTokenHelper(authentication, openIDClientDetailsModel, externalScopes, idtokenparam);Jwt jwt = idTokenHelper.encodeAndSign(this.getSigner(idtokenparam));Map<String, Object> map = new HashMap();map.put("id_token", jwt.getEncoded());accessTokenIdToken.setAdditionalInformation(map);return accessTokenIdToken;} else {LOG.warn("Missing openid scope");throw new InvalidRequestException("Missing openid scope");}}} else {return accessToken;}} catch (ModelSavingException e) {LOG.debug("HybrisOAuthTokenServices->createAccessToken : ModelSavingException", e);return super.createAccessToken(authentication);} catch (ModelRemovalException e) {LOG.debug("HybrisOAuthTokenServices->createAccessToken : ModelRemovalException", e);return super.createAccessToken(authentication);}}
可以看到其主要就是通过调用父类的 createAccessToken(authentication)来实现的。
通过调用链:
HybrisOAuthTokenServices.createAccessToken(authentication)————>>>
DefaultTokenServices.createAccessToken(authentication)
@Transactionalpublic OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException {OAuth2AccessToken existingAccessToken = this.tokenStore.getAccessToken(authentication);OAuth2RefreshToken refreshToken = null;if (existingAccessToken != null) {if (!existingAccessToken.isExpired()) {this.tokenStore.storeAccessToken(existingAccessToken, authentication);return existingAccessToken;}if (existingAccessToken.getRefreshToken() != null) {refreshToken = existingAccessToken.getRefreshToken();this.tokenStore.removeRefreshToken(refreshToken);}this.tokenStore.removeAccessToken(existingAccessToken);}if (refreshToken == null) {refreshToken = this.createRefreshToken(authentication);} else if (refreshToken instanceof ExpiringOAuth2RefreshToken) {ExpiringOAuth2RefreshToken expiring = (ExpiringOAuth2RefreshToken)refreshToken;if (System.currentTimeMillis() > expiring.getExpiration().getTime()) {refreshToken = this.createRefreshToken(authentication);}}OAuth2AccessToken accessToken = this.createAccessToken(authentication, refreshToken);this.tokenStore.storeAccessToken(accessToken, authentication);refreshToken = accessToken.getRefreshToken();if (refreshToken != null) {this.tokenStore.storeRefreshToken(refreshToken, authentication);}return accessToken;}
可以看到重点在这一句
OAuth2AccessToken existingAccessToken = this.tokenStore.getAccessToken(authentication);
通过authentication来获取token,也就是HybrisOAuthTokenStore的getAccessToken方法:
public OAuth2AccessToken getAccessToken(OAuth2Authentication authentication) {OAuth2AccessToken accessToken = null;OAuthAccessTokenModel accessTokenModel = null;String authenticationId = this.authenticationKeyGenerator.extractKey(authentication);try {accessTokenModel = this.oauthTokenService.getAccessTokenForAuthentication(authenticationId);accessToken = this.deserializeAccessToken((byte[])accessTokenModel.getToken());} catch (ClassCastException | IllegalArgumentException var7) {LOG.warn("Could not extract access token for authentication " + authentication);this.oauthTokenService.removeAccessTokenForAuthentication(authenticationId);} catch (UnknownIdentifierException var8) {if (LOG.isInfoEnabled()) {LOG.debug("Failed to find access token for authentication " + authentication);}}try {if (accessToken != null && accessTokenModel != null && !StringUtils.equals(authenticationId, this.authenticationKeyGenerator.extractKey(this.deserializeAuthentication((byte[])accessTokenModel.getAuthentication())))) {this.replaceToken(authentication, accessToken);}} catch (ClassCastException | IllegalArgumentException var6) {this.replaceToken(authentication, accessToken);}return accessToken;}
这一段的重点是通过
String authenticationId = this.authenticationKeyGenerator.extractKey(authentication);
获取authenticationId,再去对应的表里,根据authenticationId查询出对应用户的token信息:
accessTokenModel = this.oauthTokenService.getAccessTokenForAuthentication(authenticationId);
这里还有一点需要重点了解的,就是 authenticationId是如何生成的,怎么保证每个用户的authenticationId都是一样的:
public class DefaultAuthenticationKeyGenerator implements AuthenticationKeyGenerator {private static final String CLIENT_ID = "client_id";private static final String SCOPE = "scope";private static final String USERNAME = "username";public String extractKey(OAuth2Authentication authentication) {Map<String, String> values = new LinkedHashMap();OAuth2Request authorizationRequest = authentication.getOAuth2Request();if (!authentication.isClientOnly()) {values.put("username", authentication.getName());}values.put("client_id", authorizationRequest.getClientId());if (authorizationRequest.getScope() != null) {values.put("scope", OAuth2Utils.formatParameterList(new TreeSet(authorizationRequest.getScope())));}return this.generateKey(values);}protected String generateKey(Map<String, String> values) {try {MessageDigest digest = MessageDigest.getInstance("MD5");byte[] bytes = digest.digest(values.toString().getBytes("UTF-8"));return String.format("%032x", new BigInteger(1, bytes));} catch (NoSuchAlgorithmException nsae) {throw new IllegalStateException("MD5 algorithm not available. Fatal (should be in the JDK).", nsae);} catch (UnsupportedEncodingException uee) {throw new IllegalStateException("UTF-8 encoding not available. Fatal (should be in the JDK).", uee);}}
}
可以看到,就是通过提取authentication对象的一系列属性,做MD5的Hash算法算出来的,由于用户都是一个,authentication提取的属性能保持都是一致的,所以可以为每个用户生成一个唯一的authenticationId。
然后在
this.oauthTokenService.getAccessTokenForAuthentication(authenticationId)
在进行查询:
public OAuthAccessTokenModel getAccessTokenForAuthentication(final String authenticationId) {ServicesUtil.validateParameterNotNull(authenticationId, "Parameter 'authenticationId' must not be null!");return (OAuthAccessTokenModel)this.getSessionService().executeInLocalView(new SessionExecutionBody() {public Object execute() {DefaultOAuthTokenService.this.searchRestrictionService.disableSearchRestrictions();try {return DefaultOAuthTokenService.this.oauthTokenDao.findAccessTokenByAuthenticationId(authenticationId);} catch (ModelNotFoundException e) {throw new UnknownIdentifierException(e);}}});}
这里可以直接看到查询sql:
public OAuthAccessTokenModel findAccessTokenByAuthenticationId(String authenticationId) {Map<String, Object> params = new HashMap();params.put("id", authenticationId);return (OAuthAccessTokenModel)this.searchUnique(new FlexibleSearchQuery("SELECT {pk} FROM {OAuthAccessToken} WHERE {authenticationId} = ?id ", params));}
也就是从表OAuthAccessToken进行查询。
这也就是Hybris标准的登陆功能的实现原理。
按理来说只要是同一个用户登陆,在过期时间之类,都能查询到对应的token。
而笔者遇到的问题,没能查询到,就是在登陆之前修改了表Employee的字段loginDisabled的状态。
导致OAuthAccessToken表对应的用户数据被清空,从而需要刷新token。
那么刷新token 的逻辑是怎样呢?
前面也有:
在DefaultTokenServices的createAccessToken方法中,当查询不到token时,会重新生成refresh_token和access_token:
if (refreshToken == null) {refreshToken = this.createRefreshToken(authentication);} else if (refreshToken instanceof ExpiringOAuth2RefreshToken) {ExpiringOAuth2RefreshToken expiring = (ExpiringOAuth2RefreshToken)refreshToken;if (System.currentTimeMillis() > expiring.getExpiration().getTime()) {refreshToken = this.createRefreshToken(authentication);}}OAuth2AccessToken accessToken = this.createAccessToken(authentication, refreshToken);this.tokenStore.storeAccessToken(accessToken, authentication);refreshToken = accessToken.getRefreshToken();if (refreshToken != null) {this.tokenStore.storeRefreshToken(refreshToken, authentication);}return accessToken;
然后返回token对象。
结论
在解决问题的同时,通过源码详细的讲解了Hybris的登陆原理,希望对大家有所帮助。