Spring Boot 为Apple API请求生成令牌

4ktjp1zp  于 5个月前  发布在  Spring
关注(0)|答案(3)|浏览(84)

我创建这个类来访问Apple API请求

@Transactional(readOnly = true)
public class AppleAPIService {

    public static void main(String[] args) {

            Path privateKeyPath = Paths.get("/Users/ricardolle/IdeaProjects/mystic-planets-api/src/main/resources/cert/AuthKey_5425KFDYSC.p8");

    String keyContent = new String(Files.readAllBytes(privateKeyPath), StandardCharsets.UTF_8);
    System.out.println("Original Key Content: " + keyContent); // Logging the original content
    keyContent = keyContent.replace("-----BEGIN PRIVATE KEY-----", "")
            .replace("-----END PRIVATE KEY-----", "")
            .replaceAll("\\s+", ""); // Remove all whitespaces and newlines, more robust than just replacing \n
    System.out.println("Processed Key Content: " + keyContent); // Logging processed content
    byte[] decodedKey = Base64.getDecoder().decode(keyContent);
    PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decodedKey);
    KeyFactory kf = KeyFactory.getInstance("EC");
    PrivateKey pk =  kf.generatePrivate(spec);

    Map<String, Object> headerMap = new HashMap<>();

    headerMap.put("alg", "ES256"); // Algorithm, e.g., RS256 for asymmetric signing
    headerMap.put("kid", "5425KFDYSC"); // Algorithm, e.g., RS256 for asymmetric signing
    headerMap.put("typ", "JWT"); //
    
    String issuer = "68a6Se82-111e-47e3-e053-5b8c7c11a4d1"; // Replace with your issuer
    //String subject = "subject"; // Replace with your subject
    long nowMillis = System.currentTimeMillis();
    Date issuedAt = new Date(nowMillis);
    Date expiration = new Date(nowMillis + 3600000); // Expiration time (1 hour in this example)

    JwtBuilder jwtBuilder = Jwts.builder()
            .setHeader(headerMap)
            .setIssuer(issuer)
            .setAudience("appstoreconnect-v1")
            .setIssuedAt(issuedAt)
            .signWith(pk)
            .setExpiration(expiration);
    
    // Print the JWT header as a JSON string
    String headerJson = jwtBuilder.compact();

    System.out.println("JWT Header: " + headerJson);

    String apiUrl = "https://api.appstoreconnect.apple.com/v1/apps";

    // Create headers with Authorization
    HttpHeaders headers = new HttpHeaders();
    headers.set("Authorization", "Bearer " + headerJson);
    headers.setContentType(MediaType.APPLICATION_JSON);

    // Create HttpEntity with headers
    HttpEntity<String> entity = new HttpEntity<>(headers);

    // Make GET request using RestTemplate
    RestTemplate restTemplate = new RestTemplate();
    ResponseEntity<String> response = restTemplate.exchange(
            apiUrl,
            HttpMethod.GET,
            entity,
            String.class
    );

    // Handle the response
    if (response.getStatusCode() == HttpStatus.OK) {
        String responseBody = response.getBody();
        System.out.println("Response: " + responseBody);
    } else {
        System.out.println("Error: " + response.getStatusCodeValue());
    }

    // Print the JWT payload as a JSON string
    String payloadJson = jwtBuilder.compact();
    System.out.println("JWT Payload: " + payloadJson);

字符串
但我犯了个错误

Exception in thread "main" org.springframework.web.client.HttpClientErrorException$Unauthorized: 401 Unauthorized: "{<EOL>?"errors": [{<EOL>??"status": "401",<EOL>??"code": "NOT_AUTHORIZED",<EOL>??"title": "Authentication credentials are missing or invalid.",<EOL>??"detail": "Provide a properly configured and signed bearer token, and make sure that it has not expired. Learn more about Generating Tokens for API Requests https://developer.apple.com/go/?id=api-generating-tokens"<EOL>?}]<EOL>}"
at org.springframework.web.client.HttpClientErrorException.create(HttpClientErrorException.java:106)
at org.springframework.web.client.DefaultResponseErrorHandler.handleError(DefaultResponseErrorHandler.java:183)
at org.springframework.web.client.DefaultResponseErrorHandler.handleError(DefaultResponseErrorHandler.java:137)
at org.springframework.web.client.ResponseErrorHandler.handleError(ResponseErrorHandler.java:63)
at org.springframework.web.client.RestTemplate.handleResponse(RestTemplate.java:932)
at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:881)
at org.springframework.web.client.RestTemplate.execute(RestTemplate.java:781)
at org.springframework.web.client.RestTemplate.exchange(RestTemplate.java:663)
at com.mysticriver.service.AppleAPIService.main(AppleAPIService.java:77)


