CC 咖啡猫的工作空间 Coding Space

Sa-Token 接入第三方登录

Sa-Token 本身不直接支持 OAuth2 第三方登录(如微信、Google、Gitee),但可以通过扩展或集成 JustAuth 实现。本文档讲解两种主流方案。


1. 第三方登录核心原理

1.1 OAuth2 授权码流程

用户点击"微信登录"
    ↓
跳转到微信授权页面(携带 client_id、redirect_uri、state)
    ↓
用户同意授权
    ↓
微信回调 redirect_uri?code=xxxxx&state=xxxx
    ↓
服务端用 code 换 token(需配合 client_secret)
    ↓
用 token 获取用户信息(昵称、头像、唯一ID)
    ↓
在本地查找/创建用户,生成自己的 Token

1.2 Sa-Token 的角色定位

Sa-Token 负责本地 Token 管理,第三方登录只负责"用外部账号获取本地身份":

第三方平台(微信/GitHub) → 换取用户信息
                              ↓
              查询本地用户表(绑定关系)
                              ↓
                    绑定/注册本地账号
                              ↓
                    Sa-Token.login(userId)  ← Sa-Token 在这里接入

2. 方案一:集成 JustAuth(推荐)

JustAuth 是最完整的第三方授权开源库,支持 70+ 平台。

2.1 引入依赖

<dependency>
    <groupId>com.xkcoding.justauth</groupId>
    <artifactId>justauth-spring-boot-starter</artifactId>
    <version>1.6.2</version>
</dependency>

2.2 配置第三方应用信息

# application.yml
justauth:
  enabled: true
  clients:
    gitee:
      client-id: 你的Gitee Client ID
      client-secret: 你的Gitee Client Secret
      redirect-uri: http://localhost:8080/oauth/gitee/callback
    github:
      client-id: 你的GitHub Client ID
      client-secret: 你的GitHub Client Secret
      redirect-uri: http://localhost:8080/oauth/github/callback

2.3 Controller 实现

@RestController
@RequestMapping("/oauth")
public class OAuthController {

    @Autowired
    private AuthController authController;  // JustAuth 内置控制器

    // 跳转到第三方授权页面
    @GetMapping("/login/gitee")
    public void loginGitee(HttpServletResponse response) throws IOException {
        response.sendRedirect(buildAuthUrl("gitee"));
    }

    @GetMapping("/login/github")
    public void loginGithub(HttpServletResponse response) throws IOException {
        response.sendRedirect(buildAuthUrl("github"));
    }

    // 授权回调:第三方同意后跳转回来
    @GetMapping("/callback/{source}")
    public Result<?> callback(@PathVariable String source,
                              @RequestParam String code,
                              @RequestParam String state) {
        // 1. 用 code 换取第三方 token
        AuthToken authToken = authController.auth(source, code, state);

        // 2. 用 token 获取第三方用户信息
        AuthUser authUser = authController.userInfo(source, authToken);

        // 3. 绑定本地用户或注册新用户
        User localUser = userService.bindOrCreate(authUser);

        // 4. Sa-Token 登录
        StpUtil.login(localUser.getId());
        String token = StpUtil.getTokenValue();

        return Result.ok().put("token", token);
    }
}

2.4 本地用户绑定逻辑

@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    public User bindOrCreate(AuthUser authUser) {
        // 1. 查找是否已绑定过该第三方账号
        String openId = authUser.getId();  // 第三方的唯一ID
        User user = userMapper.findByOpenId(authUser.getSource(), openId);

        if (user != null) {
            // 已绑定:直接返回
            return user;
        }

        // 2. 未绑定:检查是否已登录(用户已登录则绑定)
        if (StpUtil.isLogin()) {
            // 已登录:绑定第三方账号
            User loginUser = userMapper.findById(StpUtil.getLoginIdAsLong());
            loginUser.setOpenId(authUser.getSource(), openId);
            userMapper.update(loginUser);
            return loginUser;
        }

        // 3. 未登录也未绑定:自动注册(或引导完善信息)
        User newUser = new User();
        newUser.setNickname(authUser.getNickname());
        newUser.setAvatar(authUser.getAvatar());
        newUser.setOpenId(authUser.getSource(), openId);
        userMapper.insert(newUser);
        return newUser;
    }
}

2.5 数据库设计

CREATE TABLE user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    nickname VARCHAR(50),
    avatar VARCHAR(255),
    created_at DATETIME
);

