LineCallbackController.java 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. package com.ruoyi.app.user;
  2. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  3. import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
  4. import com.ruoyi.app.utils.oauth.OAuthVerifyService;
  5. import com.ruoyi.common.annotation.Anonymous;
  6. import com.ruoyi.common.constant.CacheConstants;
  7. import com.ruoyi.common.core.controller.BaseController;
  8. import com.ruoyi.common.core.domain.model.LoginUserDto;
  9. import com.ruoyi.common.core.redis.RedisCache;
  10. import com.ruoyi.common.utils.ServletUtils;
  11. import com.ruoyi.common.utils.ip.AddressUtils;
  12. import com.ruoyi.common.utils.ip.IpUtils;
  13. import com.ruoyi.system.domain.InfoUser;
  14. import com.ruoyi.system.domain.InfoUserOauth;
  15. import com.ruoyi.system.mapper.InfoUserOauthMapper;
  16. import com.ruoyi.system.service.IInfoUserService;
  17. import com.ruoyi.system.utils.JwtUtil;
  18. import eu.bitwalker.useragentutils.UserAgent;
  19. import jakarta.servlet.http.HttpServletResponse;
  20. import org.slf4j.Logger;
  21. import org.slf4j.LoggerFactory;
  22. import org.springframework.beans.factory.annotation.Autowired;
  23. import org.springframework.beans.factory.annotation.Value;
  24. import org.springframework.web.bind.annotation.GetMapping;
  25. import org.springframework.web.bind.annotation.RequestMapping;
  26. import org.springframework.web.bind.annotation.RequestParam;
  27. import org.springframework.web.bind.annotation.RestController;
  28. import java.io.IOException;
  29. import java.net.URLEncoder;
  30. import java.nio.charset.StandardCharsets;
  31. import java.util.UUID;
  32. import java.util.concurrent.TimeUnit;
  33. /**
  34. * LINE 登录服务端回调(017-oauth-login 增量,2026-08-05)。
  35. *
  36. * <p><b>为什么需要它</b>:当用户走「唤起 LINE App / 系统浏览器」授权时,LINE 把重定向发给
  37. * 注册的 redirect_uri(后端 https URL),uniapp(另一个 App)无法在中间截获 code。这时必须由
  38. * 后端接住回调、换 token、完成登录,再把结果 302 跳回 App(自定义 scheme)。原 {@code /oauthLogin}
  39. * 保留不动,覆盖「前端自己拿到 code 再 POST 给后端」的另一种场景(H5 / webview / SDK)。
  40. *
  41. * <p><b>流程</b>:LINE → {@code GET /auth/line/callback?code=...&state=...}
  42. * → {@link OAuthVerifyService#verify verify("line", code)}(code 换 token 取 userId)
  43. * → 查 info_user_oauth:已绑定→签 JWT token;未绑定→生成 tempKey(needPhone)
  44. * → 302 跳到 {@code oauth.line.app-redirect}(如 com.twanmsdyh.app://oauthLogin)带结果:
  45. * <ul>
  46. * <li>已绑定:{@code <app-redirect>?token=xxx}</li>
  47. * <li>未绑定:{@code <app-redirect>?needPhone=1&tempKey=xxx}(App 弹手机号+短信码 UI 后调 /infouser/user/oauthBindPhone)</li>
  48. * <li>异常:{@code <app-redirect>?error=xxx}</li>
  49. * </ul>
  50. *
  51. * <p><b>redirect_uri 三方一致性</b>:LINE 要求「前端 authorize 的 redirect_uri」「后端换 token 的
  52. * redirect_uri」「LINE Console 回调白名单」三者完全一致,否则回 400 redirect_uri_mismatch。
  53. * 故 application.yml 的 {@code oauth.line.redirect-uri} 必须与 Console 一致(= 本端点地址)。
  54. *
  55. * @author foodie
  56. * @date 2026-08-05
  57. */
  58. @RestController
  59. @RequestMapping("/auth/line")
  60. public class LineCallbackController extends BaseController {
  61. private static final Logger log = LoggerFactory.getLogger(LineCallbackController.class);
  62. /** 与 InfoUserController.OAUTH_TEMP_PREFIX 一致:未绑定临时凭证 Redis 前缀(oauthBindPhone 消费) */
  63. private static final String OAUTH_TEMP_PREFIX = "oauth:bind:";
  64. @Autowired
  65. private OAuthVerifyService oauthVerifyService;
  66. @Autowired
  67. private IInfoUserService infoUserService;
  68. @Autowired
  69. private InfoUserOauthMapper infoUserOauthMapper;
  70. @Autowired
  71. private RedisCache redisCache;
  72. /** 后端登录后 302 跳回 App 的 scheme(App 注册该 scheme 接收 token/tempKey/error) */
  73. @Value("${oauth.line.app-redirect}")
  74. private String appRedirect;
  75. /**
  76. * LINE 服务端回调:接 code → 换 token → 登录 → 302 回 App。
  77. * 用 @Anonymous 放行(LINE 以浏览器/App 身份回调,无 token)。
  78. */
  79. @Anonymous
  80. @GetMapping("/callback")
  81. public void callback(@RequestParam(value = "code", required = false) String code,
  82. @RequestParam(value = "state", required = false) String state,
  83. HttpServletResponse response) throws IOException {
  84. try {
  85. if (code == null || code.isEmpty()) {
  86. log.warn("[OAuth][LINE] callback 缺 code 参数, state={}", state);
  87. redirectToApp(response, "error", "no_code");
  88. return;
  89. }
  90. log.info("[OAuth][LINE] callback 收到 code(len={}), state={}", code.length(), state);
  91. // code → userId(OAuthVerifyService.verifyLine:code 换 access_token 再取 profile)
  92. String providerUid = oauthVerifyService.verify("line", code);
  93. InfoUserOauth bind = infoUserOauthMapper.selectOne(
  94. new LambdaQueryWrapper<InfoUserOauth>()
  95. .eq(InfoUserOauth::getProvider, "line")
  96. .eq(InfoUserOauth::getProviderUid, providerUid));
  97. if (bind != null) {
  98. InfoUser u = infoUserService.getOne(new QueryWrapper<InfoUser>()
  99. .eq("user_id", bind.getUserId()).eq("status", 0).eq("del_flag", "0"));
  100. if (u == null) {
  101. log.warn("[OAuth][LINE] callback 已绑定但账号已停用 userId={}", bind.getUserId());
  102. redirectToApp(response, "error", "user_stopped");
  103. return;
  104. }
  105. String token = buildOauthToken(u, "line");
  106. log.info("[OAuth][LINE] callback 已绑定登录成功 userId={}", u.getUserId());
  107. response.sendRedirect(appRedirect + "?token=" + enc(token));
  108. return;
  109. }
  110. // 未绑定:缓存 {line, providerUid},返回 needPhone + tempKey(与 oauthLogin 同款,oauthBindPhone 消费)
  111. String tempKey = UUID.randomUUID().toString().replace("-", "");
  112. redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey, "line@" + providerUid, 5, TimeUnit.MINUTES);
  113. log.info("[OAuth][LINE] callback 未绑定,返回 needPhone providerUid={}", providerUid);
  114. response.sendRedirect(appRedirect + "?needPhone=1&tempKey=" + enc(tempKey));
  115. } catch (Exception e) {
  116. log.error("[OAuth][LINE] callback 异常", e);
  117. redirectToApp(response, "error", e.getMessage() == null ? "fail" : e.getMessage());
  118. }
  119. }
  120. /**
  121. * 签发三方登录 token(与 InfoUserController.issueOauthToken 同款,仅返回 token 串不包 AjaxResult)。
  122. * 此处刻意不复用 InfoUserController 的私有方法,避免改动已上线代码;两处逻辑保持一致。
  123. */
  124. private String buildOauthToken(InfoUser user, String provider) {
  125. redisCache.deleteKeys(CacheConstants.USER_TOKEN_KEY + user.getUserId() + ":" + "*");
  126. LoginUserDto dto = new LoginUserDto();
  127. dto.setUserId(user.getUserId());
  128. dto.setUserName(user.getPhone());
  129. dto.setProvider(provider);
  130. fillLoginUserInfo(dto);
  131. return JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, dto);
  132. }
  133. /** 填充登录设备/网络信息(IP、地点、浏览器、OS、登录时间),与 InfoUserController.fillLoginUserInfo 一致。 */
  134. private void fillLoginUserInfo(LoginUserDto userDto) {
  135. String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
  136. userDto.setIpaddr(ip);
  137. userDto.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
  138. UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
  139. userDto.setBrowser(userAgent.getBrowser().getName());
  140. userDto.setOs(userAgent.getOperatingSystem().getName());
  141. userDto.setLoginTime(System.currentTimeMillis());
  142. }
  143. /** 302 跳回 App:app-redirect + ?<key>=<value>(value 做 URL 编码)。 */
  144. private void redirectToApp(HttpServletResponse response, String key, String value) throws IOException {
  145. response.sendRedirect(appRedirect + "?" + key + "=" + enc(value));
  146. }
  147. private static String enc(String s) {
  148. return URLEncoder.encode(s == null ? "" : s, StandardCharsets.UTF_8);
  149. }
  150. }