苹果登录授权

<!DOCTYPE html>

<html lang="zh-CN">

<head>

  <meta charset="UTF-8">

  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <title>Web苹果登录 Demo</title>

  <style>

    * {

      margin: 0;

      padding: 0;

      box-sizing: border-box;

      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;

    }



    body {

      background: #f5f5f7;

      padding: 50px 20px;

      text-align: center;

    }



    .container {

      max-width: 600px;

      margin: 0 auto;

      background: #fff;

      padding: 40px 20px;

      border-radius: 12px;

      box-shadow: 0 2px 10px rgba(0,0,0,0.05);

    }



    h1 {

      color: #1d1d1f;

      font-size: 24px;

      margin-bottom: 30px;

    }



    /* 苹果登录按钮(遵循苹果设计规范) */

    .apple-login-btn {

      display: inline-flex;

      align-items: center;

      justify-content: center;

      background: #000;

      color: #fff;

      border: none;

      border-radius: 8px;

      padding: 14px 32px;

      font-size: 16px;

      cursor: pointer;

      gap: 8px;

      transition: background 0.2s;

    }



    .apple-login-btn:hover {

      background: #333;

    }



    .apple-icon {

      width: 18px;

      height: 18px;

      fill: #fff;

    }



    /* 结果展示样式 */

    .user-info {

      margin-top: 30px;

      padding: 20px;

      border: 1px solid #eee;

      border-radius: 8px;

      text-align: left;

      display: none;

    }



    .error {

      margin-top: 20px;

      color: #ff3b30;

      font-size: 14px;

      display: none;

    }



    .show {

      display: block;

    }

  </style>

</head>

