使用 Apple Java User Verification 登录

2022-09-03 12:28:55

我已经实现了新的Apple功能“使用Apple登录”的应用程序端,但我无法在后端使用授权代码进行验证。我的后端是用java编写的,我无法生成JWT并与Apple服务器通信。


答案 1

首先 developer.apple.com->证书,标识符和配置文件->密钥。为 Apple 登录生成密钥并下载此密钥。您无法再次下载此密钥,因此请将其保存在安全的地方,并且不要与他人共享。此外,此处显示的密钥 ID 请注意此内容,稍后需要用到它。您还需要团队 ID。如果你不知道它,它写在页面的右上角,如YOURDNAME - XX0XX00XXX。

您基本上将遵循以下步骤。

1.从您的密钥生成 JWT

2.使用令牌发送身份验证代码

3.解码响应

同时使用 Web 和移动设备的更新

如果您想使用Apple登录网络,则需要执行其他几个步骤。

4. 为网站添加新的标识符

转到 developer.apple.com ->证书,标识符和配置文件 ->标识符。通过单击加号按钮注册新标识符。选择“服务 ID”并继续。提供说明和标识符。标识符必须是唯一的,并且与您的捆绑 ID 不同(例如,您可以使用 com.your.bundle.id.web)。单击继续单击注册。然后选择“服务 ID”(它位于右上角的搜索图标附近),下面列出的新创建的服务 ID 单击它并
启用“使用 Apple 登录”复选框。然后,您需要配置您的域。提供您的域名并返回网址。

对于Web,如果您忘记传递有效的redirect_url或尝试多次使用相同的authorization_code,则可能会遇到invalid_grant错误。


public class AppleLoginUtil {
    private static String APPLE_AUTH_URL = "https://appleid.apple.com/auth/token";

    private static String KEY_ID = "**********";
    private static String TEAM_ID = "**********";
    private static String CLIENT_ID = "com.your.bundle.id";
    private static String WEB_CLIENT_ID = "com.your.bundle.id.web";
    private static String WEB_REDIRECT_URL = "https://bundle.your.com/";

    private static PrivateKey pKey;

    private static PrivateKey getPrivateKey() throws Exception {
    //read your key
        String path = new ClassPathResource("apple/AuthKey.p8").getFile().getAbsolutePath();

        final PEMParser pemParser = new PEMParser(new FileReader(path));
        final JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
        final PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject();
        final PrivateKey pKey = converter.getPrivateKey(object);

        return pKey;
    }

    private static String generateJWT() throws Exception {
        if (pKey == null) {
            pKey = getPrivateKey();
        }

        String token = Jwts.builder()
                .setHeaderParam(JwsHeader.KEY_ID, KEY_ID)
                .setIssuer(TEAM_ID)
                .setAudience("https://appleid.apple.com")
                .setSubject(CLIENT_ID)
                .setExpiration(new Date(System.currentTimeMillis() + (1000 * 60 * 5)))
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .signWith(pKey, SignatureAlgorithm.ES256)
                .compact();

        return token;
    }

    private static String generateWebJWT() throws Exception {
        String token = Jwts.builder()
                .setHeaderParam(JwsHeader.KEY_ID, KEY_ID)
                .setIssuer(TEAM_ID)
                .setAudience("https://appleid.apple.com")
                .setSubject(WEB_CLIENT_ID)
                .setExpiration(new Date(System.currentTimeMillis() + (1000 * 60 * 5)))
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .signWith(getPrivateKey(), SignatureAlgorithm.ES256)
                .compact();

        return token;
    }


    /*
    * Returns unique user id from apple
    * */
    public static String appleAuth(String authorizationCode, boolean forWeb) throws Exception {
        HttpResponse<String> response = Unirest.post(APPLE_AUTH_URL)
                .header("Content-Type", "application/x-www-form-urlencoded")
                .field("client_id", forWeb ? WEB_CLIENT_ID : CLIENT_ID)
                .field("client_secret", forWeb ? generateWebJWT() : generateJWT())
                .field("grant_type", "authorization_code")
                .field("code", authorizationCode)
                .field("redirect_uri", forWeb ? WEB_REDIRECT_URL : null)
                .asString();


        TokenResponse tokenResponse=new Gson().fromJson(response.getBody(),TokenResponse.class);
        String idToken = tokenResponse.getId_token();
        String payload = idToken.split("\\.")[1];//0 is header we ignore it for now
        String decoded = new String(Decoders.BASE64.decode(payload));

        IdTokenPayload idTokenPayload = new Gson().fromJson(decoded,IdTokenPayload.class);

       return idTokenPayload.getSub();
    }

}

我使用BouncyCastle jjwt来生成令牌。还有unirest和gson用于休息电话。

 <!-- https://mvnrepository.com/artifact/org.bouncycastle/bcpkix-jdk15on -->
    <dependency>
        <groupId>org.bouncycastle</groupId>
        <artifactId>bcpkix-jdk15on</artifactId>
        <version>1.63</version>
    </dependency>

