DEV Community

Cover image for Implement GitHub OAuth2 Authorization Login in Springboot
YYT-0901
YYT-0901

Posted on

Implement GitHub OAuth2 Authorization Login in Springboot

GitHub 登录本质上是 OAuth2 授权码模式

  1. 把用户重定向到 GitHub 授权页面
  2. GitHub 授权后会跳转回你的回调接口,带上一个 code,你拿这个 codeaccess_token,再用 access_token 换用户信息,最后在你系统里"登录或注册",签发自己的 JWT 返回给前端

流程图

flowchart TD
    A["用户点击 GitHub 登录按钮"]
    B["前端 location.href = 后端 /auth/loginByGithub"]
    C["后端 redirect 到 GitHub 授权页"]
    D["用户在 GitHub 点击授权"]
    E["GitHub redirect 到 /auth/callback/github?code=xxx"]
    F["后端 code 换 token,登录成功"]
    M["token存在redis30s,返回随机数code用于换取token"]
    G["后端 redirect 到前端页面?code=xxx"]
    H["前端页面加载"]
    I["从 URL 获取 code"]
    N["从服务器获取token等信息"]
    J["保存 token 到 localStorage"]
    K["跳转到首页"]
    L["后续 API 请求携带 token"]

    A --> B --> C --> D --> E --> F --> M --> G --> H --> I --> N --> J --> K --> L

0. 先去 GitHub 申请 OAuth App