<body>

  <div class="container">

    <h1>Web/PWA 苹果登录 Demo</h1>



    <!-- 苹果登录按钮 -->

    <button class="apple-login-btn" id="appleLoginBtn">

      <svg class="apple-icon" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">

        <path d="M17.712 10.536c-.088-.176-.256-.368-.48-.488-.24-.128-.544-.192-.848-.192-.288 0-.576.064-.848.192-.224.12-.4.312-.48.488-.08.176-.128.368-.128.576 0 .208.048.4.128.576.08.176.256.368.48.488.24.128.544.192.848.192.288 0 .576-.064.848-.192.224-.12.4-.312.48-.488.08-.176.128-.368.128-.576 0-.208-.048-.4-.128-.576zM11.104 4.008c-.88 0-1.616.32-2.192.96-.576.64-.864 1.392-.864 2.256 0 .864.288 1.616.864 2.256.576.64 1.312.96 2.192.96.88 0 1.616-.32 2.192-.96.576-.64.864-1.392.864-2.256 0-.864-.288-1.616-.864-2.256-.576-.64-1.312-.96-2.192-.96zm-7.008 1.104c-.4 0-.752.144-1.056.432-.304.288-.456.656-.456 1.104 0 .448.152.816.456 1.104.304.288.656.432 1.056.432.4 0 .752-.144 1.056-.432.304-.288.456-.656.456-1.104 0-.448-.152-.816-.456-1.104-.304-.288-.656-.432-1.056-.432zm12.352 0c-.4 0-.752.144-1.056.432-.304.288-.456.656-.456 1.104 0 .448.152.816.456 1.104.304.288.656.432 1.056.432.4 0 .752-.144 1.056-.432.304-.288.456-.656.456-1.104 0-.448-.152-.816-.456-1.104-.304-.288-.656-.432-1.056-.432z" fill="#000"/>

      </svg>

      用 Apple 登录

    </button>



    <!-- 登录结果展示 -->

    <div class="user-info" id="userInfo">

      <h3 style="margin-bottom: 15px; color: #1d1d1f;">登录成功</h3>

      <p style="margin: 8px 0; color: #424245;">用户 ID:<span id="userId"></span></p>

      <p style="margin: 8px 0; color: #424245;">邮箱:<span id="userEmail"></span></p>

      <p style="margin: 8px 0; color: #424245;">姓名:<span id="userName"></span></p>

    </div>



    <!-- 错误提示 -->

    <div class="error" id="errorMsg"></div>

  </div>



  <script>

    // ========== 配置项(替换为你的苹果开发者信息) ==========

    const CONFIG = {

      APPLE_SERVICE_ID: 'xxxx', // 你的 Service ID

      APPLE_REDIRECT_URI: 'xxxx', // 回调域名(与开发者后台一致)

      BACKEND_VERIFY_URL: 'xxxx' // 后端验证接口

    };



    // ========== DOM 元素获取 ==========

    const appleLoginBtn = document.getElementById('appleLoginBtn');

    const userInfoEl = document.getElementById('userInfo');

    const errorMsgEl = document.getElementById('errorMsg');

    const userIdEl = document.getElementById('userId');

    const userEmailEl = document.getElementById('userEmail');

    const userNameEl = document.getElementById('userName');



    // ========== 工具函数 ==========

    // 生成随机状态码(防止CSRF)

    function generateState() {

      return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);

    }



    // 显示错误信息

    function showError(msg) {

      errorMsgEl.textContent = msg;

      errorMsgEl.classList.add('show');

      userInfoEl.classList.remove('show');

    }



    // 显示用户信息

    function showUserInfo(info) {

      userIdEl.textContent = info.sub || '未知';

      userEmailEl.textContent = info.email || '未授权获取';

      userNameEl.textContent = (info.name?.firstName || '') + ' ' + (info.name?.lastName || '');

      userInfoEl.classList.add('show');

      errorMsgEl.classList.remove('show');

    }



    // ========== 苹果登录核心逻辑 ==========

    // 处理登录按钮点击

    appleLoginBtn.addEventListener('click', handleAppleLogin);



    function handleAppleLogin() {

      // 生成并存储状态码

      const state = generateState();

      localStorage.setItem('apple_login_state', state);



      // 构造苹果授权URL

      const appleAuthUrl = new URL('https://appleid.apple.com/auth/authorize');

      appleAuthUrl.searchParams.append('response_type', 'code id_token');

      appleAuthUrl.searchParams.append('response_mode', 'form_post'); // 前端获取token

      appleAuthUrl.searchParams.append('client_id', CONFIG.APPLE_SERVICE_ID);

      appleAuthUrl.searchParams.append('redirect_uri', CONFIG.APPLE_REDIRECT_URI);

      appleAuthUrl.searchParams.append('state', state);

      appleAuthUrl.searchParams.append('scope', 'name email'); // 请求姓名和邮箱

      appleAuthUrl.searchParams.append('nonce', generateState()); // 防止重放攻击



      // 打开授权弹窗

      const authWindow = window.open(

        appleAuthUrl.toString(),

        '_blank',

        'width=600,height=700,top=100,left=100'

      );



      // 监听弹窗消息

      const messageListener = (e) => {
        console.log("e------------------------------"+e)

        if (e.origin !== window.location.origin) return;

        if (!e.data?.appleLogin) return;



        const { code, id_token, state: returnState, error } = e.data;



        // 处理错误

        if (error) {

          showError(`授权失败:${error}`);

          authWindow?.close();

          window.removeEventListener('message', messageListener);

          return;

        }



        // 校验状态码

        const storedState = localStorage.getItem('apple_login_state');

        if (returnState !== storedState) {

          showError('状态码校验失败,可能是CSRF攻击');

          authWindow?.close();

          window.removeEventListener('message', messageListener);

          return;

        }



        // 调用后端验证Token

        verifyAppleToken(id_token, code).then(() => {

          authWindow?.close();

          window.removeEventListener('message', messageListener);

        });

      };



      window.addEventListener('message', messageListener);

    }



    // 后端验证苹果Token

    async function verifyAppleToken(idToken, code) {

      try {

        const response = await fetch(CONFIG.BACKEND_VERIFY_URL, {

          method: 'POST',

          headers: {

            'Content-Type': 'application/json',

          },

          body: JSON.stringify({

            id_token: idToken,

            code: code,

            client_id: CONFIG.APPLE_SERVICE_ID

          })

        });



        const result = await response.json();



        if (result.success) {

          showUserInfo(result.userInfo);

        } else {

          showError(result.message || 'Token验证失败');

        }

      } catch (err) {

        showError(`验证失败:${err.message}`);

      }

    }



    // ========== 监听回调Hash变化 ==========

    window.addEventListener('hashchange', () => {

      const hash = window.location.hash.substring(1);

      const params = new URLSearchParams(hash);



      const code = params.get('code');

      const idToken = params.get('id_token');

      const state = params.get('state');

      const error = params.get('error');



      // 向主窗口发送消息

      if (error) {

        window.parent.postMessage({ appleLogin: { error } }, window.location.origin);

      } else if (code && idToken && state) {

        window.parent.postMessage(

          { appleLogin: { code, id_token: idToken, state } },

          window.location.origin

        );

      }



      // 清空hash,避免重复处理

      window.history.replaceState({}, document.title, window.location.pathname);

    });

  </script>

</body>