<!--JJWT-->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.10.7</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.10.7</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.10.7</version>
        <scope>runtime</scope>
    </dependency>

<!--UNIREST-->
    <dependency>
        <groupId>com.mashape.unirest</groupId>
        <artifactId>unirest-java</artifactId>
        <version>1.4.9</version>
    </dependency>
    <dependency>
        <groupId>org.apache.httpcomponents</groupId>
        <artifactId>httpclient</artifactId>
        <version>4.3.6</version>
    </dependency>
    <dependency>
        <groupId>org.apache.httpcomponents</groupId>
        <artifactId>httpasyncclient</artifactId>
        <version>4.0.2</version>
    </dependency>
    <dependency>
        <groupId>org.apache.httpcomponents</groupId>
        <artifactId>httpmime</artifactId>
        <version>4.3.6</version>
    </dependency>
    <dependency>
        <groupId>org.json</groupId>
        <artifactId>json</artifactId>
        <version>20140107</version>
    </dependency>

如果您想知道,我还解析了对这些类的响应。

public class TokenResponse {

    private String access_token;
    private String token_type;
    private Long expires_in;
    private String refresh_token;
    private String id_token;

    ..getters and setters
}


public class IdTokenPayload {

    private String iss;
    private String aud;
    private Long exp;
    private Long iat;
    private String sub;//users unique id
    private String at_hash;
    private Long auth_time;
    private Boolean nonce_supported;
    private Boolean email_verified;
    private String email;

    ..getters and setters
}

答案 2

我也有一个错误,但经过一些调整,它的工作,在下面找到我的调整,注意,它在kotlin中

   private suspend fun getPrivateKey(): Status {

    return awaitBlocking {
        val authKeyFile = appleConfig.getString("auth_private_key_file", "")

        val authTokenFilePath = getDataDir()!!.resolve(authKeyFile).absolutePath

        val pemParser = PEMParser(FileReader(authTokenFilePath))
        val converter = JcaPEMKeyConverter()
        val obj = pemParser.readObject() as PrivateKeyInfo

        val privateKey = converter.getPrivateKey(obj)

        successStatus(data = privateKey)
    }
}


 /**
 * generateSecretKey
 */
suspend fun generateSecretKey() : Status{

    val getAuthPrivateKey = getPrivateKey()

    if(getAuthPrivateKey.isError()){
        logger.fatal(getAuthPrivateKey.message)
        return errorStatus("system_busy")
    }

    val privateKeyData =  getAuthPrivateKey.getData<PrivateKey>()

    val clientId = "com.company.app"

    //team id found in apple developer portal
    val teamId = appleConfig.getString("team_id","")

    //apple sign in key ID found in app developer portal
    val authKeyId = appleConfig.getString("auth_key_id","")


    val header = mutableMapOf<String,Any>(
            "alg" to "E256",
            "kid" to authKeyId
    )

    val now = Instant.now().epochSecond

    val claims = mutableMapOf<String,Any>(
            "iss"   to teamId,
            "iat"  to now,
            "exp"  to now + 86400*180,
            "aud"  to "https://appleid.apple.com",
            "sub" to clientId
    )

    println("header - $header")
    println("claims - $claims")

   val token = Jwts.builder()
                .setHeader(header)
                .setClaims(claims)
                .signWith(privateKeyData,SignatureAlgorithm.ES256)
                .compact();


    return successStatus(data = token)
} //end fun





    /**
     * fetchApplePublicKeys
     */
private suspend fun fetchAccessToken(authInfo: JsonObject): Status {
    return  try{

        val authCode = authInfo.getString("auth_code")
        val clientIdToken = authInfo.getString("id_token")

        val accessTokenEndpoint = 
appleConfig.getString("access_token_endpoint")

        val secretKeyTokenStatus = generateSecretKey()

        if(secretKeyTokenStatus.isError()){
            logger.fatal(secretKeyTokenStatus.message)
            return errorStatus("system_busy")
        }

        val clientSecret = secretKeyTokenStatus.getData<String>()

        val redirectUrl = ""

        val clientId = appleConfig.getString("client_id")


        val formData = MultiMap.caseInsensitiveMultiMap()

        formData.add("client_secret",clientSecret)
                .add("client_id",clientId)
                .add("redirect_uri",redirectUrl)
                .add("grant_type","authorization_code")
                .add("code",authCode)

        println("accessTokenEndpoint - $accessTokenEndpoint")
        println("formData - $formData")

        val responseData  = httpClient(this::class)
                .postAbs(accessTokenEndpoint)
                .putHeader("Content-Type","application/x-www-form-urlencoded")
                .sendFormAwait(formData)
                .bodyAsJsonObject()

        println("responseData - ${responseData}")

        if(responseData.containsKey("error")){
            logger.fatal(responseData.getString("error"))
            return errorStatus("social_auth_failed")
        }

        //val responseIdToken = responseData.getString("id_token","")

       return successStatus(data = responseData)
    } catch (e: Exception){
        logger.fatal(e.message,e)
        errorStatus("system_busy")
    }
}