封面

web端接入apple Sign in流程

前一段时间接入了google sign in的功能,现在继续接入apple sign in。待apple sign in 正式上线之后,我们的游戏支持 line、Facebook、twitter、google、apple5种三方登陆,基本上涵盖了主流sns。apple和google虽然是不同的平台,但是都是采用上oauth2.0的协议,所以接入流程大同小异。

知识储备

  1. jwt相关知识(apple采用的是jwt的验证方式)
  2. js/ts/java基础编程技能

前提准备

登陆apple开发者中心

  1. 在identifidr注册一个App ID

image-20201111175510479

image-20201111175553237

image-20201111175741824

注册之后可以得到3个内容

Description:对app的描述

Bundle ID: 注册时第2步填的反向域名(也就是client_id)

App ID Prefix: 也就是teamId,apple自动生成的随机唯一标识(init的时候不需要)

  1. 注册在identifier中注册service ID, 有几个环境就可以注册几个service ID,绑定同一个App Id就可以了。

image-20201111180250556

注册好之后打开apple sign in 并配置

image-20201111180330148

image-20201111180600392

image-20201111180815110

代码演示

  1. 下载声明

yarn add @types/apple-sign-in-api

yarn add loadjs

  1. 初始化 AppleID
import axios from 'redaxios';
import { AppleWebConfig } from '@/shared/models/AppleWebConfig';
import { AuthConfig } from '@/client/auth/index';
import { loadScript } from '@/client/utils/scriptUtils';

/**
* apple init
*/
export async function appleInit(): Promise<void> {
$('#apple-login').removeClass('hidden');
$('#apple-link').removeClass('hidden');

// get the apple config
const response = await axios({
url: '/oauth/v1/config',
method: 'get',
});
const config: AuthConfig = response.data as AuthConfig;
if (!config.apple) {
return;
}
const appleWebConfig: AppleWebConfig = new AppleWebConfig();
Object.assign(appleWebConfig, config.apple);
console.log(config.apple);

// use the config instance the AppleID
await loadScript(appleWebConfig.sdk_url)
.then(() => {
AppleID.auth.init({
clientId: appleWebConfig.identifier, // 对应配置的反向域名
scope: appleWebConfig.scope, // name email 需要获取的内容,多个用空格分开
redirectURI: appleWebConfig.redirect_url, // 回调地址
usePopup: true, // 是否用弹窗方式
});
console.info('apple environment ready');
})
.catch((e) => console.error(e));
}

loadScript 用于动态加载js

import loadjs, { LoadOptions } from 'loadjs';

interface CacheableOptions extends LoadOptions {
cacheable?: boolean;
}

export const loadScript = ((): ((
src: string,
options?: CacheableOptions
) => Promise<void>) => {
const cache: Record<string, Promise<void>> = {};
return (src: string, options?: CacheableOptions): Promise<void> => {
if (typeof src !== 'string') {
throw new Error('src must be string');
}
const opt: CacheableOptions = {
cacheable: true,
numRetries: 3,
...(options || {}),
};

if (opt.cacheable && cache[src]) {
return cache[src];
}
const promise = loadjs([src], {
...opt,
returnPromise: true,
});
if (opt.cacheable) {
cache[src] = promise;
}
return promise;
};
})();

触发登陆

<button class="sns-btn apple sns-btn-apple-link hidden" id="apple-login"><span>Apple</span></button>

监听按钮点击事件

$('#apple-login').on('click', async () => {
if (!AppleID) {
showLoginLinkTip();
return;
}
const data = await AppleID.auth.signIn();
console.log(data); // data是auth相关信息

});

监听成功/失败(可选)

document.addEventListener('AppleIDSignInOnSuccess', (data: any) => {
console.log(data);
});
document.addEventListener('AppleIDSignInOnFailure', (error: any) => {
console.error(error.detail);
});

实例

import { AppleWebConfig } from '@/shared/models/AppleWebConfig';
import {
AuthConfig,
reloadSession,
SNSLoginBindResponse,
} from '@/client/auth/index';
import { loadScript } from '@/client/utils/scriptUtils';
import axios from 'redaxios';
import { PlatformReportType } from '@/shared/types/reportTypes/platform';
import { GameConst } from '@/shared/constant/gameConst';
import { showLoginLinkTip } from '../common/popup';
import { setCookie } from '../common/cookie';

let appleWebConfig: AppleWebConfig;

/**
* apple signIn handler
* @param data authInfo
*/
async function appleSignInSuccess(
data: AppleSignInAPI.SignInResponseI
): Promise<void> {
const idToken = data.authorization.id_token;
const params = {
appId: window.option.appId,
idToken,
provider_type: 'apple',
};
const loginResponse = await axios({
url: '/oauth/v1/login',
method: 'post',
params,
}).catch((error) => {
showLoginLinkTip(error);
});

if (loginResponse) {
const result = loginResponse.data as SNSLoginBindResponse;
showLoginLinkTip(result.code);
reloadSession('visibilitychange');
} else {
showLoginLinkTip('network timeout, please try again later');
}
}

