diff --git a/pom.xml b/pom.xml index 44029f5..561c151 100644 --- a/pom.xml +++ b/pom.xml @@ -63,6 +63,8 @@ 1.2.83 4.17.5.ALL 0.8.2 + 0.12.3 + 1.77 @@ -100,6 +102,26 @@ + + io.jsonwebtoken + jjwt-api + ${jwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jwt.version} + + + io.jsonwebtoken + jjwt-jackson + ${jwt.version} + + + org.bouncycastle + bcpkix-jdk18on + ${bcpkix-jdk18on.version} + diff --git a/src/main/java/me/zhyd/oauth/config/AuthConfig.java b/src/main/java/me/zhyd/oauth/config/AuthConfig.java index 084649c..fb3b4bd 100644 --- a/src/main/java/me/zhyd/oauth/config/AuthConfig.java +++ b/src/main/java/me/zhyd/oauth/config/AuthConfig.java @@ -186,4 +186,16 @@ public class AuthConfig { * Microsoft Entra ID(原微软 AAD)中的租户 ID */ private String tenantId; + + /** + * 苹果开发者账号中的密钥标识符 + * @see create-a-sign-in-with-apple-private-key + */ + private String kid; + + /** + * 苹果开发者账号中的团队ID + * @see team id + */ + private String teamId; } diff --git a/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java b/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java index 35b835a..525e008 100644 --- a/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java +++ b/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java @@ -1296,6 +1296,31 @@ public enum AuthDefaultSource implements AuthSource { public Class getTargetClass() { return AuthProginnRequest.class; } + }, + + APPLE { + @Override + public String authorize() { + return "https://appleid.apple.com/auth/authorize"; + } + + /** + * @see generate_and_validate_tokens + */ + @Override + public String accessToken() { + return "https://appleid.apple.com/auth/token"; + } + + @Override + public String userInfo() { + return ""; + } + + @Override + public Class getTargetClass() { + return AuthAppleRequest.class; + } } } diff --git a/src/main/java/me/zhyd/oauth/enums/AuthResponseStatus.java b/src/main/java/me/zhyd/oauth/enums/AuthResponseStatus.java index 01e7e08..5cd6c2b 100644 --- a/src/main/java/me/zhyd/oauth/enums/AuthResponseStatus.java +++ b/src/main/java/me/zhyd/oauth/enums/AuthResponseStatus.java @@ -29,6 +29,10 @@ public enum AuthResponseStatus { ILLEGAL_STATUS(5009, "Illegal state"), REQUIRED_REFRESH_TOKEN(5010, "The refresh token is required; it must not be null"), ILLEGAL_TOKEN(5011, "Invalid token"), + ILLEGAL_KID(5012, "Invalid key identifier(kid)"), + ILLEGAL_TEAM_ID(5013, "Invalid team id"), + ILLEGAL_CLIENT_ID(5014, "Invalid client id"), + ILLEGAL_CLIENT_SECRET(5015, "Invalid client secret"), ; private final int code; diff --git a/src/main/java/me/zhyd/oauth/enums/scope/AuthAppleScope.java b/src/main/java/me/zhyd/oauth/enums/scope/AuthAppleScope.java new file mode 100644 index 0000000..a6e5b44 --- /dev/null +++ b/src/main/java/me/zhyd/oauth/enums/scope/AuthAppleScope.java @@ -0,0 +1,19 @@ +package me.zhyd.oauth.enums.scope; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @see scope + */ +@Getter +@AllArgsConstructor +public enum AuthAppleScope implements AuthScope { + EMAIL("email", "用户邮箱", true), + NAME("name", "用户名", true), + ; + + private final String scope; + private final String description; + private final boolean isDefault; +} diff --git a/src/main/java/me/zhyd/oauth/model/AuthCallback.java b/src/main/java/me/zhyd/oauth/model/AuthCallback.java index 39c030b..bce57c8 100644 --- a/src/main/java/me/zhyd/oauth/model/AuthCallback.java +++ b/src/main/java/me/zhyd/oauth/model/AuthCallback.java @@ -57,4 +57,15 @@ public class AuthCallback implements Serializable { */ private String oauth_verifier; + /** + * 苹果仅在用户首次授权应用程序时返回此值。如果您的应用程序已经获得了用户的授权,那么苹果将不会再次返回此值 + * @see user info + */ + private String user; + + /** + * 苹果错误信息,仅在用户取消授权时返回此值 + * @see error response + */ + private String error; } diff --git a/src/main/java/me/zhyd/oauth/model/AuthToken.java b/src/main/java/me/zhyd/oauth/model/AuthToken.java index eb4c42b..321da54 100644 --- a/src/main/java/me/zhyd/oauth/model/AuthToken.java +++ b/src/main/java/me/zhyd/oauth/model/AuthToken.java @@ -62,4 +62,8 @@ public class AuthToken implements Serializable { private String screenName; private Boolean oauthCallbackConfirmed; + /** + * Apple附带属性 + */ + private String username; } diff --git a/src/main/java/me/zhyd/oauth/request/AuthAppleRequest.java b/src/main/java/me/zhyd/oauth/request/AuthAppleRequest.java new file mode 100644 index 0000000..20b41da --- /dev/null +++ b/src/main/java/me/zhyd/oauth/request/AuthAppleRequest.java @@ -0,0 +1,156 @@ +package me.zhyd.oauth.request; + +import com.alibaba.fastjson.JSONObject; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.impl.security.AbstractJwk; +import lombok.Data; +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.scope.AuthAppleScope; +import me.zhyd.oauth.exception.AuthException; +import me.zhyd.oauth.model.AuthCallback; +import me.zhyd.oauth.model.AuthToken; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.utils.AuthScopeUtils; +import me.zhyd.oauth.utils.StringUtils; +import me.zhyd.oauth.utils.UrlBuilder; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; + +import java.io.IOException; +import java.io.StringReader; +import java.security.PrivateKey; +import java.util.Base64; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +public class AuthAppleRequest extends AuthDefaultRequest { + + private static final String AUD = "https://appleid.apple.com"; + + private volatile PrivateKey privateKey; + + public AuthAppleRequest(AuthConfig config) { + super(config, AuthDefaultSource.APPLE); + } + + public AuthAppleRequest(AuthConfig config, AuthStateCache authStateCache) { + super(config, AuthDefaultSource.APPLE, authStateCache); + } + + @Override + public String authorize(String state) { + return UrlBuilder.fromBaseUrl(super.authorize(state)) + .queryParam("response_mode", "form_post") + .queryParam("scope", this.getScopes(" ", false, AuthScopeUtils.getDefaultScopes(AuthAppleScope.values()))) + .build(); + } + + @Override + protected AuthToken getAccessToken(AuthCallback authCallback) { + if (!StringUtils.isEmpty(authCallback.getError())) { + throw new AuthException(authCallback.getError()); + } + this.config.setClientSecret(this.getToken()); + // if failed will throw AuthException + String response = doPostAuthorizationCode(authCallback.getCode()); + JSONObject accessTokenObject = JSONObject.parseObject(response); + // https://developer.apple.com/documentation/sign_in_with_apple/tokenresponse + AuthToken.AuthTokenBuilder builder = AuthToken.builder() + .accessToken(accessTokenObject.getString("access_token")) + .expireIn(accessTokenObject.getIntValue("expires_in")) + .refreshToken(accessTokenObject.getString("refresh_token")) + .tokenType(accessTokenObject.getString("token_type")) + .idToken(accessTokenObject.getString("id_token")); + if (!StringUtils.isEmpty(authCallback.getUser())) { + try { + AppleUserInfo userInfo = JSONObject.parseObject(authCallback.getUser(), AppleUserInfo.class); + builder.username(userInfo.getName().getFirstName() + " " + userInfo.getName().getLastName()); + } catch (Exception ignored) { + } + } + return builder.build(); + } + + @Override + protected AuthUser getUserInfo(AuthToken authToken) { + Base64.Decoder urlDecoder = Base64.getUrlDecoder(); + String[] idToken = authToken.getIdToken().split("\\."); + String payload = new String(urlDecoder.decode(idToken[1])); + JSONObject object = JSONObject.parseObject(payload); + // https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple#3383773 + return AuthUser.builder() + .rawUserInfo(object) + .uuid(object.getString("sub")) + .email(object.getString("email")) + .username(authToken.getUsername()) + .token(authToken) + .source(source.toString()) + .build(); + } + + @Override + protected void checkConfig(AuthConfig config) { + super.checkConfig(config); + if (StringUtils.isEmpty(config.getClientId())) { + throw new AuthException(AuthResponseStatus.ILLEGAL_CLIENT_ID, source); + } + if (StringUtils.isEmpty(config.getClientSecret())) { + throw new AuthException(AuthResponseStatus.ILLEGAL_CLIENT_SECRET, source); + } + if (StringUtils.isEmpty(config.getKid())) { + throw new AuthException(AuthResponseStatus.ILLEGAL_KID, source); + } + if (StringUtils.isEmpty(config.getTeamId())) { + throw new AuthException(AuthResponseStatus.ILLEGAL_TEAM_ID, source); + } + } + + /** + * 获取token + * @see creating-a-client-secret + * @return jwt token + */ + private String getToken() { + return Jwts.builder().header().add(AbstractJwk.KID.getId(), this.config.getKid()).and() + .issuer(this.config.getTeamId()) + .subject(this.config.getClientId()) + .audience().add(AUD).and() + .expiration(new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(3))) + .issuedAt(new Date()) + .signWith(getPrivateKey()) + .compact(); + } + + private PrivateKey getPrivateKey() { + if (this.privateKey == null) { + synchronized (this) { + if (this.privateKey == null) { + try (PEMParser pemParser = new PEMParser(new StringReader(this.config.getClientSecret()))) { + JcaPEMKeyConverter pemKeyConverter = new JcaPEMKeyConverter(); + PrivateKeyInfo keyInfo = (PrivateKeyInfo) pemParser.readObject(); + this.privateKey = pemKeyConverter.getPrivateKey(keyInfo); + } catch (IOException e) { + throw new AuthException("Failed to get apple private key", e); + } + } + } + } + return this.privateKey; + } + + @Data + static class AppleUserInfo { + private AppleUsername name; + private String email; + } + + @Data + static class AppleUsername { + private String firstName; + private String lastName; + } +} diff --git a/src/main/java/me/zhyd/oauth/request/AuthDefaultRequest.java b/src/main/java/me/zhyd/oauth/request/AuthDefaultRequest.java index 0e2e8a4..26384c4 100644 --- a/src/main/java/me/zhyd/oauth/request/AuthDefaultRequest.java +++ b/src/main/java/me/zhyd/oauth/request/AuthDefaultRequest.java @@ -40,7 +40,7 @@ public abstract class AuthDefaultRequest implements AuthRequest { throw new AuthException(AuthResponseStatus.PARAMETER_INCOMPLETE, source); } // 校验配置合法性 - AuthChecker.checkConfig(config, source); + this.checkConfig(config); } /** @@ -295,4 +295,8 @@ public abstract class AuthDefaultRequest implements AuthRequest { return encode ? UrlUtil.urlEncode(scopeStr) : scopeStr; } + protected void checkConfig(AuthConfig config) { + AuthChecker.checkConfig(config, source); + } + } diff --git a/src/main/java/me/zhyd/oauth/request/AuthFacebookRequest.java b/src/main/java/me/zhyd/oauth/request/AuthFacebookRequest.java index e9897a8..597acdb 100644 --- a/src/main/java/me/zhyd/oauth/request/AuthFacebookRequest.java +++ b/src/main/java/me/zhyd/oauth/request/AuthFacebookRequest.java @@ -4,6 +4,7 @@ import com.alibaba.fastjson.JSONObject; 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.AuthFacebookScope; import me.zhyd.oauth.exception.AuthException; @@ -11,6 +12,7 @@ import me.zhyd.oauth.model.AuthCallback; import me.zhyd.oauth.model.AuthToken; import me.zhyd.oauth.model.AuthUser; import me.zhyd.oauth.utils.AuthScopeUtils; +import me.zhyd.oauth.utils.GlobalAuthUtils; import me.zhyd.oauth.utils.UrlBuilder; /** @@ -87,6 +89,16 @@ public class AuthFacebookRequest extends AuthDefaultRequest { .build(); } + @Override + protected void checkConfig(AuthConfig config) { + super.checkConfig(config); + // facebook的回调地址必须为https的链接 + if (AuthDefaultSource.FACEBOOK == source && !GlobalAuthUtils.isHttpsProtocol(config.getRedirectUri())) { + // Facebook's redirect uri must use the HTTPS protocol + throw new AuthException(AuthResponseStatus.ILLEGAL_REDIRECT_URI, source); + } + } + /** * 检查响应内容是否正确 * diff --git a/src/main/java/me/zhyd/oauth/request/AuthMicrosoftCnRequest.java b/src/main/java/me/zhyd/oauth/request/AuthMicrosoftCnRequest.java index eccd606..73bb054 100644 --- a/src/main/java/me/zhyd/oauth/request/AuthMicrosoftCnRequest.java +++ b/src/main/java/me/zhyd/oauth/request/AuthMicrosoftCnRequest.java @@ -3,6 +3,9 @@ package me.zhyd.oauth.request; 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.exception.AuthException; +import me.zhyd.oauth.utils.GlobalAuthUtils; /** * 微软中国登录(世纪华联) @@ -20,4 +23,14 @@ public class AuthMicrosoftCnRequest extends AbstractAuthMicrosoftRequest { super(config, AuthDefaultSource.MICROSOFT_CN, authStateCache); } + @Override + protected void checkConfig(AuthConfig config) { + super.checkConfig(config); + // 微软中国的回调地址必须为https的链接或者localhost,不允许使用http + if (AuthDefaultSource.MICROSOFT_CN == source && !GlobalAuthUtils.isHttpsProtocolOrLocalHost(config.getRedirectUri())) { + // Microsoft's redirect uri must use the HTTPS or localhost + throw new AuthException(AuthResponseStatus.ILLEGAL_REDIRECT_URI, source); + } + } + } diff --git a/src/main/java/me/zhyd/oauth/request/AuthMicrosoftRequest.java b/src/main/java/me/zhyd/oauth/request/AuthMicrosoftRequest.java index 545f5e2..ac657ee 100644 --- a/src/main/java/me/zhyd/oauth/request/AuthMicrosoftRequest.java +++ b/src/main/java/me/zhyd/oauth/request/AuthMicrosoftRequest.java @@ -3,6 +3,9 @@ package me.zhyd.oauth.request; 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.exception.AuthException; +import me.zhyd.oauth.utils.GlobalAuthUtils; /** * 微软登录 @@ -21,4 +24,14 @@ public class AuthMicrosoftRequest extends AbstractAuthMicrosoftRequest { super(config, AuthDefaultSource.MICROSOFT, authStateCache); } + @Override + protected void checkConfig(AuthConfig config) { + super.checkConfig(config); + // 微软的回调地址必须为https的链接或者localhost,不允许使用http + if (AuthDefaultSource.MICROSOFT == source && !GlobalAuthUtils.isHttpsProtocolOrLocalHost(config.getRedirectUri())) { + // Microsoft's redirect uri must use the HTTPS or localhost + throw new AuthException(AuthResponseStatus.ILLEGAL_REDIRECT_URI, source); + } + } + } diff --git a/src/main/java/me/zhyd/oauth/utils/AuthChecker.java b/src/main/java/me/zhyd/oauth/utils/AuthChecker.java index 2737d4c..74c6095 100644 --- a/src/main/java/me/zhyd/oauth/utils/AuthChecker.java +++ b/src/main/java/me/zhyd/oauth/utils/AuthChecker.java @@ -63,21 +63,6 @@ public class AuthChecker { if (!GlobalAuthUtils.isHttpProtocol(redirectUri) && !GlobalAuthUtils.isHttpsProtocol(redirectUri)) { throw new AuthException(AuthResponseStatus.ILLEGAL_REDIRECT_URI, source); } - // facebook的回调地址必须为https的链接 - if (AuthDefaultSource.FACEBOOK == source && !GlobalAuthUtils.isHttpsProtocol(redirectUri)) { - // Facebook's redirect uri must use the HTTPS protocol - throw new AuthException(AuthResponseStatus.ILLEGAL_REDIRECT_URI, source); - } - // 微软的回调地址必须为https的链接或者localhost,不允许使用http - if (AuthDefaultSource.MICROSOFT == source && !GlobalAuthUtils.isHttpsProtocolOrLocalHost(redirectUri)) { - // Microsoft's redirect uri must use the HTTPS or localhost - throw new AuthException(AuthResponseStatus.ILLEGAL_REDIRECT_URI, source); - } - // 微软中国的回调地址必须为https的链接或者localhost,不允许使用http - if (AuthDefaultSource.MICROSOFT_CN == source && !GlobalAuthUtils.isHttpsProtocolOrLocalHost(redirectUri)) { - // Microsoft's redirect uri must use the HTTPS or localhost - throw new AuthException(AuthResponseStatus.ILLEGAL_REDIRECT_URI, source); - } } /**