用编辑器打开文件会得到这样的结果:

-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg5Fu6zyvQDhgGvevK
pe4OYs32cFSz1oxLd/YCYWJSOPagCgYIKoZIzj0DAQehRANCAATrJf+q7/nieM4y
V9/v71e/Xl/aS+LF4riW5lkcld8lFQB5ekivp5T7w57t6nqp8rCqtq79nEhIyzDr
hCMnmLEk
-----END PRIVATE KEY-----

n3ipq98p

n3ipq98p1#

原始问题(编辑前):401 Unauthorized:这应该意味着您使用的authentication token (JWT)不正确,签名不正确,或者过期。
从你的代码中,你可能会错过“用你的私钥对JWT进行签名”的步骤。JWT必须用你的私钥进行签名,Apple API才能对其进行身份验证。
请参阅“Creating API Keys for App Store Connect API“以创建密钥。
然后,使用所述私钥对JWT进行签名:

PrivateKey privateKey = // Load your private key here
JwtBuilder jwtBuilder = Jwts.builder()
        .setHeader(headerMap)
        .setIssuer(issuer)
        .setAudience("appstoreconnect-v1")
        .setIssuedAt(issuedAt)
        .setExpiration(expiration)
        .signWith(SignatureAlgorithm.RS256, privateKey); // Signing the JWT

字符串
参见Dejan Milosevic中的“REST Security With JWT Using Java and Spring Security“。
此外,检查JWT结构(头部,有效载荷,签名)是否符合Apple的指导方针。
并且,像往常一样,添加一些错误处理,以便在失败时提供更多信息反馈。

try {
    ResponseEntity<String> response = restTemplate.exchange(
            apiUrl,
            HttpMethod.GET,
            entity,
            String.class
    );
    // Rest of the code
} catch (HttpClientErrorException ex) {
    System.out.println("HTTP Error: " + ex.getStatusCode());
    System.out.println("Error Body: " + ex.getResponseBodyAsString());
}


您的更新代码现在包括:

  • .p8文件加载私钥,该文件通常用于Apple API身份验证。它使用KeyFactory和“RSA”来生成PrivateKey
  • 使用加载的私钥对JWT进行签名,这是使用Apple API进行身份验证的必要步骤。

您现在有一个InvalidKeySpecException,表示密钥规范或格式有问题。
.p8文件可能包含EC (Elliptic Curve) private key,而不是RSA。因此KeyFactory示例应该使用"EC"而不是"RSA"
确保.p8文件未损坏且格式正确。

KeyFactory keyFactory = KeyFactory.getInstance("EC"); // Change from "RSA" to "EC"
PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec);


再次,添加加载私钥的错误处理以查明问题。

try {
    PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec);
    // Rest of the code
} catch (InvalidKeySpecException e) {
    e.printStackTrace(); // More informative error output
    return;
}


