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