-- 第三方绑定表
CREATE TABLE user_bind (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    source VARCHAR(20) NOT NULL,    -- gitee, github, wechat...
    openid VARCHAR(100) NOT NULL,   -- 第三方唯一ID
    UNIQUE KEY uk_source_openid (source, openid)
);

3. 方案二:手动实现(不走 JustAuth)

适合特定平台或自定义需求。

3.1 以 Gitee 为例:获取 AccessToken

@GetMapping("/oauth/gitee/callback")
public Result<?> giteeCallback(@RequestParam String code) {
    // 1. 用 code 换 AccessToken
    String tokenUrl = "https://gitee.com/oauth/token?" +
        "grant_type=authorization_code&" +
        "code=" + code + "&" +
        "client_id=" + giteeClientId + "&" +
        "redirect_uri=" + giteeRedirectUri + "&" +
        "client_secret=" + giteeClientSecret;

    HttpResponse tokenResponse = HttpUtil.createPost(tokenUrl).execute();
    String body = tokenResponse.body();
    // 解析 {"access_token":"xxx","token_type":"bearer",...}

    // 2. 解析 token
    JSONObject tokenJson = JSONUtil.parseObj(body);
    String accessToken = tokenJson.getStr("access_token");

    // 3. 用 token 获取用户信息
    String userUrl = "https://gitee.com/api/v5/user?access_token=" + accessToken;
    HttpResponse userResponse = HttpUtil.createGet(userUrl).execute();
    JSONObject userJson = JSONUtil.parseObj(userResponse.body());

    String openId = userJson.getStr("id");
    String nickname = userJson.getStr("login");
    String avatar = userJson.getStr("avatar_url");

    // 4. 绑定/注册本地用户
    User user = userService.bindOrCreate("gitee", openId, nickname, avatar);

    // 5. Sa-Token 登录
    StpUtil.login(user.getId());
    return Result.ok().put("token", StpUtil.getTokenValue());
}

4. 多平台登录整合

4.1 统一登录入口

@RestController
@RequestMapping("/auth")
public class AuthController {

    @GetMapping("/render/{source}")
    public void renderAuth(@PathVariable String source, HttpServletResponse response) throws IOException {
        String authUrl;
        switch (source) {
            case "gitee":
                authUrl = "https://gitee.com/oauth/authorize?" +
                    "client_id=" + giteeClientId + "&" +
                    "redirect_uri=" + giteeRedirectUri + "&" +
                    "response_type=code";
                break;
            case "github":
                authUrl = "https://github.com/login/oauth/authorize?" +
                    "client_id=" + githubClientId + "&" +
                    "redirect_uri=" + githubRedirectUri + "&" +
                    "scope=read:user";
                break;
            default:
                throw new BizException("不支持的登录方式");
        }
        response.sendRedirect(authUrl);
    }
}

4.2 前端调用

// Vue / React 中调用
// 跳转到第三方授权页
window.location.href = '/auth/render/gitee'

// 授权成功后,自动回调到 /oauth/callback/gitee
// 后端返回 token,前端存储

5. 注意事项

5.1 为什么要用 state 参数

防止 CSRF 攻击:

// 登录前:生成随机 state 存入 Session
String state = UUID.randomUUID().toString();
session.setAttribute("oauth_state", state);
response.sendRedirect(authUrl + "&state=" + state);

// 回调时:校验 state 是否匹配
String savedState = session.getAttribute("oauth_state").toString();
if (!savedState.equals(state)) {
    throw new BizException("state 不匹配,可能是 CSRF 攻击");
}

5.2 第三方登录的安全建议

  1. HTTPS:授权回调必须走 HTTPS
  2. code 一次性:code 只能使用一次,换完 Token 后立即失效
  3. client_secret 保密:后端使用,绝不暴露在前端
  4. 绑定关系唯一source + openid 唯一索引,防止重复绑定

5.3 Sa-Token 的局限

Sa-Token 本身不提供:

  • 第三方登录的 SDK
  • OAuth2 协议实现
  • 第三方用户信息获取

这些需要配合 JustAuth 或自行实现。Sa-Token 只负责本地 Token 生成和验证

5.4 与 Spring Security OAuth2 的对比

维度 Sa-Token + JustAuth Spring Security OAuth2 Client
第三方登录 支持(通过 JustAuth) 原生支持
学习成本 低(Sa-Token 简单) 高(OAuth2 协议复杂)
Token 管理 Sa-Token Spring Security 内置
接入平台数量 70+(JustAuth) 有限(需自定义)
适合场景 国内项目(微信/QQ/钉钉) 国际化(Google/GitHub)