Hello World

吞风吻雨葬落日 欺山赶海踏雪径

0%

谷歌、评估三方登录接入

三方登录架构设计

设计

整体流程
2025101601.png

核心流程
App端获取到三方的idToken传递给服务端换取 access_token和 refresh_token
接口返回同 验证码登录

idToken客户端的获取逻辑
谷歌

1
2
GoogleSignInAccount account = task.getResult(ApiException.class);
String idToken = account.getIdToken(); // 发送给后端验证

苹果
https://developer.apple.com/documentation/authenticationservices/asauthorizationappleidcredential/identitytoken

核心接口

/thirdparty_login

入参

  • idToken App端必传
  • authCode 非必传,idToken 取不到的时候传入(web端使用)
  • source 来源,哪一方获取的token (服务端校验使用)
    • google
    • apple

出参

  • code
  • message
  • data 通用返回,同 验证码登录、一键登录等接口
    • accessToken
    • refreshToken
    • expire
1
2
3
4
5
6
7
8
9
{
"code": 0,
"message": "成功",
"data": {
"accessToken": "xxx",
"refreshToken": "xxx",
"expire": 86400,
}
}

表结构

tb_thirdparty_user

字段 说明 备注
id
gmt_create
gmt_modified
sub 三方唯一标识
type 三方类型
email 邮箱
name
locale
status 状态 1-已绑定 0-未绑定 -1-删除
bind_at 绑定时间

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206

@Slf4j
@Service
public class ThirdPartyAuthService {

@Value("${thirdparty.login.google.android.client-id:xxx}")
private String googleAndroidClientId;

@Value("${thirdparty.login.google.ios.client-id:xxx}")
private String googleIosClientId;

@Value("${thirdparty.login.apple.ios.client-id:xxx}")
private String appleIosClientId;

// 因为客户端sdk版本较低,这里改成使用web端的client-id
private String googleWebClientId = "xxx";

private static final String APPLE_ISSUER = "https://appleid.apple.com";

private static final String APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";

private final Cache<String, List<JWK>> jwkCache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofHours(6)) // 所有 key 的值 6 小时后过期
.maximumSize(5)
.build();

private static final String APPLE_KEYS_CACHE_KEY = "apple_keys_cache";

// 全局唯一
GoogleIdTokenVerifier googleIdTokenVerifier = null;

@PostConstruct
public void init() {
googleIdTokenVerifier = new GoogleIdTokenVerifier.Builder(
new NetHttpTransport(),
new GsonFactory())
.setAudience(Lists.newArrayList(googleAndroidClientId, googleIosClientId, googleWebClientId)) // 验证 Audience
.build();
}

public ThirdpartyUserProfile getUserProfile(ThirdpartyLoginRequest loginRequest) {

if (loginRequest == null || StringUtils.isAnyBlank(loginRequest.getSource(), loginRequest.getIdToken())) {
log.warn("getUserProfile invalid parameter: {}", JSON.toJSONString(loginRequest));
return null;
}
ThirdpartyTypeEnum thirdpartyTypeEnum = ThirdpartyTypeEnum.of(loginRequest.getSource());
if (thirdpartyTypeEnum == null) {
log.warn("getUserProfile invalid source: {}", JSON.toJSONString(loginRequest));
return null;
}
switch (thirdpartyTypeEnum) {
case google:
return getGoogleUserProfile(loginRequest);
case apple:
return getAppleUserProfile(loginRequest);
default:
break;
}
return null;
}

public ThirdpartyUserProfile getGoogleUserProfile(ThirdpartyLoginRequest loginRequest) {
String idTokenString = loginRequest.getIdToken();
try {
if (googleIdTokenVerifier == null) {
init();
}
GoogleIdToken idToken = googleIdTokenVerifier.verify(idTokenString);
if (idToken == null) {
log.warn("getGoogleUserProfile invalid idToken: {}", JSON.toJSONString(idTokenString));
throw new MyThrowException(ExceptionEnum.INVALID_AUTH_TOKEN);
}
GoogleIdToken.Payload payload = idToken.getPayload();
String subject = payload.getSubject();
String email = payload.getEmail();
Boolean emailVerified = payload.getEmailVerified();
String name = (String) payload.get("name");
String picture = (String) payload.get("picture");

ThirdpartyUserProfile profile = new ThirdpartyUserProfile();
profile.setEmail(email);
profile.setEmailVerified(emailVerified);
profile.setSub(subject);
profile.setName(name);
profile.setPicture(picture);
profile.setLocale((String) payload.get("locale"));
profile.setGivenName((String) payload.get("given_name"));
profile.setFamilyName((String) payload.get("family_name"));
profile.setType(ThirdpartyTypeEnum.google.name());
return profile;
} catch (Exception e) {
log.error("getGoogleUserProfile verify idToken: {}, error:", JSON.toJSONString(idTokenString), e);
}
return null;
}