/**
* apple link success handler
* @param data authInfo
*/
async function appleLinkSuccess(
data: AppleSignInAPI.SignInResponseI
): Promise<void> {
const idToken = data.authorization.id_token;
const params = {
appId: window.option.appId,
idToken,
provider_type: 'apple',
};
const linkResponse = await axios({
url: '/oauth/v1/link',
method: 'post',
params,
}).catch((error) => {
showLoginLinkTip(error);
});

if (linkResponse) {
const result = linkResponse.data as SNSLoginBindResponse;
showLoginLinkTip(result.code);
reloadSession('visibilitychange');
} else {
showLoginLinkTip('network timeout ,please try again later');
}
}

// eslint-disable-next-line @typescript-eslint/ban-types
function appleSignInFail(error: object): void {
console.error(error);
}

function getQueryVariable(variable: string): string | null {
return new URL(window.location.href).searchParams.get(variable);
}

/**
* apple init
*/
export async function appleInit(): Promise<void> {
const cookie = getQueryVariable(GameConst.appleFeatureSwitchCookie);
if (cookie) {
setCookie(GameConst.appleFeatureSwitchCookie, cookie, 360000);
}
// get the apple config
const response = await axios({
url: '/oauth/v1/config',
method: 'get',
});
const config: AuthConfig = response.data as AuthConfig;
if (!config.apple) {
console.error("can't get the apple config");
return;
}

$('#apple-login').removeClass('hidden');
$('#apple-link').removeClass('hidden');
appleWebConfig = new AppleWebConfig();
Object.assign(appleWebConfig, config.apple);

// use the config instance the AppleID
await loadScript(appleWebConfig.sdk_url).catch((e) => console.error(e));
console.info('apple environment ready!');
}

/**
* login listener
*/
$('#apple-login').on('click', async () => {
if (!AppleID) {
showLoginLinkTip();
return;
}
try {
AppleID.auth.init({
clientId: appleWebConfig.identifier,
scope: appleWebConfig.scope,
redirectURI: appleWebConfig.login_callback,
usePopup: true,
});
if (!AppleID) {
showLoginLinkTip();
return;
}
const data: AppleSignInAPI.SignInResponseI = await AppleID.auth.signIn();
if (data) {
appleSignInSuccess(data);
}
} catch (e) {
appleSignInFail(e);
}
});

/**
* apple link listener
*/
$('#apple-link').on('click', async () => {
if (!AppleID) {
showLoginLinkTip();
return;
}
try {
AppleID.auth.init({
clientId: appleWebConfig.identifier,
scope: appleWebConfig.scope,
redirectURI: appleWebConfig.link_callback,
usePopup: true,
});

if (!AppleID) {
showLoginLinkTip();
return;
}

const data: AppleSignInAPI.SignInResponseI = await AppleID.auth.signIn();
if (data) {
appleLinkSuccess(data);
}
} catch (e) {
appleSignInFail(e);
}
});

服务端代码(主要验证idToken的合法性和自己的用户系统关联起来)

build.gradle

implementation 'com.nimbusds:nimbus-jose-jwt:9.0'

application.yml

apple:
identifier: info.xiaomo.app
login_callback: https://info.xiaomo.app/oauth/v1/login
link_callback: https://info.xiaomo.app/oauth/v1/link
sdk_url: https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/ja_JP/appleid.auth.js
verify_url: https://appleid.apple.com/auth/token
team_id: your teamId
scope: email name

配置的实体类

@Data
@ConfigurationProperties(prefix = "apple")
@Validated
public class AppleProperties {

private String identifier;

private String sdkUrl;

private String verifyUrl;

private String teamId;

private String scope;

private String loginCallback;

private String linkCallback;
}

配置文件和实体类关联

@Configuration
@EnableConfigurationProperties(AppleProperties.class)
@RequiredArgsConstructor
public class AppleConfiguration {}

登陆api


@autowird
private AppleProperties appleProperties;

/**
* Apple link
*
* @param idToken idToken
* @return SNSLoginBindCode
* @throws IllegalProviderProfileException IllegalProviderProfileException
*/
public SNSLoginBindCode link(String idToken, User user, AuthLogUtil.ClientInfo clientInfo)
throws IllegalProviderProfileException, SnsAlreayLinkException,
UserAlreadyLinkedSameProviderException {
AppleJwtPayload appleJwtPayload = verifyAppleJWTAndGetPayload(idToken);

String providerId = appleJwtPayload.getSub();
Optional<ProviderLink> providerLink =
platformService.findProviderLinkById(Provider.APPLE, providerId);

if (providerLink.isPresent()) {
if (!providerLink.get().getUserId().equals(providerId)) {
throw new SnsAlreayLinkException(providerId);
} else {
return SNSLoginBindCode.SNS_LINK_SUCCESS;
}
}

String userId = user.getId();
String userInfo =
AuthLogUtil.buildUserInfo(
clientInfo, Provider.APPLE.name(), userId, appleJwtPayload);
RootController.USER_PROFILE_LOGGER.info(userInfo);
ProviderProfile profile =
new ProviderProfile(Provider.APPLE, providerId, user.getDisplayName(), "");

Optional<ProviderLink> provider = platformService.getProvider(userId, Provider.APPLE);

if (provider.isPresent()) {
if (!provider.get().getUserId().equals(userId)) {
throw new UserAlreadyLinkedSameProviderException(userId, providerId);
} else {
return SNSLoginBindCode.SNS_LINK_SUCCESS;
}
}
platformService.linkProvider(user, profile);
return SNSLoginBindCode.SNS_LINK_SUCCESS;
}

