封面

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 {}

验证jwt


@autowird
private AppleProperties appleProperties;


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, idToken);

return appleJwtPayload;

} catch (ParseException e) {
throw new IllegalProviderProfileException();
}
}

/**
* verify jwt
*
* @param appleJwtHeader appleJwtHeader
* @param jwt jwt
*/
private void jwtVerifyHandler(JWSHeader appleJwtHeader, String jwt)
throws IllegalProviderProfileException {
try {
Optional<JWKSet> publicKeyCache = findPublicKey();
if (publicKeyCache.isEmpty()) {
reloadPublicKeyCache();
}
if (publicKeyCache.isEmpty()) {
throw new IllegalProviderProfileException();
}

RSAKey rsaKey =
publicKeyCache.get().getKeyByKeyId(appleJwtHeader.getKeyID()).toRSAKey();
SignedJWT signedJWT = SignedJWT.parse(jwt);
JWSVerifier verifier = new RSASSAVerifier(rsaKey);
boolean verify = signedJWT.verify(verifier);
if (!verify) {
throw new IllegalProviderProfileException();
}

@NotNull List<String> audience = signedJWT.getJWTClaimsSet().getAudience();
@Nullable Date expirationTime = signedJWT.getJWTClaimsSet().getExpirationTime();
@Nullable String issuer = signedJWT.getJWTClaimsSet().getIssuer();

if (!"https://appleid.apple.com".equals(issuer)) {
throw new IllegalProviderProfileException();
}

if (expirationTime == null || expirationTime.before(new Date())) {
log.error(
"token expired: {} {}->{}",
jwt,
expirationTime == null ? null : expirationTime.getTime(),
new Date().getTime());
throw new IllegalProviderProfileException();
}
if (!audience.contains(appleProperties.getIdentifier())) {
throw new IllegalProviderProfileException();
}

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

@Scheduled(fixedRate = 5 * 60 * 1000) // auto reload/5 min
private void reloadPublicKeyCache() {
try {
URL authKeyUrl = new URL("https://appleid.apple.com/auth/keys");
JWKSet publicKeys = JWKSet.load(authKeyUrl);
publicKeysCache.invalidateAll();
publicKeysCache.put("key", publicKeys);
} catch (IOException | ParseException e) {
log.error("get apple auth key error:{}", e.getMessage());
}
}

public Optional<JWKSet> findPublicKey() {
return Optional.ofNullable(publicKeysCache.getIfPresent("key"));
}

参考

  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