public ThirdpartyUserProfile getAppleUserProfile(ThirdpartyLoginRequest loginRequest) {

String idTokenString = loginRequest.getIdToken();

try {
SignedJWT signedJWT = SignedJWT.parse(idTokenString);
String kid = signedJWT.getHeader().getKeyID();
JWSVerifier verifier = getAppleVerifier(kid, true);

if (!signedJWT.verify(verifier)) {
// 非缓存在试一次
verifier = getAppleVerifier(kid, false);
if (!signedJWT.verify(verifier)) {
log.warn("invalid identityToken: {}", idTokenString);
throw new MyThrowException(ExceptionEnum.INVALID_AUTH_TOKEN);
}
}

JWTClaimsSet claims = signedJWT.getJWTClaimsSet();
if (!APPLE_ISSUER.equals(claims.getIssuer())) {
log.warn("invalid identityToken: {}, wrong issuer:{}", idTokenString, claims.getIssuer());
throw new MyThrowException(ExceptionEnum.INVALID_AUTH_TOKEN);
}
if (!claims.getAudience().contains(appleIosClientId)) {
log.warn("invalid identityToken: {}, wrong audiences:{}", idTokenString, JSON.toJSONString(claims.getAudience()));
throw new MyThrowException(ExceptionEnum.INVALID_AUTH_TOKEN);
}
if (claims.getExpirationTime() != null && new Date().after(claims.getExpirationTime())) {
log.warn("invalid identityToken: {}, expiration time:{}", idTokenString, claims.getExpirationTime().getTime());
throw new MyThrowException(ExceptionEnum.INVALID_AUTH_TOKEN);
}

Map<String, Object> claimsMap = claims.getClaims();

String subject = claims.getSubject();
String email = (String) claimsMap.get("email");
Object emailVerifiedObj = claimsMap.get("email_verified");
Boolean emailVerified = null;
if (emailVerifiedObj != null) {
emailVerified = Boolean.valueOf(String.valueOf(emailVerifiedObj));
}
Boolean isPrimaryEmail = null;
Object isPrimaryObj = claimsMap.get("is_private_email");
if (isPrimaryObj != null) {
isPrimaryEmail = Boolean.valueOf(String.valueOf(isPrimaryObj));
}

ThirdpartyUserProfile profile = new ThirdpartyUserProfile();
profile.setEmail(email);
profile.setEmailVerified(emailVerified);
profile.setPrivateEmail(isPrimaryEmail);
profile.setSub(subject);
profile.setType(ThirdpartyTypeEnum.apple.name());
return profile;

} catch (Exception e) {
log.error("getAppleUserProfile verify idToken: {}, error:", JSON.toJSONString(idTokenString), e);
}
return null;
}

private JWSVerifier getAppleVerifier(String kid, boolean useCache) {
try {
List<JWK> jwks = useCache ? getApplePublicJwksFromCache() : getApplePublicJwks();
JWK applePublicJwk = jwks.stream()
.filter(k -> k.getKeyID().equals(kid))
.findFirst()
.orElse(null);
if (applePublicJwk == null) {
log.warn("get jwk failed, kid: {}", kid);
throw new MyThrowException(ExceptionEnum.SERVICE_INTERVAL);
}
RSAPublicKey publicKey = ((RSAKey) applePublicJwk).toRSAPublicKey();
JWSVerifier verifier = new RSASSAVerifier(publicKey);
return verifier;
} catch (Exception e) {
log.error("getAppleVerifier error: ", e);
}
throw new MyThrowException(ExceptionEnum.SERVICE_INTERVAL);
}

private List<JWK> getApplePublicJwks() {

try {
JWKSet jwkSet = JWKSet.load(new URL(APPLE_KEYS_URL));
List<JWK> jwks = jwkSet.getKeys();
if (!CollectionUtils.isEmpty(jwks)) {
jwkCache.put(APPLE_KEYS_CACHE_KEY, jwks);
}
return jwks;
} catch (Exception e) {
log.error("getApplePublicJwks error: ", e);
}
return Collections.emptyList();
}

private List<JWK> getApplePublicJwksFromCache() {
try {
List<JWK> jwks = jwkCache.getIfPresent(APPLE_KEYS_CACHE_KEY);
if (CollectionUtils.isEmpty(jwks)) {
jwks = getApplePublicJwks();
}
return jwks;
} catch (Exception e) {
log.error("getApplePublicJwks error: ", e);
}
return Collections.emptyList();
}
}

参考

google

Credential Manager 说明
https://developer.android.google.cn/identity/sign-in/credential-manager?hl=zh-cn

一键登录的官方流程
https://developer.android.com/identity/sign-in/credential-manager-siwg?hl=zh-cn

说明
https://developers.google.com/identity/siwg

后端校验 idToken
https://developers.google.com/identity/sign-in/android/backend-auth?hl=zh-cn
https://developers.google.com/identity/sign-in/ios/backend-auth?hl=zh-cn
https://developers.google.com/identity/gsi/web/guides/verify-google-id-token?hl=zh-cn

源代码
https://github.com/googleapis/google-api-java-client/blob/main/google-api-client/src/main/java/com/google/api/client/googleapis/auth/oauth2/GoogleIdTokenVerifier.java

android 接入
https://developer.android.com/identity/sign-in/credential-manager-siwg?hl=zh-cn

最终获取到 googleIdTokenCredential.getIdToken()传递给服务端

ios接入
https://developers.google.com/identity/sign-in/ios/start-integrating?hl=zh-cn

最终可以获取到 let idToken = user.idToken传递给服务端

google payload模型 参考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
// These six fields are included in all Google ID Tokens.
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"azp": "1008719970978-hb24n2dstb40o45d4feuo2ukqmcc6381.apps.googleusercontent.com",
"aud": "1008719970978-hb24n2dstb40o45d4feuo2ukqmcc6381.apps.googleusercontent.com",
"iat": "1433978353",
"exp": "1433981953",

// These seven fields are only included when the user has granted the "profile" and
// "email" OAuth scopes to the application.
"email": "testuser@gmail.com",
"email_verified": "true",
"name" : "Test User",
"picture": "https://lh4.googleusercontent.com/-kYgzyAWpZzJ/ABCDEFGHI/AAAJKLMNOP/tIXL9Ir44LE/s99-c/photo.jpg",
"given_name": "Test",
"family_name": "User",
"locale": "en"
}

apple

对接文档
https://developer.apple.com/documentation/signinwithapple/

ios对接
https://developer.apple.com/documentation/signinwithapple/authenticating-users-with-sign-in-with-apple?utm_source=chatgpt.com