diff --git a/src/main/java/me/zhyd/oauth/config/AuthConfig.java b/src/main/java/me/zhyd/oauth/config/AuthConfig.java index 339d6fa09b744f5a8ececbe7916562a5086ce6fc..a18ad55335ccf3c8a8fc74aa7d76ea7529b4dfa0 100644 --- a/src/main/java/me/zhyd/oauth/config/AuthConfig.java +++ b/src/main/java/me/zhyd/oauth/config/AuthConfig.java @@ -131,4 +131,11 @@ public class AuthConfig { * @since 1.15.9 */ private String packId; + + /** + * 是否开启 PKCE 模式,该配置仅用于支持 PKCE 模式的平台,针对无服务应用,不推荐使用隐式授权,推荐使用 PKCE 模式 + * + * @since 1.16.0 + */ + private boolean pkce; } diff --git a/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java b/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java index 2accd42fe45c2dfb8e39b340229fd28d61d3bc45..cbb7202eea13df9a8471bab3fa6f248d6e3eeced 100644 --- a/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java +++ b/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java @@ -832,5 +832,32 @@ public enum AuthDefaultSource implements AuthSource { public String refresh() { return "https://oauth.aliyun.com/v1/token"; } + }, + + /** + * Amazon + * + * @since 1.16.0 + */ + AMAZON { + @Override + public String authorize() { + return "https://www.amazon.com/ap/oa"; + } + + @Override + public String accessToken() { + return "https://api.amazon.com/auth/o2/token"; + } + + @Override + public String userInfo() { + return "https://api.amazon.com/user/profile"; + } + + @Override + public String refresh() { + return "https://api.amazon.com/auth/o2/token"; + } } } diff --git a/src/main/java/me/zhyd/oauth/enums/scope/AuthAmazonScope.java b/src/main/java/me/zhyd/oauth/enums/scope/AuthAmazonScope.java new file mode 100644 index 0000000000000000000000000000000000000000..61f27a92d0e321065c3bac4851eb03233f0bf95b --- /dev/null +++ b/src/main/java/me/zhyd/oauth/enums/scope/AuthAmazonScope.java @@ -0,0 +1,28 @@ +package me.zhyd.oauth.enums.scope; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Amazon平台 OAuth 授权范围 + * + * @author yadong.zhang (yadong.zhang0415(a)gmail.com) + * @version 1.0.0 + * @since 1.16.0 + */ +@Getter +@AllArgsConstructor +public enum AuthAmazonScope implements AuthScope { + + /** + * {@code scope} 含义,以{@code description} 为准 + */ + R_LITEPROFILE("profile", "The profile scope includes a user's name and email address", true), + R_EMAILADDRESS("profile:user_id", "The profile:user_id scope only includes the user_id field of the profile", true), + W_MEMBER_SOCIAL("postal_code", "This includes the user's zip/postal code number from their primary shipping address", true); + + private final String scope; + private final String description; + private final boolean isDefault; + +} diff --git a/src/main/java/me/zhyd/oauth/request/AuthAmazonRequest.java b/src/main/java/me/zhyd/oauth/request/AuthAmazonRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..30c8c3d0ee97623a1ff48f659284893619699269 --- /dev/null +++ b/src/main/java/me/zhyd/oauth/request/AuthAmazonRequest.java @@ -0,0 +1,182 @@ +package me.zhyd.oauth.request; + +import com.alibaba.fastjson.JSONObject; +import com.xkcoding.http.constants.Constants; +import com.xkcoding.http.support.HttpHeader; +import com.xkcoding.http.util.UrlUtil; +import me.zhyd.oauth.cache.AuthStateCache; +import me.zhyd.oauth.config.AuthConfig; +import me.zhyd.oauth.config.AuthDefaultSource; +import me.zhyd.oauth.enums.AuthResponseStatus; +import me.zhyd.oauth.enums.AuthUserGender; +import me.zhyd.oauth.enums.scope.AuthAmazonScope; +import me.zhyd.oauth.exception.AuthException; +import me.zhyd.oauth.model.AuthCallback; +import me.zhyd.oauth.model.AuthResponse; +import me.zhyd.oauth.model.AuthToken; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.utils.AuthScopeUtils; +import me.zhyd.oauth.utils.HttpUtils; +import me.zhyd.oauth.utils.PkceUtil; +import me.zhyd.oauth.utils.UrlBuilder; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Amazon登录 + * Login with Amazon for Websites Overview: https://developer.amazon.com/zh/docs/login-with-amazon/register-web.html + * Login with Amazon SDK for JavaScript Reference Guide:https://developer.amazon.com/zh/docs/login-with-amazon/javascript-sdk-reference.html + * + * @author yadong.zhang (yadong.zhang0415(a)gmail.com) + * @since 1.16.0 + */ +public class AuthAmazonRequest extends AuthDefaultRequest { + + public AuthAmazonRequest(AuthConfig config) { + super(config, AuthDefaultSource.AMAZON); + } + + public AuthAmazonRequest(AuthConfig config, AuthStateCache authStateCache) { + super(config, AuthDefaultSource.AMAZON, authStateCache); + } + + /** + * https://developer.amazon.com/zh/docs/login-with-amazon/authorization-code-grant.html#authorization-request + * + * @param state state 验证授权流程的参数,可以防止csrf + * @return String + */ + @Override + public String authorize(String state) { + UrlBuilder builder = UrlBuilder.fromBaseUrl(source.authorize()) + .queryParam("client_id", config.getClientId()) + .queryParam("scope", this.getScopes(" ", true, AuthScopeUtils.getDefaultScopes(AuthAmazonScope.values()))) + .queryParam("redirect_uri", config.getRedirectUri()) + .queryParam("response_type", "code") + .queryParam("state", getRealState(state)); + + if (config.isPkce()) { + String cacheKey = this.source.getName().concat(":code_verifier:").concat(config.getClientId()); + String codeVerifier = PkceUtil.generateCodeVerifier(); + String codeChallengeMethod = "S256"; + String codeChallenge = PkceUtil.generateCodeChallenge(codeChallengeMethod, codeVerifier); + builder.queryParam("code_challenge", codeChallenge) + .queryParam("code_challenge_method", codeChallengeMethod); + // 缓存 codeVerifier 十分钟 + this.authStateCache.cache(cacheKey, codeVerifier, TimeUnit.MINUTES.toMillis(10)); + } + + return builder.build(); + } + + /** + * https://developer.amazon.com/zh/docs/login-with-amazon/authorization-code-grant.html#access-token-request + * + * @return access token + */ + @Override + protected AuthToken getAccessToken(AuthCallback authCallback) { + Map form = new HashMap<>(8); + form.put("grant_type", "authorization_code"); + form.put("code", authCallback.getCode()); + form.put("redirect_uri", config.getRedirectUri()); + form.put("client_id", config.getClientId()); + form.put("client_secret", config.getClientSecret()); + + if (config.isPkce()) { + String cacheKey = this.source.getName().concat(":code_verifier:").concat(config.getClientId()); + String codeVerifier = this.authStateCache.get(cacheKey); + form.put("code_verifier", codeVerifier); + } + return getToken(form, this.source.accessToken()); + } + + @Override + public AuthResponse refresh(AuthToken authToken) { + Map form = new HashMap<>(6); + form.put("grant_type", "refresh_token"); + form.put("refresh_token", authToken.getRefreshToken()); + form.put("client_id", config.getClientId()); + form.put("client_secret", config.getClientSecret()); + return AuthResponse.builder() + .code(AuthResponseStatus.SUCCESS.getCode()) + .data(getToken(form, this.source.refresh())) + .build(); + + } + + private AuthToken getToken(Map param, String url) { + HttpHeader httpHeader = new HttpHeader(); + httpHeader.add("Host", "api.amazon.com"); + httpHeader.add(Constants.CONTENT_TYPE, "application/x-www-form-urlencoded;charset=UTF-8"); + String response = new HttpUtils(config.getHttpConfig()).post(url, param, httpHeader, false); + JSONObject jsonObject = JSONObject.parseObject(response); + this.checkResponse(jsonObject); + return AuthToken.builder() + .accessToken(jsonObject.getString("access_token")) + .tokenType(jsonObject.getString("token_type")) + .expireIn(jsonObject.getIntValue("expires_in")) + .refreshToken(jsonObject.getString("refresh_token")) + .build(); + } + + /** + * 校验响应内容是否正确 + * + * @param jsonObject 响应内容 + */ + private void checkResponse(JSONObject jsonObject) { + if (jsonObject.containsKey("error")) { + throw new AuthException(jsonObject.getString("error_description").concat(" ") + jsonObject.getString("error_description")); + } + } + + /** + * https://developer.amazon.com/zh/docs/login-with-amazon/obtain-customer-profile.html#call-profile-endpoint + * + * @param authToken token信息 + * @return AuthUser + */ + @Override + protected AuthUser getUserInfo(AuthToken authToken) { + String accessToken = authToken.getAccessToken(); + this.checkToken(accessToken); + + HttpHeader httpHeader = new HttpHeader(); + httpHeader.add("Host", "api.amazon.com"); + httpHeader.add("Authorization", "bearer " + accessToken); + String userInfo = new HttpUtils(config.getHttpConfig()).get(this.source.userInfo(), new HashMap<>(0), httpHeader, false); + JSONObject jsonObject = JSONObject.parseObject(userInfo); + this.checkResponse(jsonObject); + + return AuthUser.builder() + .rawUserInfo(jsonObject) + .uuid(jsonObject.getString("user_id")) + .username(jsonObject.getString("name")) + .nickname(jsonObject.getString("name")) + .email(jsonObject.getString("email")) + .gender(AuthUserGender.UNKNOWN) + .source(source.toString()) + .token(authToken) + .build(); + } + + private void checkToken(String accessToken) { + String tokenInfo = new HttpUtils(config.getHttpConfig()).get("https://api.amazon.com/auth/o2/tokeninfo?access_token=" + UrlUtil.urlEncode(accessToken)); + JSONObject jsonObject = JSONObject.parseObject(tokenInfo); + if (!config.getClientId().equals(jsonObject.getString("aud"))) { + throw new AuthException(AuthResponseStatus.ILLEGAL_TOKEN); + } + } + + @Override + protected String userInfoUrl(AuthToken authToken) { + return UrlBuilder.fromBaseUrl(source.userInfo()) + .queryParam("user_id", authToken.getUserId()) + .queryParam("screen_name", authToken.getScreenName()) + .queryParam("include_entities", true) + .build(); + } +} diff --git a/src/main/java/me/zhyd/oauth/utils/PkceUtil.java b/src/main/java/me/zhyd/oauth/utils/PkceUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..22fe9a4fa59724aa709e4fd344421fa5fe06b8e6 --- /dev/null +++ b/src/main/java/me/zhyd/oauth/utils/PkceUtil.java @@ -0,0 +1,39 @@ +package me.zhyd.oauth.utils; + +import java.nio.charset.StandardCharsets; + +/** + * 该配置仅用于支持 PKCE 模式的平台,针对无服务应用,不推荐使用隐式授权,推荐使用 PKCE 模式 + * + * @author yadong.zhang (yadong.zhang0415(a)gmail.com) + * @version 1.0.0 + * @since 1.0.0 + */ +public class PkceUtil { + + public static String generateCodeVerifier() { + String randomStr = RandomUtil.randomString(50); + return Base64Utils.encodeUrlSafe(randomStr); + } + + /** + * 适用于 OAuth 2.0 PKCE 增强协议 + * + * @param codeChallengeMethod s256 / plain + * @param codeVerifier 客户端生产的校验码 + * @return code challenge + */ + public static String generateCodeChallenge(String codeChallengeMethod, String codeVerifier) { + if ("S256".equalsIgnoreCase(codeChallengeMethod)) { + // https://tools.ietf.org/html/rfc7636#section-4.2 + // code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) + return newStringUsAscii(Base64Utils.encodeUrlSafe(Sha256.digest(codeVerifier), true)); + } else { + return codeVerifier; + } + } + + public static String newStringUsAscii(byte[] bytes) { + return new String(bytes, StandardCharsets.US_ASCII); + } +} diff --git a/src/main/java/me/zhyd/oauth/utils/RandomUtil.java b/src/main/java/me/zhyd/oauth/utils/RandomUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..e68a2c6a52e3b5dea8a6a6c21c313543db69fea5 --- /dev/null +++ b/src/main/java/me/zhyd/oauth/utils/RandomUtil.java @@ -0,0 +1,38 @@ +package me.zhyd.oauth.utils; + +import java.util.concurrent.ThreadLocalRandom; + +/** + * 生成随机字符串 + * + * @author yadong.zhang (yadong.zhang0415(a)gmail.com) + * @version 1.0.0 + * @since 1.16.0 + */ +public class RandomUtil { + + /** + * 用于随机选的字符和数字 + */ + public static final String BASE_CHAR_NUMBER = "abcdefghijklmnopqrstuvwxyz0123456789"; + + /** + * 获得一个随机的字符串 + * + * @param length 字符串的长度 + * @return 指定长度的随机字符串 + */ + public static String randomString(int length) { + final StringBuilder sb = new StringBuilder(length); + + if (length < 1) { + length = 1; + } + int baseLength = BASE_CHAR_NUMBER.length(); + for (int i = 0; i < length; i++) { + int number = ThreadLocalRandom.current().nextInt(baseLength); + sb.append(BASE_CHAR_NUMBER.charAt(number)); + } + return sb.toString(); + } +} diff --git a/src/main/java/me/zhyd/oauth/utils/Sha256.java b/src/main/java/me/zhyd/oauth/utils/Sha256.java new file mode 100644 index 0000000000000000000000000000000000000000..fd83648fe7b79dc71c3e73f9b5d64bd47b2bd59d --- /dev/null +++ b/src/main/java/me/zhyd/oauth/utils/Sha256.java @@ -0,0 +1,27 @@ +package me.zhyd.oauth.utils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * SHA256 加密 + * + * @author yadong.zhang (yadong.zhang0415(a)gmail.com) + * @version 1.0.0 + * @since 1.16.0 + */ +public class Sha256 { + + public static byte[] digest(String str) { + MessageDigest messageDigest; + try { + messageDigest = MessageDigest.getInstance("SHA-256"); + messageDigest.update(str.getBytes(StandardCharsets.UTF_8)); + return messageDigest.digest(); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } + return null; + } +}