</html>
@PostMapping("/login/web/apple")
    public ResponseEntity<?> appleCallback(
            @RequestParam("code") String code,
            @RequestParam("id_token") String idToken,
            @RequestParam(value = "user", required = false) String user,
            @RequestParam(value = "state", required = false) String state,
            CommonHeader header,
            HttpServletResponse response
    ) {
        try {
            // 调用 Service 方法统一验证
            AppleVerifyResultDto result = verifyAppleToken(
                    idToken,
                    code,
                    AppleConfig.SERVICE_ID
            );
            log.info("Apple 登录验证结果:{}", result);
            if (!result.isSuccess()) {
                log.error("Apple 登录验证失败:{}", result.getMessage());
                // 验证失败
                return ResponseEntity.badRequest().body(result);
            }
            AppleUserInfo userInfo = result.getUserInfo();
            log.info("result.getUserInfo() 登录用户信息:{}", userInfo);
            ThirdParam thirdParam = new ThirdParam();

            // 首次授权用户信息(仅一次,可解析 user 参数)
            if (user != null && !user.isEmpty()) {
                ObjectMapper mapper = new ObjectMapper();
                JsonNode userNode = mapper.readTree(user);

                String firstName = userNode.path("name").path("firstName").asText("");
                String lastName = userNode.path("name").path("lastName").asText("");
                if (StringUtils.isAnyBlank(firstName,lastName)) {
                    log.info("Apple 用户首次登录lastName:{},firstName:{}", lastName,firstName);
                    String safeFirstName = StringUtils.defaultString(firstName);
                    String safeLastName = StringUtils.defaultString(lastName);
                    thirdParam.setNickname(safeFirstName+safeLastName);
                }
            }
            UserCache userCache = userCacheService.findUserByThird(userInfo.getSub(), ThirdLoginEnum.APPLE);
            if (userCache != null){
                thirdParam.setThirdId(userCache.getApple());
            }else {
                thirdParam.setThirdId(userInfo.getSub());
                thirdParam.setEmail(userInfo.getEmail());
            }
            thirdParam.setThirdType(ThirdLoginEnum.APPLE.getDataFlag());
            log.info("Apple 登录邮箱参数:{}", userInfo.getEmail());


            AuthSuccessResult authSuccessResult = authService.loginThird(header, thirdParam);
            String redirectUrl = AppleConfig.REDIRECT_URL;

            if (authSuccessResult != null && StringUtils.isNotBlank(authSuccessResult.getToken()) ){
                redirectUrl = redirectUrl+"?token="+authSuccessResult.getToken()+"&username="+authSuccessResult.getUsername()+"&action="+authSuccessResult.getAction();
            }
            log.info("Apple 登录跳转地址:{}", redirectUrl);

            response.sendRedirect(redirectUrl);
            log.info("页面跳转成功 result:{}", authSuccessResult);
            return ResponseEntity.ok(authSuccessResult);
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(
                    Map.of(
                            "success", false,
                            "message", e.getMessage()
                    )
            );
        }
    }

    /**
     * 验证 Apple 登录 token 并获取用户信息
     *
     * @param idToken  前端传来的 id_token
     * @param code     前端传来的 code
     * @param clientId 服务端配置的 Service ID
     * @return Map 包含 success 和 userInfo 或 message
     */
    public AppleVerifyResultDto verifyAppleToken(String idToken, String code, String clientId) {
        AppleVerifyResultDto result = new AppleVerifyResultDto();
        AppleUserInfo userInfo = new AppleUserInfo();

        try {
            // 校验 id_token
            JWTClaimsSet claims = AppleIdTokenVerifier.verify(idToken, clientId);
            userInfo.setSub(claims.getSubject());
            userInfo.setEmail(claims.getStringClaim("email"));
            userInfo.setExpiresAt(claims.getExpirationTime());

            // 用 code 换 token
            String clientSecret = AppleClientSecretUtil.generateClientSecret();
            MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
            form.add("client_id", clientId);
            form.add("client_secret", clientSecret);
            form.add("code", code);
            form.add("grant_type", "authorization_code");

            Map tokenResMap = webClient.post()
                    .uri("https://appleid.apple.com/auth/token")
                    .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                    .bodyValue(form)
                    .retrieve()
                    .bodyToMono(Map.class)
                    .block(Duration.ofSeconds(10));

            // 转成 DTO
            AppleTokenResDto tokenRes = new ObjectMapper().convertValue(tokenResMap, AppleTokenResDto.class);
            userInfo.setTokenRes(tokenRes);
            result.setSuccess(true);
            result.setUserInfo(userInfo);

        } catch (Exception e) {
            log.error("Apple 登录异常", e);
            result.setSuccess(false);
            result.setMessage(e.getMessage());
            result.setUserInfo(userInfo); // 即使失败也返回部分信息
        }

        return result;
    }
@Data
public class AppleVerifyResultDto {
    private boolean success;
    private String message;           // 验证失败信息,可选
    private AppleUserInfo userInfo;   // 用户信息
}

@Data
public class AppleUserInfo {
    private String sub;            // 用户唯一ID
    private String email;          // 邮箱
    private Date expiresAt;        // id_token 过期时间
    private AppleTokenResDto tokenRes; // token 结果
    private String fullName;        // 首次登录用户姓名(可选)
    private String lastName;        // 首次登录用户姓名(可选)
    private String firstName;        // 首次登录用户姓名(可选)
    private String state;
    private String code;
    private String idToken;
}
@Data
public class AppleTokenResDto {
    private String access_token;
    private String token_type;
    private Integer expires_in;
    private String refresh_token;
    private String id_token;
}

提供苹果的回调接口, 和tiktok不同的是 ,苹果用户信息只能拿到邮箱 用户唯一id,其它信息需要授权

posted @ 2025-12-18 09:14  Fyy发大财  阅读(1)  评论(0)    收藏  举报