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

 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
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;
  };
})();

触发登陆

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

监听按钮点击事件

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

});

监听成功/失败(可选)

1
2
3
4
5
6
document.addEventListener('AppleIDSignInOnSuccess', (data: any) => {
  console.log(data);
});
document.addEventListener('AppleIDSignInOnFailure', (error: any) => {
  console.error(error.detail);
});

实例

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

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

application.yml

1
2
3
4
5
6
7
8
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

配置的实体类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@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;
}

配置文件和实体类关联

1
2
3
4
@Configuration
@EnableConfigurationProperties(AppleProperties.class)
@RequiredArgsConstructor
public class AppleConfiguration {}

验证jwt

 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

@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样式、业务耦合较重的内容等等没有一一提及,所以直接拷贝代码的话肯定会有不少的依赖文件找不到。

署名 - 非商业性使用 - 禁止演绎 4.0