第三次编辑:
jdk.crypto.ec/sun.security.ec.ECKeyFactory.engineGeneratePrivate(ECKeyFactory.java:168)应该表示从.p8文件加载私钥时出现问题。堆栈跟踪表明问题在于PKCS8EncodedKeySpec,它无法解码密钥。
您已经更新了KeyFactory示例以使用“EC”而不是“RSA”,这对于Apple的.p8私钥是正确的。但是尽管进行了此更改,InvalidKeySpecException仍然存在,这表明问题不在于密钥工厂算法(RSA或EC)的选择,而在于密钥文件的格式或内容。
错误消息“Unable to decode key“和“extra data at the end“表明.p8文件可能不在正确的PKCS#8 format中。
因此,请尝试并确保.p8文件是正确的PKCS#8格式私钥。它应该以“-----BEGIN PRIVATE KEY-----“开头,以“-----END PRIVATE KEY-----“结尾。任何其他数据或格式问题都可能导致错误。
如果.p8文件包含任何页眉或页脚(如“BEGIN PRIVATE KEY“),则需要在传递给PKCS8EncodedKeySpec之前将其删除。
.p8文件中的密钥数据通常是Base64编码的。在PKCS8EncodedKeySpec中使用它之前,请确保将其正确解码为二进制形式。

String keyContent = new String(Files.readAllBytes(privateKeyPath), StandardCharsets.UTF_8)
                        .replaceAll("\\n", "")
                        .replace("-----BEGIN PRIVATE KEY-----", "")
                        .replace("-----END PRIVATE KEY-----", "");
byte[] decodedKey = Base64.getDecoder().decode(keyContent);
PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(decodedKey);


一些错误处理:

try {
    PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec);
    // Rest of the code
} catch (InvalidKeySpecException e) {
    e.printStackTrace(); // More detailed error information
    return;
}


如果在格式化和Base64解码内容之后,加载私钥仍然有问题,那就意味着密钥本身或处理方式有问题。
我假设你的.p8文件确实以-----BEGIN PRIVATE KEY-----开头,以-----END PRIVATE KEY-----结尾,并且它是完整的,没有被截断。你已经从Apple Developer帐户生成了一个新的密钥,所以你可以与以前的密钥进行比较。
确保Base64解码正确。不正确的解码可能导致无效的密钥规范。
并确保文件阅读过程不会以任何方式改变密钥内容。例如,确保用于读取文件的字符编码与文件的编码匹配。

public static PrivateKey loadPrivateKey(Path privateKeyPath) throws IOException, GeneralSecurityException {
    String keyContent = new String(Files.readAllBytes(privateKeyPath), StandardCharsets.UTF_8);
    System.out.println("Original Key Content: " + keyContent); // Logging the original content
    keyContent = keyContent.replace("-----BEGIN PRIVATE KEY-----", "")
                           .replace("-----END PRIVATE KEY-----", "")
                           .replaceAll("\\s+", ""); // Remove all whitespaces and newlines, more robust than just replacing \n
    System.out.println("Processed Key Content: " + keyContent); // Logging processed content
    byte[] decodedKey = Base64.getDecoder().decode(keyContent);
    PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decodedKey);
    KeyFactory kf = KeyFactory.getInstance("EC");
    return kf.generatePrivate(spec);
}


这基本上是你的代码,但是在一个函数中设置为在错误的情况下抛出异常,使用\s+代替\s+的replaceAll,并使用一些日志记录(System.out.println
或者,为了测试,尝试更直接的解码方法:

String keyContent = new String(Files.readAllBytes(privateKeyPath), StandardCharsets.UTF_8);
keyContent = keyContent.replace("-----BEGIN PRIVATE KEY-----", "")
                        .replace("-----END PRIVATE KEY-----", "")
                        .replaceAll("\\s+", ""); // Remove all whitespaces and newlines
byte[] decodedKey = Base64.getDecoder().decode(keyContent);


在任何情况下,尝试使用OpenSSL之类的工具来检查密钥的有效性。这可以帮助确定问题是在于密钥本身还是Java代码。

openssl pkcs8 -in [path-to-your-key].p8 -nocrypt -topk8


如果文件格式正确,该命令应该输出密钥详细信息。如果它失败,问题可能是密钥文件本身。

pwuypxnk

pwuypxnk2#

最简单的解决方案是使用BountyCastleLibrary。
这个库将负责删除不必要的头和解码Base64 PEM数据。

注意:BountyCastle对椭圆曲线密码算法解析有很好的支持。

另外,您可以尝试使用com.auth0:java-jwt依赖项,因为它提供的功能比io.jsonwebtoken:jjwt依赖项多得多。
在pom.xml中添加此依赖项:

<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcpkix-jdk18on</artifactId>
    <version>1.76</version>
</dependency>
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>4.4.0</version>
</dependency>

字符串
更改/更新您的逻辑:

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemReader;
import org.springframework.http.*;
import org.springframework.web.client.RestTemplate;

import java.io.FileReader;
import java.security.KeyFactory;
import java.security.interfaces.ECPrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Date;
import java.util.concurrent.TimeUnit;

@Transactional(readOnly = true)
public class AppleAPIService {

    public static void main(String[] args) {

        try (PemReader pemReader = new PemReader(
                new FileReader("/Users/ricardolle/IdeaProjects/mystic-planets-api/src/main/resources/cert/AuthKey_5425KFDYSC.p8"))) {

            KeyFactory keyFactory = KeyFactory.getInstance("EC");
            PemObject pemObj = pemReader.readPemObject();
            byte[] content = pemObj.getContent();
            PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(content);
            ECPrivateKey privateKey = (ECPrivateKey) keyFactory.generatePrivate(privateKeySpec);

            String token = JWT.create()
                    .withKeyId("5525KFDYSC")
                    .withIssuer("69a6de82-121e-48e3-e053-5b8c7c11a4d1")
                    .withExpiresAt(new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1)))
                    .withClaim("scope", Collections.singletonList("GET /v1/apps"))
                    .withAudience("appstoreconnect-v1")
                    .sign(Algorithm.ECDSA256(privateKey));

            System.out.println("JWT token: " + token);

            // Create headers with Authorization
            HttpHeaders headers = new HttpHeaders();
            headers.setBearerAuth(token);
            headers.setContentType(MediaType.APPLICATION_JSON);

            // Create HttpEntity with headers
            HttpEntity<String> entity = new HttpEntity<>(headers);

            // Make GET request using RestTemplate
            ResponseEntity<String> response = new RestTemplate().exchange(
                    "https://api.appstoreconnect.apple.com/v1/apps",
                    HttpMethod.GET, entity, String.class);

            // Handle the response
            if (response.getStatusCode() == HttpStatus.OK) {
                String responseBody = response.getBody();
                System.out.println("Response: " + responseBody);
            } else {
                System.out.println("Error: " + response.getStatusCodeValue());
            }
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }

}


请查看此Apple开发者主题论坛问题以解决您的问题-https://developer.apple.com/forums/thread/707220
如果上面的线程仍然无法解决您的问题,请尝试通过withClaims("bid", "put your bundle id")添加bid(apple bundle id)
仅此而已

qzlgjiam

qzlgjiam3#

tl;干

尝试提供不大于20分钟的过期时间,例如15(尽管文档中声明不大于,但恐怕应该小于20):

Date expiration = new Date(nowMillis + 15 * 60 * 1000);

字符串

详情

你的答案中提供的最后一个版本的代码基本上是好的。
我认为问题与您指定的令牌的生命周期有关,一个小时。
正如Apple Developer文档在描述exp JWT payload字段时所解释的那样:
令牌的过期时间(以Unix纪元时间表示)。过期时间超过20分钟的令牌将无效,确定适当的令牌生存期中列出的资源除外。
参考的确定适当的令牌生命周期文档指出,App Store Connect在以下情况下接受生命周期大于20分钟的令牌:

  • 令牌定义了一个范围。
  • 范围仅包括GET请求。
  • 作用域中的资源允许长寿命令牌。

您的Java代码满足前两个条件,但不满足第三个条件:前面提到的文档列出了可以接受长期令牌的资源,而您正在使用的List Apps端点,通常是Apps资源,并不包括在其中。
如上所述,要解决此问题,请在执行请求时尝试定义小于20分钟的过期时间。例如:

Date expiration = new Date(nowMillis + 15 * 60 * 1000);


代码的其余部分看起来很好:请注意,您在生成JWT令牌时提供的所有信息都是正确的,并且密钥没有被撤销,并且它已经被分配了一个被授权执行请求的角色。
请参考this related article,我认为它很好地说明了如何执行操作。

相关问题