今天在做自己的一个自用项目,由于不想记忆太多账号密码,也不想设置一个简单的密码并不安全,为了防止了密码忘记了每次都找,自建了 Gitea 私服,就打算直接接入第三方授权登录,Java 已经有这方面比较全的开箱即用的整合第三方登录的开源组件,就是 JustAuth,直接引入组件简单配置就能完成Github、Gitee、支付宝、新浪微博、微信、Google、Facebook、Twitter、StackOverflow等国内外数大多数主流的第三方平台授权登录策略。

由于自己是要集成 Gitea 私服,现在最新版都还没有支持,不知道是基于什么考虑,官方仓库有 issues177 就有人提过,但是一直处于打开状态。所以今天直接按照官方把这块代码完善了。

OAuth2 协议介绍

做之前还是先大概了解一下 oauth2 授权协议,OAuth 2.0 协议是一种三方授权协议,目前大部分的第三方登录与授权都是基于该协议的标准或改进实现。网上一大把资源都能查到,我这里直接用 AI 翻译的大白话介绍一下:

Auth 2.0 是一种授权机制,简单来说,它能让你在不把自己账号密码告诉其他应用的情况下,允许该应用访问你在另一个服务中的某些信息或功能。

比如说,你想在某个健身应用里分享你的运动数据到微信朋友圈。如果你直接把微信账号密码给健身应用,那太不安全了,万一应用有漏洞或者被黑客攻击,你的微信账号就危险了。这时候 OAuth 2.0 就派上用场了,你点击健身应用的分享按钮后,会跳转到微信的授权页面,你可以选择授权健身应用访问你的哪些信息,比如运动步数等,然后健身应用就可以根据你授权的范围,把相应数据分享到朋友圈,而它并不知道你的微信账号密码,也无法获取你其他未授权的信息。这样既方便又安全,能让不同的应用之间安全地进行信息交互和功能授权。

JustAuth 扩展 Gitea OAuth2 授权登录

假设整个流程开始之前,用户已经登录,那么整个授权流程如下:

  1. 客户端请求授权服务器
  2. 授权授权服务的授权端点重定向用户至授权交互页面,并询问用户是否授权
  3. 如果用户许可,则授权端点验证客户端的身份,并发放授权码给客户端
  4. 客户端拿到授权码之后,携带授权码请求授权服务器的令牌端点下发访问令牌
  5. 令牌端点验证客户端的身份和授权码,通过则下发访问令牌和刷新令牌(可选)
  6. 客户端拿到访问令牌后,携带访问令牌请求资源服务器上的受保护资源
  7. 资源服务器验证客户端身份和访问令牌,通过则响应受保护资源访问请求

整个流程中,客户端都无法接触到用户的登录凭证信息,客户端通过访问令牌请求受保护资源,用户可以通过对授权操作的控制来间接控制客户端对于受保护资源的访问权限范围和时效。

OAuth2.0定义的5种角色

  • 客户端(Client)

客户端是 OAuth 服务的接入方,其目的是请求用户存储在资源服务器上的受保护资源,客户端可以移动应用、网页应用,以及电视应用等等。

  • 用户代理(User Agent)

用户代理是用户参与互联网的工具,一般可以理解为浏览器。

  • 资源所有者(Resource Owner)

受保护资源所属的实体,比如资源的持有人等,下文的用户即资源所有者。

  • 授权服务器(Authorization Server)

授权服务器的主要职责是验证资源所有者的身份,并依据资源所有者的许可对第三方应用下发令牌。

  • 资源服务器(Resource Server)

托管资源的服务器,能够接收和响应持有令牌的资源访问请求,可以与授权服务器是同一台服务器,也可以分开。

     +--------+                               +---------------+
     |        |--(A)- Authorization Request ->|   Resource    |
     |        |                               |     Owner     |
     |        |<-(B)-- Authorization Grant ---|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(C)-- Authorization Grant -->| Authorization |
     | Client |                               |     Server    |
     |        |<-(D)----- Access Token -------|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(E)----- Access Token ------>|    Resource   |
     |        |                               |     Server    |
     |        |<-(F)--- Protected Resource ---|               |
     +--------+                               +---------------+

流程解析

  • (A) 用户打开客户端以后,客户端要求用户给予授权。
  • (B) 用户同意给予客户端授权。
  • (C) 客户端使用上一步获得的授权,向认证服务器申请令牌。
  • (D) 认证服务器客户端进行认证以后,确认无误,同意发放令牌
  • (E) 客户端使用令牌,向资源服务器申请获取资源。
  • (F) 资源服务器确认令牌无误,同意向客户端开放资源。

OAuth 2.0 的授权流程可以用一个生活场景来简单理解,比如你去餐厅吃饭需要寄存外套:

  1. 用户请求服务(Client 向 Resource Owner 请求授权)

    • 你到餐厅后想寄存外套,但存包处不归餐厅管,而是由第三方安保公司(如「安心寄存」)负责。
  2. 跳转授权中心(Redirect 到 Authorization Server)

    • 服务员给你一张「寄存授权单」,让你去安保公司柜台填信息。你拿着单子走到安保公司窗口。
  3. 用户身份验证(Resource Owner 登录)

    • 安保人员问你姓名和手机号,你提供后,他们验证确实是你本人(类似输入账号密码登录)。
  4. 选择授权范围(Consent)

    • 安保人员给你一份《寄存授权协议》,问你:

      • 只寄存外套(授权只读权限)
      • 外套 + 背包(授权读写权限)
      • 允许代取(授权离线访问)
    • 你勾选「只寄存外套」后签字。
  5. 获取授权码(Authorization Code)

    • 安保人员给你一张「临时寄存牌」(授权码),上面有编号但不包含你的个人信息。
  6. 换取访问令牌(Access Token)

    • 你把临时寄存牌交给餐厅服务员,服务员拿这牌子去安保公司后台,用他们的「商家 ID」和「秘钥」换真正的「存包柜钥匙」(访问令牌)。
  7. 访问资源(Access Resource Server)

    • 服务员用钥匙打开存包柜帮你存外套,整个过程你不用把身份证或家门钥匙交给餐厅,而且钥匙只能开指定柜子(对应授权范围)。
  8. 令牌有效期

    • 存包柜钥匙可能有时间限制(如 4 小时有效),过期后服务员得拿你的临时寄存牌重新换钥匙(刷新令牌机制)。

JustAuth 集成 Gitea 授权

参考官方文档自定义第三方平台的OAuth 只需要实现实现AuthSource接口以及自定义一个Request。

下面给出 Gitea 的实现类,Gitea 的授权断点可以再这里找到:https://docs.gitea.com/zh-cn/development/oauth2-provider

Authorization Endpoint/login/oauth/authorize
Access Token Endpoint/login/oauth/access_token
OpenID Connect UserInfo/login/oauth/userinfo
JSON Web Key Set/login/oauth/keys

Oauth2 一共有四种授权模式:1)授权码模式(Authorization Code Grant);2)隐式授权模式(Implicit Grant);3)资源所有者密码凭证模式(Resource Owner Password Credentials Grant);4)以及客户端凭证模式(Client Credentials Grant),目前 Gitea 仅支持Authorization Code Grant)标准。

AuthGiteaSource.class:

import me.zhyd.oauth.config.AuthSource;
import me.zhyd.oauth.request.AuthDefaultRequest;

/**
 * gitea Oauth2 默认接口说明
 * * https://www.51it.wang
 *
 * @author lcry
 */
public enum AuthGiteaSource implements AuthSource {

    /**
     * 自己搭建的 gitea 私服
     */
    GITEA {
        /**
         * 授权的api
         */
        @Override
        public String authorize() {
            return AuthGiteaRequest.SERVER_URL + "/login/oauth/authorize";
        }

        /**
         * 获取accessToken的api
         */
        @Override
        public String accessToken() {
            return AuthGiteaRequest.SERVER_URL + "/login/oauth/access_token";
        }

        /**
         * 获取用户信息的api
         */
        @Override
        public String userInfo() {
            return AuthGiteaRequest.SERVER_URL + "/login/oauth/userinfo";
        }

        /**
         * 平台对应的 AuthRequest 实现类,必须继承自 {@link AuthDefaultRequest}
         */
        @Override
        public Class<? extends AuthDefaultRequest> getTargetClass() {
            return AuthGiteaRequest.class;
        }

    }
}

AuthGiteaRequest.class:

import cn.hutool.core.lang.Dict;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.cache.AuthStateCache;
import me.zhyd.oauth.config.AuthConfig;
import me.zhyd.oauth.exception.AuthException;
import me.zhyd.oauth.model.AuthCallback;
import me.zhyd.oauth.model.AuthToken;
import me.zhyd.oauth.model.AuthUser;
import me.zhyd.oauth.request.AuthDefaultRequest;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.json.utils.JsonUtils;

/**
 * Gitea授权登录请求实现
 * https://www.51it.wang
 * @author lcry
 */
@Slf4j
public class AuthGiteaRequest extends AuthDefaultRequest {

    public static final String SERVER_URL = SpringUtils.getProperty("justauth.type.gitea.server-url");

    /**
     * 设定归属域
     */
    public AuthGiteaRequest(AuthConfig config) {
        super(config, AuthGiteaSource.GITEA);
    }

    public AuthGiteaRequest(AuthConfig config, AuthStateCache authStateCache) {
        super(config, AuthGiteaSource.GITEA, authStateCache);
    }

    @Override
    public AuthToken getAccessToken(AuthCallback authCallback) {
        String body = doPostAuthorizationCode(authCallback.getCode());
        Dict object = JsonUtils.parseMap(body);
        // oauth/token 验证异常
        if (object.containsKey("error")) {
            throw new AuthException(object.getStr("error_description"));
        }
        // user 验证异常
        if (object.containsKey("message")) {
            throw new AuthException(object.getStr("message"));
        }
        return AuthToken.builder()
            .accessToken(object.getStr("access_token"))
            .refreshToken(object.getStr("refresh_token"))
            .idToken(object.getStr("id_token"))
            .tokenType(object.getStr("token_type"))
            .scope(object.getStr("scope"))
            .build();
    }

    @Override
    protected String doPostAuthorizationCode(String code) {
        HttpRequest request = HttpRequest.post(source.accessToken())
            .form("client_id", config.getClientId())
            .form("client_secret", config.getClientSecret())
            .form("grant_type", "authorization_code")
            .form("code", code)
            .form("redirect_uri", config.getRedirectUri());
        HttpResponse response = request.execute();
        return response.body();
    }

    @Override
    public AuthUser getUserInfo(AuthToken authToken) {
        String body = doGetUserInfo(authToken);
        Dict object = JsonUtils.parseMap(body);
        // oauth/token 验证异常
        if (object.containsKey("error")) {
            throw new AuthException(object.getStr("error_description"));
        }
        // user 验证异常
        if (object.containsKey("message")) {
            throw new AuthException(object.getStr("message"));
        }
        return AuthUser.builder()
            .uuid(object.getStr("sub"))
            .username(object.getStr("name"))
            .nickname(object.getStr("preferred_username"))
            .avatar(object.getStr("picture"))
            .email(object.getStr("email"))
            .token(authToken)
            .source(source.toString())
            .build();
    }

}

配置文件格式:

justauth:
  type:
    gitea:
      server-url: https://demo.gitea.com
      client-id: b3c5*********8f13910
      client-secret: gto_5t*********wiyykoa
      redirect-uri: ${justauth.address}/social-callback?source=gitea

所有参数信息直接通过登录 Gitea 个人账号-用户设置-应用-创建新的 OAuth2 应用程序。

验证流程:

JustAuth 扩展 Gitea OAuth2 授权登录

JustAuth 扩展 Gitea OAuth2 授权登录

JustAuth 扩展 Gitea OAuth2 授权登录

总结

通过本文你可以通过扩展 JustAuth 组件实现所有支持 Oauth2 授权登录,本文代码本来打算直接提交给 JustAuth,但是看了了仓库还有很多 pull request 作者没有合并,并且最近更新也是 5 个月前,可能作者是觉得已经开放了自定义扩展,可以自己内部扩展就行了,没必要都实现,所以就先这样吧。

参考链接

OAuth2.0 协议原理

OAuth流程

JustAuth-demo 示例仓库

justauth-spring-boot-starter 仓库

文章目录