GitHub → Settings → Developer settings → OAuth Apps → New OAuth App,拿到 Client IDClient Secret,并配置 Authorization callback URL(比如 http://your-domain.com/api/auth/callback/github)。

1. 配置文件

oauth:
  github:
    client-id: 你的client_id
    client-secret: 你的client_secret
    redirect-uri: http://your-domain.com/api/auth/callback/github
    frontend-callback-url: http://your-frontend-domain.com/callback
    authorize-url: https://github.com/login/oauth/authorize
    token-url: https://github.com/login/oauth/access_token
    user-info-url: https://api.github.com/user
Enter fullscreen mode Exit fullscreen mode
@Data
@Component
@ConfigurationProperties(prefix = "oauth.github")
public class GithubOAuthProperties {
    private String clientId;
    private String clientSecret;
    private String redirectUri;
    private String authorizeUrl;
    private String tokenUrl;
    private String userInfoUrl;
    private String frontendCallbackUrl;
}
Enter fullscreen mode Exit fullscreen mode

2. 第三方账号绑定表

不要把 github_id 直接塞进 sys_user 表,因为以后可能还要支持微信/QQ登录,用一张独立表比较好扩展:

CREATE TABLE `sys_user_oauth` (
  `id` bigint NOT NULL,
  `user_id` bigint NOT NULL COMMENT '关联本地用户ID',
  `platform` varchar(20) NOT NULL COMMENT '第三方平台,如github/wechat',
  `open_id` varchar(100) NOT NULL COMMENT '第三方平台唯一标识',
  `nickname` varchar(50) DEFAULT NULL COMMENT '第三方昵称',
  `avatar` varchar(255) DEFAULT NULL COMMENT '第三方头像',
  `create_at` datetime DEFAULT NULL,
  `update_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_platform_openid` (`platform`,`open_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方账号绑定表';
Enter fullscreen mode Exit fullscreen mode

3. controller

@Slf4j
@RestController
@RequestMapping("/auth")
@Tag(name = "认证相关接口")
public class AuthController {

    @Autowired
    private GithubOAuthProperties githubOAuthProperties;
    @Autowired
    private SysUserOauthService sysUserOauthService;
    @Autowired
    private RedisService redisService;

    @GetMapping("/loginByGithub")
    @Operation(summary = "通过Github登录", description = "通过Github登录,跳转到Github授权页面")
    public void loginByGithub(HttpServletResponse response) throws IOException {
        String state = UUID.randomUUID().toString(); // 防CSRF,实际项目应存入redis校验
        redisService.set("oauth:github:state:" + state, "1", 5, TimeUnit.MINUTES);
        String url = githubOAuthProperties.getAuthorizeUrl()
                + "?client_id=" + githubOAuthProperties.getClientId()
                + "&redirect_uri=" + URLEncoder.encode(githubOAuthProperties.getRedirectUri(), StandardCharsets.UTF_8)
                + "&scope=read:user user:email"
                + "&state=" + state;
        response.sendRedirect(url);
    }

    @GetMapping("/callback/github")
    @Operation(summary = "Github回调", description = "Github回调,保存登录信息,第一次登录则自动创建新用户,跳转回前端页面,参数带上放在redis中的值(tokenCode),后续用于获取token")
    public void callback(@RequestParam String code, @RequestParam(required = false) String state, HttpServletResponse response) throws IOException {
        if (state == null || !redisService.hasKey("oauth:github:state:" + state)) {
            log.info("Invalid state parameter {}", state);
            throw new BusinessException("Invalid state parameter");
        }
        LoginVo loginVo = sysUserOauthService.loginByGithub(code);
        // 重定向到前端页面,把token存储在redis,暴露code用于替换token 30s提高安全性,code当URL参数带过去
        String tokenCode = UUID.randomUUID().toString();
        redisService.set("oauth:token:code" + tokenCode, loginVo, 30, TimeUnit.SECONDS);
        String redirectUrl = githubOAuthProperties.getFrontendCallbackUrl() + "?code=" + tokenCode;
        response.sendRedirect(redirectUrl);
    }

    @GetMapping("/getToken")
    @Operation(summary = "获取Token")
    public Result getToken(@RequestParam String code) {
        if (!redisService.hasKey("oauth:token:code" + code)) {
            throw new BusinessException(ResultFailEnum.PARAMETER_ERROR);
        }
        LoginVo loginVo = (LoginVo) redisService.get("oauth:token:code" + code);
        return Result.ok(loginVo);
    }
}
Enter fullscreen mode Exit fullscreen mode

redisService参考:https://dev.to/yyt0901/common-dependency-configuration-for-spring-boot-354-java-1721-1li7CTRL + F to search redisService in website)

@Data
public class LoginVo {
    private String token;
    private Long userId;
    private String nickname;
    private String avatar;
}
Enter fullscreen mode Exit fullscreen mode

4. SysUserOauthServiceImpl

@Autowired
private GithubOAuthProperties githubOAuthProperties;
@Autowired
private RestTemplate restTemplate;
@Autowired
private SysUserService sysUserService;
@Autowired
private SysUserRoleService sysUserRoleService;

private static final String PLATFORM_GITHUB = "github";

@Override
public LoginVo loginByGithub(String code) {
    // ---------- 1. 用code换access_token ----------
    String accessToken = getGithubAccessToken(code);

    // ---------- 2. 用access_token换用户信息 ----------
    GithubUserInfo githubUser = getGithubUserInfo(accessToken);

    // ---------- 3. 查是否已绑定过 ----------
    SysUserOauth oauth = this.getOne(new LambdaQueryWrapper<SysUserOauth>()
            .eq(SysUserOauth::getPlatform, PLATFORM_GITHUB)
            .eq(SysUserOauth::getOpenId, githubUser.getId()));

    Date curDate = new Date();
    SysUser sysUser;

    if (oauth != null) {
        // ---------- 3a. 已绑定,直接登录 ----------
        sysUser = sysUserService.getById(oauth.getUserId());
        if (sysUser == null || sysUser.getStatus() == 0) {
            throw new BusinessException("账号不存在或已被禁用");
        }
        // 顺便更新一下第三方昵称/头像
        oauth.setNickname(githubUser.getName());
        oauth.setAvatar(githubUser.getAvatarUrl());
        oauth.setUpdateAt(curDate);
        this.updateById(oauth);
    } else {
        // ---------- 3b. 未绑定,自动注册新用户 ----------
        sysUser = new SysUser();
        sysUser.setId(IdTools.generateId());
        sysUser.setUsername("github_" + githubUser.getLogin());
        sysUser.setNickname(githubUser.getName() != null ? githubUser.getName() : githubUser.getLogin());
        sysUser.setAvatar(githubUser.getAvatarUrl());
        sysUser.setPassword(""); // 三方登录用户无本地密码,禁止走密码登录
        sysUser.setStatus(1);
        sysUser.setCreateAt(curDate);
        sysUser.setUpdateAt(curDate);
        sysUserService.save(sysUser);

        // 分配默认角色
        SysUserRole userRole = new SysUserRole();
        userRole.setId(IdTools.generateId());
        userRole.setUserId(sysUser.getId());
        userRole.setRoleId(RoleEnum.USER.getId());
        userRole.setCreateAt(curDate);
        sysUserRoleService.save(userRole);

        // 保存绑定关系
        SysUserOauth newOauth = new SysUserOauth();
        newOauth.setId(IdTools.generateId());
        newOauth.setUserId(sysUser.getId());
        newOauth.setPlatform(PLATFORM_GITHUB);
        newOauth.setOpenId(String.valueOf(githubUser.getId()));
        newOauth.setNickname(githubUser.getName());
        newOauth.setAvatar(githubUser.getAvatarUrl());
        newOauth.setCreateAt(curDate);
        newOauth.setUpdateAt(curDate);
        this.save(newOauth);
    }

    sysUser.setLastLoginTime(curDate);
    sysUserService.updateById(sysUser);

    // ---------- 4. Sa-Token 登录,自动生成并管理token ----------
    StpUtil.login(sysUser.getId());
    String token = StpUtil.getTokenValue();

    LoginVo vo = new LoginVo();
    vo.setToken(token);
    vo.setUserId(sysUser.getId());
    vo.setNickname(sysUser.getNickname());
    vo.setAvatar(sysUser.getAvatar());
    return vo;
}

/**
 * 用code换access_token
 */
private String getGithubAccessToken(String code) {
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_JSON);
    headers.setAccept(List.of(MediaType.APPLICATION_JSON));

    Map<String, String> body = new HashMap<>();
    body.put("client_id", githubOAuthProperties.getClientId());
    body.put("client_secret", githubOAuthProperties.getClientSecret());
    body.put("code", code);
    body.put("redirect_uri", githubOAuthProperties.getRedirectUri());

    HttpEntity<Map<String, String>> request = new HttpEntity<>(body, headers);

    ResponseEntity<Map> response = restTemplate.postForEntity(
            githubOAuthProperties.getTokenUrl(), request, Map.class);

    Map<String, Object> result = response.getBody();
    if (result == null || result.get("access_token") == null) {
        log.error("GitHub换取access_token失败: {}", result);
        throw new BusinessException("GitHub授权失败");
    }
    return (String) result.get("access_token");
}

/**
 * 用access_token查询GitHub用户信息
 */
private GithubUserInfo getGithubUserInfo(String accessToken) {
    HttpHeaders headers = new HttpHeaders();
    headers.set("Authorization", "token " + accessToken);
    headers.setAccept(List.of(MediaType.APPLICATION_JSON));

    HttpEntity<Void> request = new HttpEntity<>(headers);

    ResponseEntity<GithubUserInfo> response = restTemplate.exchange(
            githubOAuthProperties.getUserInfoUrl(), HttpMethod.GET, request, GithubUserInfo.class);

    GithubUserInfo userInfo = response.getBody();
    if (userInfo == null || userInfo.getId() == null) {
        throw new BusinessException("获取GitHub用户信息失败");
    }
    return userInfo;
}
Enter fullscreen mode Exit fullscreen mode
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class GithubUserInfo {
    private Long id;
    private String login;       // GitHub用户名
    private String name;        // 昵称,可能为空
    @JsonProperty("avatar_url")
    private String avatarUrl;
    private String email;       // 可能为空(用户设置了隐私)
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)