diff --git a/blade-auth/src/main/java/org/springblade/auth/dto/IamSsoProfileResponse.java b/blade-auth/src/main/java/org/springblade/auth/dto/IamSsoProfileResponse.java new file mode 100644 index 0000000..d51f7f8 --- /dev/null +++ b/blade-auth/src/main/java/org/springblade/auth/dto/IamSsoProfileResponse.java @@ -0,0 +1,65 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.auth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.Map; + +/** + * IAM统一身份认证用户信息响应 + * + * @author Chill + */ +@Data +public class IamSsoProfileResponse { + + /** + * IAM用户主账号 + */ + private String id; + + /** + * IAM用户扩展属性 + */ + private Map attributes; + + /** + * 获取本系统账号 + * + * @return 本系统账号 + */ + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + public String getAccountNo() { + if (attributes == null) { + return null; + } + Object accountNo = attributes.get("account_no"); + return accountNo == null ? null : String.valueOf(accountNo); + } + +} diff --git a/blade-auth/src/main/java/org/springblade/auth/dto/IamSsoTokenResponse.java b/blade-auth/src/main/java/org/springblade/auth/dto/IamSsoTokenResponse.java new file mode 100644 index 0000000..a68094f --- /dev/null +++ b/blade-auth/src/main/java/org/springblade/auth/dto/IamSsoTokenResponse.java @@ -0,0 +1,51 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.auth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * IAM统一身份认证Token响应 + * + * @author Chill + */ +@Data +public class IamSsoTokenResponse { + + /** + * IAM访问令牌 + */ + @JsonProperty("access_token") + private String accessToken; + + /** + * IAM刷新令牌 + */ + @JsonProperty("refresh_token") + private String refreshToken; + +} diff --git a/blade-auth/src/main/java/org/springblade/auth/granter/IamSsoTokenGranter.java b/blade-auth/src/main/java/org/springblade/auth/granter/IamSsoTokenGranter.java new file mode 100644 index 0000000..52a24aa --- /dev/null +++ b/blade-auth/src/main/java/org/springblade/auth/granter/IamSsoTokenGranter.java @@ -0,0 +1,244 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.auth.granter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springblade.auth.dto.IamSsoProfileResponse; +import org.springblade.auth.dto.IamSsoTokenResponse; +import org.springblade.auth.props.IamSsoProperties; +import org.springblade.auth.utils.TokenUtil; +import org.springblade.core.oauth2.constant.OAuth2TokenConstant; +import org.springblade.core.oauth2.exception.UserInvalidException; +import org.springblade.core.oauth2.granter.AuthorizationCodeGranter; +import org.springblade.core.oauth2.handler.PasswordHandler; +import org.springblade.core.oauth2.provider.OAuth2Request; +import org.springblade.core.oauth2.service.OAuth2ClientService; +import org.springblade.core.oauth2.service.OAuth2User; +import org.springblade.core.oauth2.service.OAuth2UserService; +import org.springblade.core.redis.cache.BladeRedis; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.system.feign.IUserClient; +import org.springblade.system.pojo.entity.UserInfo; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * IAM统一身份认证授权器 + * + * @author Chill + */ +@Slf4j +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class IamSsoTokenGranter extends AuthorizationCodeGranter { + + private static final String IAM_GRANT_TYPE = "authorization_code"; + private static final String BEARER_PREFIX = "Bearer "; + + private final IUserClient userClient; + private final IamSsoProperties properties; + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + + public IamSsoTokenGranter(OAuth2ClientService clientService, + OAuth2UserService userService, + PasswordHandler passwordHandler, + BladeRedis bladeRedis, + IUserClient userClient, + IamSsoProperties properties, + ObjectMapper objectMapper) { + super(clientService, userService, passwordHandler, bladeRedis); + this.userClient = userClient; + this.properties = properties; + this.objectMapper = objectMapper; + this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + } + + @Override + public OAuth2User user(OAuth2Request request) { + if (!isIamRequest(request)) { + return super.user(request); + } + validateRequest(request); + // 先校验本系统客户端,IAM的redirect_uri由后续远端接口校验。 + client(request); + + IamSsoTokenResponse tokenResponse = requestIamToken(request); + String accessToken = tokenResponse.getAccessToken(); + if (StringUtil.isBlank(accessToken)) { + throw new UserInvalidException(OAuth2TokenConstant.TOKEN_NOT_CORRECT); + } + + IamSsoProfileResponse profileResponse = requestIamProfile(accessToken); + String accountNo = profileResponse.getAccountNo(); + if (StringUtil.isBlank(accountNo)) { + log.warn("IAM统一身份认证用户信息缺少account_no,iamId={}", profileResponse.getId()); + throw new UserInvalidException(OAuth2TokenConstant.USER_NOT_FOUND); + } + + R result = userClient.userInfo(request.getTenantId(), accountNo); + if (!result.isSuccess() || result.getData() == null) { + log.warn("IAM统一身份认证未匹配到本系统账号,tenantId={}, accountNo={}", request.getTenantId(), accountNo); + throw new UserInvalidException(OAuth2TokenConstant.USER_NOT_FOUND); + } + + OAuth2User user = TokenUtil.convertUser(result.getData(), request); + if (user == null) { + throw new UserInvalidException(OAuth2TokenConstant.USER_NOT_FOUND); + } + user.setClient(client(request)); + return user; + } + + private boolean isIamRequest(OAuth2Request request) { + return StringUtil.isNotBlank(properties.getRedirectUri()) && StringUtil.equals(properties.getRedirectUri(), request.getRedirectUri()); + } + + private void validateRequest(OAuth2Request request) { + if (StringUtil.isBlank(request.getCode())) { + throw new UserInvalidException(OAuth2TokenConstant.TOKEN_NOT_CORRECT); + } + if (StringUtil.isAnyBlank( + properties.getTokenUrl(), + properties.getProfileUrl(), + properties.getClientId(), + properties.getClientSecret(), + properties.getRedirectUri() + )) { + throw new UserInvalidException("IAM统一身份认证配置不完整"); + } + } + + private IamSsoTokenResponse requestIamToken(OAuth2Request request) { + try { + HttpRequest httpRequest = HttpRequest.newBuilder(URI.create(properties.getTokenUrl())) + .timeout(Duration.ofSeconds(10)) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .header(HttpHeaders.AUTHORIZATION, authorizationHeader()) + .POST(HttpRequest.BodyPublishers.ofString(buildTokenBody(request), StandardCharsets.UTF_8)) + .build(); + HttpResponse response = httpClient.send( + httpRequest, + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8) + ); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + log.warn("IAM统一身份认证换取Token失败,status={}", response.statusCode()); + throw new UserInvalidException(OAuth2TokenConstant.TOKEN_NOT_CORRECT); + } + return objectMapper.readValue(response.body(), IamSsoTokenResponse.class); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new UserInvalidException(OAuth2TokenConstant.TOKEN_NOT_CORRECT); + } catch (IOException | IllegalArgumentException exception) { + log.warn("IAM统一身份认证换取Token异常", exception); + throw new UserInvalidException(OAuth2TokenConstant.TOKEN_NOT_CORRECT); + } + } + + private IamSsoProfileResponse requestIamProfile(String accessToken) { + try { + HttpRequest httpRequest = HttpRequest.newBuilder(URI.create(buildProfileUrl(accessToken))) + .timeout(Duration.ofSeconds(10)) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.AUTHORIZATION, profileAuthorizationHeader(accessToken)) + .GET() + .build(); + HttpResponse response = httpClient.send( + httpRequest, + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8) + ); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + log.warn("IAM统一身份认证获取用户信息失败,status={}", response.statusCode()); + throw new UserInvalidException(OAuth2TokenConstant.TOKEN_NOT_CORRECT); + } + return objectMapper.readValue(response.body(), IamSsoProfileResponse.class); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new UserInvalidException(OAuth2TokenConstant.TOKEN_NOT_CORRECT); + } catch (IOException | IllegalArgumentException exception) { + log.warn("IAM统一身份认证获取用户信息异常", exception); + throw new UserInvalidException(OAuth2TokenConstant.TOKEN_NOT_CORRECT); + } + } + + private String buildTokenBody(OAuth2Request request) { + Map params = new LinkedHashMap<>(); + params.put("grant_type", IAM_GRANT_TYPE); + params.put("client_id", properties.getClientId()); + params.put("client_secret", properties.getClientSecret()); + params.put("code", request.getCode()); + params.put("redirect_uri", properties.getRedirectUri()); + return params.entrySet().stream() + .map(entry -> encode(entry.getKey()) + "=" + encode(entry.getValue())) + .collect(Collectors.joining("&")); + } + + private String buildProfileUrl(String accessToken) { + String separator = properties.getProfileUrl().contains("?") ? "&" : "?"; + return properties.getProfileUrl() + separator + "access_token=" + encode(accessToken); + } + + private String authorizationHeader() { + if (StringUtil.isNotBlank(properties.getAuthorization())) { + return properties.getAuthorization(); + } + String token = Base64.getEncoder().encodeToString( + (properties.getClientId() + ":" + properties.getClientSecret()).getBytes(StandardCharsets.UTF_8) + ); + return "Basic " + token; + } + + private String profileAuthorizationHeader(String accessToken) { + if (StringUtil.isNotBlank(properties.getProfileAuthorization())) { + return properties.getProfileAuthorization(); + } + return BEARER_PREFIX + accessToken; + } + + private String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + +} diff --git a/blade-auth/src/main/java/org/springblade/auth/props/IamSsoProperties.java b/blade-auth/src/main/java/org/springblade/auth/props/IamSsoProperties.java new file mode 100644 index 0000000..7e91b06 --- /dev/null +++ b/blade-auth/src/main/java/org/springblade/auth/props/IamSsoProperties.java @@ -0,0 +1,75 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.auth.props; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * IAM统一身份认证配置 + * + * @author Chill + */ +@Data +@ConfigurationProperties(prefix = "iam.sso") +public class IamSsoProperties { + + /** + * IAM换取Token接口地址 + */ + private String tokenUrl; + + /** + * IAM获取用户信息接口地址 + */ + private String profileUrl; + + /** + * IAM客户端ID + */ + private String clientId; + + /** + * IAM客户端密钥 + */ + private String clientSecret; + + /** + * IAM回调地址 + */ + private String redirectUri; + + /** + * IAM认证头。为空时默认使用 Basic Base64(clientId:clientSecret) + */ + private String authorization; + + /** + * IAM用户信息认证头。为空时默认使用 Bearer accessToken + */ + private String profileAuthorization; + +}