private AppleJwtPayload verifyAppleJWTAndGetPayload(String idToken)
throws IllegalProviderProfileException {
try {
JWSObject jwt = JWSObject.parse(idToken);
JWSHeader appleJwtHeader = jwt.getHeader();
AppleJwtPayload appleJwtPayload =
JsonUtil.toJsonObject(jwt.getPayload().toString(), AppleJwtPayload.class);
jwtVerifyHandler(appleJwtHeader, appleJwtPayload, idToken);

return appleJwtPayload;

} catch (NoSuchAlgorithmException | ParseException | InvalidKeySpecException e) {
throw new IllegalProviderProfileException();
}
}

/**
* get the AuthKey from Apple
*
* @param kid kid
* @return AppleAuthKey
* @throws IllegalProviderProfileException IllegalProviderProfileException
*/
@SuppressWarnings("unchecked")
private AppleAuthKey getAuthKeys(String kid) throws IllegalProviderProfileException {
AppleAuthKey authKey = null;
try {
String res = HttpUtil.get("https://appleid.apple.com/auth/keys");
Map<String, Object> jsonObject = JsonUtil.toJsonObject(res, Map.class);
String keys = jsonObject.get("keys").toString();
List<LinkedTreeMap<String, String>> appleAuthKeys = JsonUtil.parseArray(keys);
for (LinkedTreeMap<String, String> appleAuthKeyMap : appleAuthKeys) {
AppleAuthKey appleAuthKey =
JsonUtil.toJsonObject(
JsonUtil.toJsonString(appleAuthKeyMap), AppleAuthKey.class);
if (appleAuthKey.getKid().equals(kid)) {
authKey = appleAuthKey;
break;
}
}
} catch (IOException e) {
throw new IllegalProviderProfileException();
}
if (authKey == null) {
throw new IllegalProviderProfileException();
}

return authKey;
}

/**
* verify jwt
*
* @param appleJwtHeader appleJwtHeader
* @param jwt jwt
*/
private void jwtVerifyHandler(JWSHeader appleJwtHeader, AppleJwtPayload payload, String jwt)
throws IllegalProviderProfileException, InvalidKeySpecException,
NoSuchAlgorithmException {

AppleAuthKey authKey = getAuthKeys(appleJwtHeader.getKeyID());
BigInteger bigIntModulus = new BigInteger(1, Base64.decodeBase64(authKey.getN()));
BigInteger bigIntPrivateExponent = new BigInteger(1, Base64.decodeBase64(authKey.getE()));
RSAPublicKeySpec keySpec = new RSAPublicKeySpec(bigIntModulus, bigIntPrivateExponent);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PublicKey key = keyFactory.generatePublic(keySpec);

JwtParser jwtParser = Jwts.parser().setSigningKey(key);
jwtParser.requireIssuer("https://appleid.apple.com");
jwtParser.requireAudience(payload.getAud());
jwtParser.requireSubject(payload.getSub());
try {
Jws<Claims> claim = jwtParser.parseClaimsJws(jwt);
if (claim != null && claim.getBody().containsKey("auth_time")) {
log.info("apple jwt verify successful");
return;
}
throw new IllegalProviderProfileException();
} catch (ExpiredJwtException e) {
log.error("apple identityToken expired", e);
throw new IllegalProviderProfileException();
} catch (Exception e) {
log.error("apple identityToken illegal", e);
throw new IllegalProviderProfileException();
}
}

参考

  1. Sign with in Apple,网站配置 Apple 登录
  2. Sign in with Apple NODE,web端接入苹果第三方登录
  3. java-如何在Nimbus JOSE JWT中验证令牌签名

后记

文章中贴的实例代码只是提供一个开发思路,实际业务开发中牵扯的内容比较多。像是一些零散的util方法、css样式、业务耦合较重的内容等等没有一一提及,所以直接拷贝代码的话肯定会有不少的依赖文件找不到。

文章目录
  1. 1. 知识储备
  2. 2. 前提准备
  3. 3. 代码演示
  4. 4. 触发登陆
  5. 5. 参考
  6. 6. 后记


twitter分享


如果想及时收到回复,可在 订阅中心Participating中勾选Email

Fork me on GitHub