Browse Source
Add DINGTALK mapper type to MapperType enum, register DingTalkClientMapper in OAuth2ClientMapperProvider, and implement DingTalk-specific OAuth2 components: auth code filter, user service, token response client, client mapper, and configuration XML template. fix(auth): Fix NPE in Oauth2AuthenticationFailureHandler when error message is nullpull/15927/head
9 changed files with 424 additions and 3 deletions
@ -0,0 +1,28 @@ |
|||||
|
{ |
||||
|
"providerId": "DingTalk", |
||||
|
"additionalInfo": { |
||||
|
"providerName": "DingTalk" |
||||
|
}, |
||||
|
"accessTokenUri": "https://api.dingtalk.com/v1.0/oauth2/userAccessToken", |
||||
|
"authorizationUri": "https://login.dingtalk.com/oauth2/auth", |
||||
|
"scope": [ |
||||
|
"corpid" |
||||
|
], |
||||
|
"jwkSetUri": null, |
||||
|
"userInfoUri": "https://api.dingtalk.com/v1.0/contact/users/me", |
||||
|
"clientAuthenticationMethod": "POST", |
||||
|
"userNameAttributeName": "userid", |
||||
|
"mapperConfig": { |
||||
|
"type": "DINGTALK", |
||||
|
"basic": { |
||||
|
"emailAttributeKey": "email", |
||||
|
"firstNameAttributeKey": "nick", |
||||
|
"lastNameAttributeKey": null, |
||||
|
"tenantNameStrategy": "DOMAIN" |
||||
|
} |
||||
|
}, |
||||
|
"comment": null, |
||||
|
"loginButtonIcon": "dingtalk-logo", |
||||
|
"loginButtonLabel": "DingTalk", |
||||
|
"helpLink": "https://open.dingtalk.com/document/isvapp/obtain-user-token" |
||||
|
} |
||||
@ -0,0 +1,85 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.service.security.auth.oauth2; |
||||
|
|
||||
|
import jakarta.servlet.FilterChain; |
||||
|
import jakarta.servlet.ServletException; |
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import jakarta.servlet.http.HttpServletRequestWrapper; |
||||
|
import jakarta.servlet.http.HttpServletResponse; |
||||
|
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationCodeGrantFilter; |
||||
|
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
import org.springframework.web.filter.OncePerRequestFilter; |
||||
|
import org.thingsboard.server.queue.util.TbCoreComponent; |
||||
|
|
||||
|
import java.io.IOException; |
||||
|
import java.util.Collections; |
||||
|
import java.util.Enumeration; |
||||
|
import java.util.HashMap; |
||||
|
import java.util.Map; |
||||
|
|
||||
|
@Component |
||||
|
@TbCoreComponent |
||||
|
public class DingTalkAuthCodeFilter extends OncePerRequestFilter { |
||||
|
|
||||
|
@Override |
||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) |
||||
|
throws ServletException, IOException { |
||||
|
|
||||
|
String authCode = request.getParameter("authCode"); |
||||
|
if (authCode != null && !authCode.isEmpty() && request.getParameter(OAuth2ParameterNames.CODE) == null) { |
||||
|
HttpServletRequest wrappedRequest = new DingTalkAuthCodeRequestWrapper(request, authCode); |
||||
|
filterChain.doFilter(wrappedRequest, response); |
||||
|
return; |
||||
|
} |
||||
|
filterChain.doFilter(request, response); |
||||
|
} |
||||
|
|
||||
|
private static class DingTalkAuthCodeRequestWrapper extends HttpServletRequestWrapper { |
||||
|
|
||||
|
private final Map<String, String[]> modifiedParams; |
||||
|
|
||||
|
DingTalkAuthCodeRequestWrapper(HttpServletRequest request, String authCode) { |
||||
|
super(request); |
||||
|
this.modifiedParams = new HashMap<>(request.getParameterMap()); |
||||
|
this.modifiedParams.put(OAuth2ParameterNames.CODE, new String[]{authCode}); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public String getParameter(String name) { |
||||
|
if (OAuth2ParameterNames.CODE.equals(name)) { |
||||
|
return modifiedParams.get(OAuth2ParameterNames.CODE)[0]; |
||||
|
} |
||||
|
return super.getParameter(name); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public Map<String, String[]> getParameterMap() { |
||||
|
return Collections.unmodifiableMap(modifiedParams); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public Enumeration<String> getParameterNames() { |
||||
|
return Collections.enumeration(modifiedParams.keySet()); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public String[] getParameterValues(String name) { |
||||
|
return modifiedParams.get(name); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.service.security.auth.oauth2; |
||||
|
|
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException; |
||||
|
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.thingsboard.server.common.data.oauth2.OAuth2Client; |
||||
|
import org.thingsboard.server.dao.oauth2.OAuth2User; |
||||
|
import org.thingsboard.server.queue.util.TbCoreComponent; |
||||
|
import org.thingsboard.server.service.security.model.SecurityUser; |
||||
|
|
||||
|
import java.util.Map; |
||||
|
|
||||
|
@Service(value = "dingTalkOAuth2ClientMapper") |
||||
|
@Slf4j |
||||
|
@TbCoreComponent |
||||
|
public class DingTalkOAuth2ClientMapper extends AbstractOAuth2ClientMapper implements OAuth2ClientMapper { |
||||
|
|
||||
|
@Override |
||||
|
public SecurityUser getOrCreateUserByClientPrincipal(HttpServletRequest request, OAuth2AuthenticationToken token, String providerAccessToken, OAuth2Client oAuth2Client) { |
||||
|
Map<String, Object> attributes = token.getPrincipal().getAttributes(); |
||||
|
String email = attributes.containsKey("email") ? String.valueOf(attributes.get("email")) : null; |
||||
|
String nick = attributes.containsKey("nick") ? String.valueOf(attributes.get("nick")) : null; |
||||
|
|
||||
|
if (email == null || email.isEmpty()) { |
||||
|
throw new UsernameNotFoundException("Please try again after configuring personal email account in DingTalk."); |
||||
|
} |
||||
|
|
||||
|
OAuth2User oauth2User = BasicMapperUtils.getOAuth2User(email, attributes, oAuth2Client.getMapperConfig()); |
||||
|
oauth2User.setFirstName(nick); |
||||
|
|
||||
|
return getOrCreateSecurityUserFromOAuth2User(oauth2User, oAuth2Client); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,107 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.service.security.auth.oauth2; |
||||
|
|
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.http.HttpEntity; |
||||
|
import org.springframework.http.HttpHeaders; |
||||
|
import org.springframework.http.HttpMethod; |
||||
|
import org.springframework.http.MediaType; |
||||
|
import org.springframework.security.oauth2.client.registration.ClientRegistration; |
||||
|
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; |
||||
|
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; |
||||
|
import org.springframework.security.oauth2.core.OAuth2AuthenticationException; |
||||
|
import org.springframework.security.oauth2.core.user.DefaultOAuth2User; |
||||
|
import org.springframework.security.oauth2.core.user.OAuth2User; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
import org.springframework.web.client.HttpClientErrorException; |
||||
|
import org.springframework.web.client.HttpServerErrorException; |
||||
|
import org.springframework.web.client.RestTemplate; |
||||
|
import org.thingsboard.server.queue.util.TbCoreComponent; |
||||
|
|
||||
|
import java.util.Collections; |
||||
|
import java.util.HashMap; |
||||
|
import java.util.Map; |
||||
|
|
||||
|
@Component |
||||
|
@Slf4j |
||||
|
@TbCoreComponent |
||||
|
public class DingTalkOAuth2UserService extends DefaultOAuth2UserService { |
||||
|
|
||||
|
private static final String DINGTALK_USER_INFO_URI = "https://api.dingtalk.com/v1.0/contact/users/me"; |
||||
|
|
||||
|
private final RestTemplate restTemplate; |
||||
|
|
||||
|
public DingTalkOAuth2UserService() { |
||||
|
this.restTemplate = new RestTemplate(); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { |
||||
|
ClientRegistration.ProviderDetails providerDetails = userRequest.getClientRegistration().getProviderDetails(); |
||||
|
String userInfoUri = providerDetails.getUserInfoEndpoint().getUri(); |
||||
|
|
||||
|
if (!DINGTALK_USER_INFO_URI.equals(userInfoUri)) { |
||||
|
return super.loadUser(userRequest); |
||||
|
} |
||||
|
|
||||
|
String accessToken = userRequest.getAccessToken().getTokenValue(); |
||||
|
|
||||
|
HttpHeaders headers = new HttpHeaders(); |
||||
|
headers.set("x-acs-dingtalk-access-token", accessToken); |
||||
|
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); |
||||
|
|
||||
|
HttpEntity<?> entity = new HttpEntity<>(headers); |
||||
|
|
||||
|
Map<String, Object> responseBody; |
||||
|
try { |
||||
|
responseBody = restTemplate.exchange(userInfoUri, HttpMethod.GET, entity, Map.class).getBody(); |
||||
|
} catch (HttpClientErrorException | HttpServerErrorException e) { |
||||
|
log.error("Failed to get user info from DingTalk: status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); |
||||
|
throw new OAuth2AuthenticationException("Failed to get user info from DingTalk: HTTP " + e.getStatusCode() + " - " + e.getResponseBodyAsString()); |
||||
|
} catch (Exception e) { |
||||
|
log.error("Failed to get user info from DingTalk: {}", e.getMessage(), e); |
||||
|
throw new OAuth2AuthenticationException("Failed to get user info from DingTalk: " + e.getMessage()); |
||||
|
} |
||||
|
|
||||
|
if (responseBody == null) { |
||||
|
throw new OAuth2AuthenticationException("Empty response from DingTalk user info endpoint"); |
||||
|
} |
||||
|
|
||||
|
Map<String, Object> userAttributes = new HashMap<>(responseBody); |
||||
|
|
||||
|
String userNameAttributeName = providerDetails.getUserInfoEndpoint().getUserNameAttributeName(); |
||||
|
|
||||
|
if (userAttributes.get(userNameAttributeName) == null) { |
||||
|
if (userAttributes.get("unionId") != null) { |
||||
|
userAttributes.put(userNameAttributeName, userAttributes.get("unionId")); |
||||
|
log.warn("DingTalk response missing '{}', using 'unionId' as fallback", userNameAttributeName); |
||||
|
} else if (userAttributes.get("openContactId") != null) { |
||||
|
userAttributes.put(userNameAttributeName, userAttributes.get("openContactId")); |
||||
|
log.warn("DingTalk response missing '{}', using 'openContactId' as fallback", userNameAttributeName); |
||||
|
} else { |
||||
|
log.error("DingTalk response missing '{}' and no fallback fields available. Response: {}", userNameAttributeName, responseBody); |
||||
|
throw new OAuth2AuthenticationException("DingTalk response does not contain '" + userNameAttributeName + "' or any fallback identifier (unionId, openContactId)"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return new DefaultOAuth2User( |
||||
|
Collections.emptyList(), |
||||
|
userAttributes, |
||||
|
userNameAttributeName |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,141 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.service.security.auth.oauth2; |
||||
|
|
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.http.HttpEntity; |
||||
|
import org.springframework.http.HttpHeaders; |
||||
|
import org.springframework.http.HttpMethod; |
||||
|
import org.springframework.http.MediaType; |
||||
|
import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; |
||||
|
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; |
||||
|
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; |
||||
|
import org.springframework.security.oauth2.client.registration.ClientRegistration; |
||||
|
import org.springframework.security.oauth2.core.OAuth2AccessToken; |
||||
|
import org.springframework.security.oauth2.core.OAuth2AuthenticationException; |
||||
|
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
import org.springframework.util.CollectionUtils; |
||||
|
import org.springframework.web.client.RestTemplate; |
||||
|
import org.thingsboard.server.queue.util.TbCoreComponent; |
||||
|
|
||||
|
import java.util.Collections; |
||||
|
import java.util.HashMap; |
||||
|
import java.util.Map; |
||||
|
|
||||
|
@Component |
||||
|
@Slf4j |
||||
|
@TbCoreComponent |
||||
|
public class DingTalkTokenResponseClient implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> { |
||||
|
|
||||
|
private static final String DINGTALK_TOKEN_URI = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken"; |
||||
|
private static final long DEFAULT_EXPIRE_SECONDS = 7200; |
||||
|
|
||||
|
private final RestTemplate restTemplate; |
||||
|
private final OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> defaultClient; |
||||
|
|
||||
|
public DingTalkTokenResponseClient() { |
||||
|
this.restTemplate = new RestTemplate(); |
||||
|
this.defaultClient = new DefaultAuthorizationCodeTokenResponseClient(); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest grantRequest) { |
||||
|
if (supports(grantRequest.getClientRegistration())) { |
||||
|
return getTokenResponseInternal(grantRequest); |
||||
|
} |
||||
|
return defaultClient.getTokenResponse(grantRequest); |
||||
|
} |
||||
|
|
||||
|
private OAuth2AccessTokenResponse getTokenResponseInternal(OAuth2AuthorizationCodeGrantRequest grantRequest) { |
||||
|
ClientRegistration clientRegistration = grantRequest.getClientRegistration(); |
||||
|
String tokenUri = clientRegistration.getProviderDetails().getTokenUri(); |
||||
|
|
||||
|
Map<String, String> body = new HashMap<>(); |
||||
|
body.put("clientId", clientRegistration.getClientId()); |
||||
|
body.put("clientSecret", clientRegistration.getClientSecret()); |
||||
|
body.put("code", grantRequest.getAuthorizationExchange().getAuthorizationResponse().getCode()); |
||||
|
body.put("grantType", "authorization_code"); |
||||
|
|
||||
|
HttpHeaders headers = new HttpHeaders(); |
||||
|
headers.setContentType(MediaType.APPLICATION_JSON); |
||||
|
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); |
||||
|
|
||||
|
HttpEntity<Map<String, String>> entity = new HttpEntity<>(body, headers); |
||||
|
|
||||
|
Map<String, Object> responseBody; |
||||
|
try { |
||||
|
responseBody = restTemplate.exchange(tokenUri, HttpMethod.POST, entity, Map.class).getBody(); |
||||
|
} catch (Exception e) { |
||||
|
log.error("Failed to get access token from DingTalk: {}", e.getMessage(), e); |
||||
|
throw new OAuth2AuthenticationException("Failed to get access token from DingTalk: " + e.getMessage()); |
||||
|
} |
||||
|
|
||||
|
if (responseBody == null) { |
||||
|
throw new OAuth2AuthenticationException("Empty response from DingTalk token endpoint"); |
||||
|
} |
||||
|
|
||||
|
if (responseBody.containsKey("code")) { |
||||
|
String responseCode = String.valueOf(responseBody.get("code")); |
||||
|
if (!"0".equals(responseCode)) { |
||||
|
String message = responseBody.containsKey("message") ? String.valueOf(responseBody.get("message")) : "Unknown error"; |
||||
|
throw new OAuth2AuthenticationException("DingTalk error [" + responseCode + "]: " + message); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Object accessToken = responseBody.get("accessToken"); |
||||
|
if (accessToken == null) { |
||||
|
throw new OAuth2AuthenticationException("DingTalk token response missing 'accessToken'"); |
||||
|
} |
||||
|
|
||||
|
String refreshToken = responseBody.containsKey("refreshToken") ? String.valueOf(responseBody.get("refreshToken")) : null; |
||||
|
long expireIn = DEFAULT_EXPIRE_SECONDS; |
||||
|
if (responseBody.containsKey("expireIn")) { |
||||
|
try { |
||||
|
expireIn = Long.parseLong(String.valueOf(responseBody.get("expireIn"))); |
||||
|
} catch (NumberFormatException e) { |
||||
|
log.warn("Failed to parse expireIn from DingTalk response, using default: {}", DEFAULT_EXPIRE_SECONDS); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Map<String, Object> additionalParameters = new HashMap<>(); |
||||
|
if (responseBody.containsKey("corpId")) { |
||||
|
additionalParameters.put("corpId", responseBody.get("corpId")); |
||||
|
} |
||||
|
|
||||
|
if (!CollectionUtils.isEmpty(additionalParameters)) { |
||||
|
return OAuth2AccessTokenResponse.withToken(String.valueOf(accessToken)) |
||||
|
.tokenType(OAuth2AccessToken.TokenType.BEARER) |
||||
|
.expiresIn(expireIn) |
||||
|
.refreshToken(refreshToken) |
||||
|
.scopes(grantRequest.getAuthorizationExchange().getAuthorizationRequest().getScopes()) |
||||
|
.additionalParameters(additionalParameters) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
return OAuth2AccessTokenResponse.withToken(String.valueOf(accessToken)) |
||||
|
.tokenType(OAuth2AccessToken.TokenType.BEARER) |
||||
|
.expiresIn(expireIn) |
||||
|
.refreshToken(refreshToken) |
||||
|
.scopes(grantRequest.getAuthorizationExchange().getAuthorizationRequest().getScopes()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
public boolean supports(ClientRegistration clientRegistration) { |
||||
|
String tokenUri = clientRegistration.getProviderDetails().getTokenUri(); |
||||
|
return DINGTALK_TOKEN_URI.equals(tokenUri); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue