1. OAuth2 流程
Web 三方登录有什么难的?不就是 OAuth2
吗?
嘿嘿,别着急,等苹果重拳出击。
理想情况是这样的:
- 先去苹果获取一对 client_id / client_secret,注册一个 redirect_uri
- 登录的时候跳到苹果页面去进行授权
- 用户授权成功后,重定向回我们的页面并带上code和其他参数
- 前端提交code和其他参数到后端
- 后端使用client_id+client_secret+code 换 acccess_token 和 id_token
- 获取苹果用户信息
实际上是这样的:
- 先去苹果获取一对 client_id / client_secret,注册一个 redirect_uri
- 登录的时候跳到苹果页面去进行授权
- 用户授权成功后,苹果用表单提交code和其他参数到我们后端接口(后端接口:这是在逼我用JSP?)
- 后端接口这时候要不就硬着头皮去验证获取access_token 和 id_token,要不就舔着脸把code和其他参数又重定向回前端页面(遵循OAuth2,但不多)
2. 准备
- 苹果开发者账号
- client_id/client_secret
- redirect_uri
-
登录苹果开发者网站,搞个账号(收费的) Account - Apple Developer
-
获取 client_id 和 client_secret
进入开发者首页,找到 identifiers
增加一个Service ID
生成密钥
现在我们可以得到几个参数:
- client_id: 对应service id identifier
- client_secret: 对应 Key 那部分下载下来的p8文件
- team_id:这里有 Account - Apple Developer
- key_id:就是 keyID(狗头)
3. 方案分析
实际上是这样的:
- 先去苹果获取一对 client_id / client_secret,注册一个 redirect_uri
- 登录的时候跳到苹果页面去进行授权
- 用户授权成功后,苹果用表单提交code和其他参数到我们后端接口(后端接口:这是在逼我用JSP?)
- 后端接口这时候要不就硬着头皮去验证获取access_token 和 id_token,要不就舔着脸把code和其他参数又重定向回前端页面(遵循OAuth2,但不多)
根据这个实际情况,我们有两个方案
方案一:
方案一流程:
- 用户点击 Apple 登录
- 调用后端 authorize 接口,这个接口会生成一个跳转到苹果授权页面的URL并返回给前端
- 前端 redirect 到后端返回的这个URL
- 用户登录授权成功后,苹果用表单提交code和其他参数到我们后端接口
- 后端接口硬着头皮去验证获取access_token 和 id_token。获取用户成功后,又重定回前端页面, 注意这个时候要把cookie写回前端。
这个方案有个缺点,后端接口和前端页面必须同域,cookie才能生效。
方案二:
方案二流程:
- 用户点击 Apple 登录
- 调用后端 authorize 接口,这个接口会生成一个跳转到苹果授权页面的URL并返回给前端
- 前端 redirect 到后端返回的这个URL
- 用户登录授权成功后,苹果用表单提交code和其他参数到我们后端接口
- 后端接口得到code和其他参数之后,直接 Redirect 透传参数到前端的URL
- 前端页面得到code和其他参数后,调用后端的login接口进行登录
- 后端去验证并获取access_token 和 id_token。获取用户成功后,返回登录成功。
这个方案做的就是把苹果弄歪的 OAuth 流程给拉回来了 ,但有一个需要注意的点,重定向回前端URL的时候注意域名,别把Cookies写到钓鱼网站去了。
4. 实现
顺便选个方案来实现吧,难点不是这里。
- 导入依赖
使用 jjwt
就导入这几个依赖
xml
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
</dependency>
使用 jsoe4j
就用这个依赖
xml
<dependency>
<groupId>org.bitbucket.b_c</groupId>
<artifactId>jose4j</artifactId>
<version>0.9.3</version>
</dependency>
再加一个 okhttp
的依赖
xml
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.11.0</version>
</dependency>
- 添加配置
properties
apple.oauth.team-id=xxxxx
apple.oauth.client-id=xxxxxxx
apple.oauth.client-secret=MIGTAgExxxxxxxxxxxx+o41iDkiJA
apple.oauth.key-id=xxxxx
apple.oauth.post-form-endpoint=https://example.com/post_form
apple.oauth.authorization-endpoint=https://appleid.apple.com/auth/authorize
apple.oauth.token-endpoint=https://appleid.apple.com/auth/token
java
@Data
@Configuration
@ConfigurationProperties(prefix = "apple.oauth")
public class AppleConfig {
private String teamId;
private String audience;
private String keyId;
private String clientId;
private String clientSecret;
private String postFormEndpoint;
private String authorizationEndpoint;
private String tokenEndpoint;
}
- 生成重定向链接
java
@ApiOperation(value = "获取登录信息")
@GetMapping("/authorize")
public ResponseEntity<?> authorize() {
// 后端生成
String state = generateState();
// 标记这个state存在
stateStore.put(state, (byte) 0);
// https://appleid.apple.com/.well-known/openid-configuration authorization_endpoint
String location = UriComponentsBuilder.fromHttpUrl(appleConfig.getAuthorizationEndpoint())
// 授权码模式
.queryParam("response_type", "code")
// 苹果后台申请的
.queryParam("client_id", appleConfig.getClientId())
// 后端用来用来接收苹果post_form的接口
.queryParam("redirect_uri", appleConfig.getPostFormEndpoint())
// 后端生成
.queryParam("state", state)
// 这是苹果要求的,Web登录都是form_post
.queryParam("response_mode", "form_post")
// https://appleid.apple.com/.well-known/openid-configuration scopes_supported
.queryParam("scope", "openid email name")
.build().toUriString();
// 重定向到苹果
MultiValueMap<String, String> headers = new HttpHeaders();
headers.add(HttpHeaders.LOCATION, location);
return new ResponseEntity<>(headers, org.springframework.http.HttpStatus.FOUND);
}
测试完,发生重定向了,重定向地址是appleid授权页面
我们需要手动把 location 这里面的地址放到浏览器打开,这样才能完成授权。我这里因为要截图,所以才用了 Postman,简单点可以直接浏览器访问 localhost:port/test/authorize
,浏览器会自动跳转到 Location。
- 处理post_form
授权完成后,苹果会使用表单提交,将code 以及 state 和用户信息(首次才有)提交到我们的apple.oauth.post-form-endpoint
,我们应该提供接口来接收这次提交。
因为苹果后台要求必须配置 https 协议的链接,如果有条件的可以直接上云测试,像我这种没条件的,只能把参数记起来。
java
@ApiOperation(value = "苹果登录回调")
@PostMapping(value = "/post_form", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public void postForm(OauthAppLoginParam form, HttpServletResponse response) throws IOException {
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl("http://localhost:10001/login");
// 验证 state 存在
if (!stateStore.containsKey(form.getState())) {
response.sendRedirect(builder.queryParam("code", "invalid_state").toUriString());
return ;
}
// 根据 code 和 status,去苹果换id_token/access_token
OkHttpClient client = new OkHttpClient();
// 构建请求体
okhttp3.RequestBody formBody = new FormBody.Builder()
.add("client_id", appleConfig.getClientId())
.add("client_secret", generateClientSecretJose4j())
.add("grant_type", "authorization_code")
.add("code", form.getCode())
.add("redirect_uri", appleConfig.getPostFormEndpoint())
.build();
// 创建 POST 请求
Request request = new Request.Builder()
.url("https://appleid.apple.com/auth/token")
.post(formBody) // 设置请求体
.build();
try (Response resp = client.newCall(request).execute()) {
if (!resp.isSuccessful() || resp.body() == null) {
response.sendRedirect(builder.queryParam("code", "invalid_request").toUriString());
return ;
}
// 用户信息存储
String body = resp.body().string();
JsonNode jsonNode = JacksonUtils.parseJson(body);
String idToken = JacksonUtils.getStringValue(jsonNode, "id_token");
// idToken是个jwt,简单点就直接取中间段然后base64解开,然后json解析
// 可靠点就使用jwk来解析出claim。jwks:https://appleid.apple.com/auth/keys
String[] parts = idToken.split("\\.");
String payload = BASE64Utils.decode(parts[1]);
String token = generateToken();
userStore.put(token, payload);
response.addCookie(new Cookie("token", token));
}
response.sendRedirect(builder.queryParam("code", "ok").toUriString());
}
把浏览器的参数复制,调用后可以看到接口做了两个事情:
- 设置了cookie:用于下一步获取用户信息提供token
- 重定向到了一个地址:一般是跳到了前端页面,前后端需要约定好。
- 获取用户信息
上一步已经解析完了用户信息,这一步的代码你自己来吧,CRUD 我懒得写。
Ref
教你在 Node.js 项目中接入 Sign with Apple 第三方登录 - 知乎 (zhihu.com)
Authenticating users with Sign in with Apple | Apple Developer Documentation