Browse Source

feat(oauth2): Add DingTalk OAuth2 provider backend services and mapper registration

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 null
pull/15927/head
longyan 2 weeks ago
parent
commit
1e608fcff5
  1. 28
      application/src/main/data/json/system/oauth2_config_templates/dingtalk_config.json
  2. 85
      application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/DingTalkAuthCodeFilter.java
  3. 50
      application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/DingTalkOAuth2ClientMapper.java
  4. 107
      application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/DingTalkOAuth2UserService.java
  5. 141
      application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/DingTalkTokenResponseClient.java
  6. 6
      application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapperProvider.java
  7. 6
      application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java
  8. 2
      common/data/src/main/java/org/thingsboard/server/common/data/oauth2/MapperType.java
  9. 2
      dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2ClientEntity.java

28
application/src/main/data/json/system/oauth2_config_templates/dingtalk_config.json

@ -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"
}

85
application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/DingTalkAuthCodeFilter.java

@ -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);
}
}
}

50
application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/DingTalkOAuth2ClientMapper.java

@ -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);
}
}

107
application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/DingTalkOAuth2UserService.java

@ -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
);
}
}

141
application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/DingTalkTokenResponseClient.java

@ -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);
}
}

6
application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapperProvider.java

@ -43,6 +43,10 @@ public class OAuth2ClientMapperProvider {
@Qualifier("appleOAuth2ClientMapper")
private OAuth2ClientMapper appleOAuth2ClientMapper;
@Autowired
@Qualifier("dingTalkOAuth2ClientMapper")
private OAuth2ClientMapper dingTalkOAuth2ClientMapper;
public OAuth2ClientMapper getOAuth2ClientMapperByType(MapperType oauth2MapperType) {
switch (oauth2MapperType) {
case CUSTOM:
@ -53,6 +57,8 @@ public class OAuth2ClientMapperProvider {
return githubOAuth2ClientMapper;
case APPLE:
return appleOAuth2ClientMapper;
case DINGTALK:
return dingTalkOAuth2ClientMapper;
default:
throw new RuntimeException("OAuth2ClientRegistrationMapper with type " + oauth2MapperType + " is not supported!");
}

6
application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java

@ -67,7 +67,11 @@ public class Oauth2AuthenticationFailureHandler extends SimpleUrlAuthenticationF
errorPrefix = "/login?loginError=";
}
httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
String errorMessage = exception.getMessage();
if (errorMessage == null) {
errorMessage = "Unknown error";
}
getRedirectStrategy().sendRedirect(request, response, baseUrl + errorPrefix +
URLEncoder.encode(exception.getMessage(), StandardCharsets.UTF_8.toString()));
URLEncoder.encode(errorMessage, StandardCharsets.UTF_8));
}
}

2
common/data/src/main/java/org/thingsboard/server/common/data/oauth2/MapperType.java

@ -16,5 +16,5 @@
package org.thingsboard.server.common.data.oauth2;
public enum MapperType {
BASIC, CUSTOM, GITHUB, APPLE;
BASIC, CUSTOM, GITHUB, APPLE, DINGTALK;
}

2
dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2ClientEntity.java

@ -177,7 +177,7 @@ public class OAuth2ClientEntity extends BaseSqlEntity<OAuth2Client> {
.activateUser(activateUser)
.type(type)
.basic(
(type == MapperType.BASIC || type == MapperType.GITHUB || type == MapperType.APPLE) ?
(type == MapperType.BASIC || type == MapperType.GITHUB || type == MapperType.APPLE || type == MapperType.DINGTALK) ?
OAuth2BasicMapperConfig.builder()
.emailAttributeKey(emailAttributeKey)
.firstNameAttributeKey(firstNameAttributeKey)

Loading…
Cancel
Save