Skip to content

Commit

Permalink
Merge pull request #192 from G-XD/feat_apple
Browse files Browse the repository at this point in the history
feat: sign with apple
  • Loading branch information
zhangyd-c authored Aug 3, 2024
2 parents 6b758e0 + 988d3cd commit 2ee2483
Show file tree
Hide file tree
Showing 13 changed files with 296 additions and 16 deletions.
22 changes: 22 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
<fastjson-version>1.2.83</fastjson-version>
<alipay-sdk-version>4.17.5.ALL</alipay-sdk-version>
<jacoco-version>0.8.2</jacoco-version>
<jwt.version>0.12.3</jwt.version>
<bcpkix-jdk18on.version>1.77</bcpkix-jdk18on.version>
</properties>

<dependencies>
Expand Down Expand Up @@ -100,6 +102,26 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>${bcpkix-jdk18on.version}</version>
</dependency>
</dependencies>

<build>
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/me/zhyd/oauth/config/AuthConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,16 @@ public String getAuthServerId() {
* Microsoft Entra ID(原微软 AAD)中的租户 ID
*/
private String tenantId;

/**
* 苹果开发者账号中的密钥标识符
* @see <a href="https://developer.apple.com/help/account/configure-app-capabilities/create-a-sign-in-with-apple-private-key/">create-a-sign-in-with-apple-private-key</a>
*/
private String kid;

/**
* 苹果开发者账号中的团队ID
* @see <a href="https://developer.apple.com/help/glossary/team-id/">team id</a>
*/
private String teamId;
}
25 changes: 25 additions & 0 deletions src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java
Original file line number Diff line number Diff line change
Expand Up @@ -1296,6 +1296,31 @@ public String userInfo() {
public Class<? extends AuthDefaultRequest> getTargetClass() {
return AuthProginnRequest.class;
}
},

APPLE {
@Override
public String authorize() {
return "https://appleid.apple.com/auth/authorize";
}

/**
* @see <a href="https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens">generate_and_validate_tokens</a>
*/
@Override
public String accessToken() {
return "https://appleid.apple.com/auth/token";
}

@Override
public String userInfo() {
return "";
}

@Override
public Class<? extends AuthDefaultRequest> getTargetClass() {
return AuthAppleRequest.class;
}
}

}
4 changes: 4 additions & 0 deletions src/main/java/me/zhyd/oauth/enums/AuthResponseStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/me/zhyd/oauth/enums/scope/AuthAppleScope.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package me.zhyd.oauth.enums.scope;

import lombok.AllArgsConstructor;
import lombok.Getter;

/**
* @see <a href="https://developer.apple.com/documentation/sign_in_with_apple/clientconfigi/3230955-scope/">scope</a>
*/
@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;
}
11 changes: 11 additions & 0 deletions src/main/java/me/zhyd/oauth/model/AuthCallback.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,15 @@ public class AuthCallback implements Serializable {
*/
private String oauth_verifier;

/**
* 苹果仅在用户首次授权应用程序时返回此值。如果您的应用程序已经获得了用户的授权,那么苹果将不会再次返回此值
* @see <a href="https://developer.apple.com/documentation/sign_in_with_apple/useri">user info</a>
*/
private String user;

/**
* 苹果错误信息,仅在用户取消授权时返回此值
* @see <a href="https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms">error response</a>
*/
private String error;
}
4 changes: 4 additions & 0 deletions src/main/java/me/zhyd/oauth/model/AuthToken.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,8 @@ public class AuthToken implements Serializable {
private String screenName;
private Boolean oauthCallbackConfirmed;

/**
* Apple附带属性
*/
private String username;
}
156 changes: 156 additions & 0 deletions src/main/java/me/zhyd/oauth/request/AuthAppleRequest.java
Original file line number Diff line number Diff line change
@@ -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 <a href="https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret">creating-a-client-secret</a>
* @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;
}
}
6 changes: 5 additions & 1 deletion src/main/java/me/zhyd/oauth/request/AuthDefaultRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public AuthDefaultRequest(AuthConfig config, AuthSource source, AuthStateCache a
throw new AuthException(AuthResponseStatus.PARAMETER_INCOMPLETE, source);
}
// 校验配置合法性
AuthChecker.checkConfig(config, source);
this.checkConfig(config);
}

/**
Expand Down Expand Up @@ -295,4 +295,8 @@ protected String getScopes(String separator, boolean encode, List<String> defaul
return encode ? UrlUtil.urlEncode(scopeStr) : scopeStr;
}

protected void checkConfig(AuthConfig config) {
AuthChecker.checkConfig(config, source);
}

}
12 changes: 12 additions & 0 deletions src/main/java/me/zhyd/oauth/request/AuthFacebookRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
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;
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;

/**
Expand Down Expand Up @@ -87,6 +89,16 @@ protected String userInfoUrl(AuthToken authToken) {
.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);
}
}

/**
* 检查响应内容是否正确
*
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/me/zhyd/oauth/request/AuthMicrosoftCnRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
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;

/**
* 微软中国登录(世纪华联)
Expand All @@ -20,4 +23,14 @@ public AuthMicrosoftCnRequest(AuthConfig config, AuthStateCache authStateCache)
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);
}
}

}
13 changes: 13 additions & 0 deletions src/main/java/me/zhyd/oauth/request/AuthMicrosoftRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
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;

/**
* 微软登录
Expand All @@ -21,4 +24,14 @@ public AuthMicrosoftRequest(AuthConfig config, AuthStateCache authStateCache) {
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);
}
}

}
Loading

0 comments on commit 2ee2483

Please sign in to comment.