苹果登录授权
<!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,其它信息需要授权
浙公网安备 33010602011771号