一、前置知识

1、OAuth协议

​ OAuth 不是一个具体的框架或服务,而是一个验证授权(Authorization)的开放标准,用来授权第三方应用获取用户数据。常见的 SpringSecurity 框架即基于此标准实现。

​ OAuth 通常是为了解决开放平台资源服务的认证授权而生,但随着时代发展,越来越多系统的用户登录也是用了该标准(基本是因为所用的安全框架基于这个标准)。

​ OAuth 主要有 OAuth 1.0a 和 OAuth 2.0 两个版本,并且二者完全不同,且不兼容,OAuth2.0 是目前广泛使用的版本,我们多数谈论 OAuth 时,为 OAuth2.0。

2、为什么需要OAuth协议

​ 在之前,认证授权多为用户名和密码进行验证,而在三方登录兴起之后,例如某论坛要支持 QQ 登录,支持微信登录,我们不可能在这个论坛上输入自己的 qq 账号密码,因此 OAuth 的出现就是为了解决访问资源的安全性以及灵活性。OAuth 使得第三方应用对资源的访问更加安全。

应用场景

​ 假如你正在“网站A”上冲浪,看到一篇帖子表示非常喜欢,当你情不自禁的想要点赞时,它会提示你进行登录操作。

​ 打开登录页面你会发现,除了最简单的账户密码登录外,还为我们提供了微博、微信、QQ等快捷登录方式。假设选择了快捷登录,它会提示我们扫码或者输入账号密码进行登录。

​ 登录成功之后便会将QQ/微信的昵称和头像等信息回填到“网站A”中,此时你就可以进行点赞操作了。

3、OAuth2.0的角色

  • Client:客户端(即第三方应用),它本身不会存储用户快捷登录的账号和密码,只是通过资源拥有者的授权去请求资源服务器的资源,即例子中的网站A;
  • Resource Owner:资源所有者,通常是用户,即例子中拥有QQ/微信账号的用户;
  • Authorization Server:授权服务器,可以提供身份认证和用户授权的服务器,即给客户端颁发token和校验token,如QQ;
  • Resource Server:资源服务器,存储用户资源的服务器,即例子中的QQ/微信存储的用户信息,通常和授权服务器属于同一应用;

4、认证流程

下面的流程图取自The OAuth 2.0 Authorization Framework#1.2

+--------+                               +---------------+
| |--(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 ---| |
+--------+ +---------------+

​ 如图是oauth2官网的认证流程图,我们来分析一下:

  • A客户端向资源拥有者发送授权申请;
  • B资源拥有者同意客户端的授权,返回授权码;
  • C客户端使用授权码向认证服务器申请令牌token;
  • D认证服务器对客户端进行身份校验,认证通过后发放令牌;
  • E客户端拿着认证服务器颁发的令牌去资源服务器请求资源;
  • F资源服务器校验令牌的有效性,返回给客户端资源信息;

为了大家更好的理解,特地画了一张图:

​ 首先引入三个角色:

  • 用户A:可以理解成你自己
  • 网站B:可以理解成 OSChina
  • 第三方C:可以理解成 Github

​ 需求:你(用户A)想通过 Github(第三方C) 登录网站B(OSChina)。

5、OAuth2.0中四种授权方式

为了获得访问令牌(Token),客户端需要先从资源所有者(用户)那里获得授权。授权是以授权许可(Grant Type)的形式来表示的。OAuth定义了四种授权类型:

1、授权码模式(Authorization Code)

功能最完整,流程最严密的授权模式。国内各大服务提供商(微信、QQ、微 博、淘宝 、百度)都采用此模式进行授权。可以确定是用户真正同意授权;而且令牌是认证服务器发放给第三方应用的服务器,而不是浏览器上。结合普通服务器端应用使用(web端常用的授权方式)

当用户访问资源时,比如在网易云音乐中使用第三方登录功能,例如QQ登录,那么这里的资源就是用户的QQ昵称和头像等信息。此时第三方应用(网易云音乐)将发送请求到授权服务器(QQ)去获取授权,此时授权服务器(QQ)将返回一个界面给用户,用户需要登录到QQ,并同意授权网易云音乐获得某些信息(资源)。当用户同意授权后,授权服务器将返回一个授权码(Authorization Code)给第三方应用,此时第三方应用在通过client_id、client_secret(这是需要第三方应用在授权服务器去申请的)和授权码去获得Access Token和Refresh Token,此时授权码将失效。然后就是第三方应用通过Access Token去资源服务器请求资源了,资源服务器校验Access Token成功后将返回资源给第三方应用。

2、简化模式,又叫隐式授权(Implicit)

令牌是发放给浏览器的,oauth客户端运行在浏览器中,通过JS脚本去申请令牌。而不是发放给第三方应用的服务器。结合结合移动应用或 Web App 使用

它和授权码模式类似,只不过少了获取授权码的步骤,是直接获取令牌token的,且没有Refresh Token,适用于公开的浏览器单页应用。因为令牌直接从授权服务器返回,所以没有安全保证,令牌容易因为被拦截窃听而泄露。

3、密码模式(Resource Owner Password Credentials)

将用户名和密码传过去,直接获取 access_token 。适用于受信任客户端应用,例如同个组织的内部或外部应用

用户同意授权动作是在第三方应用上完成 ,而不是在认证服务器上。第三方应用申请令牌时,直接带着用户名密码去向 认证服务器申请令牌。这种方式认证服务器无法断定用户是否真的授权了,用户名密码可能是第三方应用盗取来的。

密码模式一般用于自己平台登录,毕竟如果用于第三方登录,用户需要把自己 github 的密码告诉百度贴吧明显十分危险!

适用范围:**只适用于应用是受信任的场景。**一个典型的例子是同一个企业内部的不同产品要使用本企业的 Oauth2.0 体系。在这种情况下,由于是同个企业,不需要向用户展示“xxx将获取以下权限”等字样并询问用户的授权意向,而只需进行用户的身份认证即可。这个时候,只需要用户输入凭证并直接传递给鉴权服务器进行授权即可。

4、客户端证书模式(Client credentials)

用得少。当一个第三应用自己本身需要获取资源(而不是以用户的名义),而不是获取用户的资源时,客户端模式十分有用。适用于客户端调用主服务API型应用(比如百度API Store)

客户端模式一般用于公共接口的授权,表示我 Github 知道你是哪个平台请求的,但不需要知道你是哪个用户,例如某些字典接口,不能对外暴露,但是又必须让例如百度贴吧它先在我平台注册才能访问这个接口

客户端模式已经不太属于oauth2的范畴了,用户直接在客户端进行注册,然后客户端去认证服务器获取令牌时不需要携带用户信息,完全脱离了用户,也就不存在授权问题了。

PS:如何选择,根据具体业务和各模式特点综合考虑

6、怎么实现 OAuth2.0 标准的授权服务 ?

OAuth2.0 的授权模式大概分为 授权码模式简化模式密码模式客户端模式和最新扩展 OIDC模式(open id connect)等等,每种模式的请求逻辑略有不同。

一个 OAuth2.0 服务大致会有以下端点(可理解为页面或接口)

  • a. 授权端点(Authorization Endpoint)
    • 用途:用于用户认证和授权请求的起点。
    • 示例:类似微信扫码登录后出现的页面,或某论坛支持 github 登录,点击跳转到 github 的授权用户信息给某论坛的确认页面。
    • 端点:/authorize
  • b. 令牌端点(Token Endpoint)
    • 用途:用于获取访问令牌和刷新令牌。
    • 示例:例如授权码模式下,通过 code 发起请求获取 access_token
    • 端点:/token
  • c. 用户信息端点(Userinfo Endpoint):
    • 用途:用于获取关联于访问令牌的用户信息。
    • 示例:通过 access_token 获取用户 JSON 信息。
    • 端点:/userinfo

一个 OAuth2.0 服务必定会有 client_idclient_secret,服务端会通过 client_id 去识别谁请求的登录。若我们的系统只需要自己用户去登录,甚至后端写固定值也无所谓,若我们的系统需要支持第三方应用来授权登录,则需要将每个第三方应用对应的 client_id 等信息生成并保存,需要后续去做识别认证。

二、JustAuth的使用

1、什么是JustAuth

​ JustAuth,如你所见,它仅仅是一个第三方授权登录工具类库,它可以让我们脱离繁琐的第三方登录 SDK,让登录变得So easy! 网址如下:JustAuth

​ JustAuth 集成了诸如:Github、Gitee、支付宝、新浪微博、微信、Google、Facebook、Twitter、StackOverflow等国内外数十家第三方平台。更多请参考已集成的平台

2、JustAuth的使用

#### 2.1、引入(Maven)
# 截止文章发布时最新的稳定版本为:v1.16.7
<dependency>
<groupId>me.zhyd.oauth</groupId>
<artifactId>JustAuth</artifactId>
<version>{latest-version}</version>
</dependency>

2.2、 名词解释

本文将就JustAuth中涉及到的一些配置、关键词做一下简单说明,方便使用者理解、使用。

  • 开发者 指使用JustAuth的开发者
  • 第三方 指开发者对接的第三方网站,比如:QQ平台、微信平台、微博平台
  • 用户 指最终服务的真实用户
JustAuth中的关键词

以下内容了解后,将会使你更容易地上手JustAuth。

  • clientId 客户端身份标识符(应用id),一般在申请完Oauth应用后,由第三方平台颁发,唯一
  • clientSecret 客户端密钥,一般在申请完Oauth应用后,由第三方平台颁发
  • redirectUri 开发者项目中的有效api地址。用户在确认第三方平台授权(登录)后,第三方平台会重定向到该地址,并携带code等参数
  • state 用来保持授权会话流程完整性,防止CSRF攻击的安全的随机的参数,由开发者生成
  • alipayPublicKey 支付宝公钥。当选择支付宝登录时,必传该值,由开发者生成
  • unionId 是否需要申请unionid,目前只针对qq登录。注:qq授权登录时,获取unionid需要单独发送邮件申请权限。如果个人开发者账号中申请了该权限,可以将该值置为true,在获取openId时就会同步获取unionId。参考链接:UnionID介绍(opens new window)
  • stackOverflowKey Stack Overflow 登陆时需单独提供的key,由第三方平台颁发
  • agentId 企业微信登陆时需单独提供该值,由第三方平台颁发,为授权方的网页应用ID
  • source JustAuth支持的第三方平台,比如:GITHUB、GITEE等
  • uuid 一般为第三方平台的用户ID。以下几个平台需特别注意:
    • 钉钉、抖音:uuid 为用户的 unionid
    • 微信公众平台登录、京东、酷家乐、美团:uuid 为用户的 openId
    • 微信开放平台登录、QQ:uuid 为用户的 openId,平台支持获取unionidunionidAuthToken 中(如果支持),在登录完成后,可以通过 response.getData().getToken().getUnionId() 获取
    • Google:uuid 为用户的 subsub为Google的所有账户体系中用户唯一的身份标识符,详见:OpenID Connect

注:建议通过uuid + source的方式唯一确定一个用户,这样可以解决用户身份归属的问题。因为 单个用户ID 在某一平台中是唯一的,但不能保证在所有平台中都是唯一的

2.3 基本流程

使用JustAuth总共分三步(这三步也适合于JustAuth支持的任何一个平台):

  1. 申请注册第三方平台的开发者账号
  2. 创建第三方平台的应用,获取配置信息(accessKey, secretKey, redirectUri)
  3. 使用该工具实现授权登陆
// 创建授权request
AuthRequest authRequest = new AuthGiteeRequest(AuthConfig.builder()
.clientId("clientId")
.clientSecret("clientSecret")
.redirectUri("redirectUri")
.build());
// 生成授权页面
authRequest.authorize("state");
// 授权登录后会返回code(auth_code(仅限支付宝))、state,1.8.0版本后,可以用AuthCallback类作为回调接口的参数
// 注:JustAuth默认保存state的时效为3分钟,3分钟内未使用则会自动清除过期的state
authRequest.login(callback);

注意:JustAuth从v1.14.0 (opens new window)开始默认集成了的simple-http (opens new window)作为HTTP通用接口(更新说明见JustAuth 1.14.0版本正式发布!完美解耦HTTP工具 (opens new window)),鉴于一般项目中都已经集成了HTTP工具,比如OkHttp3、apache HttpClient、hutool-http,因此为了减少不必要的依赖,从v1.14.0 (opens new window)开始JustAuth将不会默认集成hutool-http,如果开发者的项目是全新的或者项目内没有集成HTTP实现工具,请自行添加对应的HTTP实现类,备选依赖如下:hutool-http、httpclient、okhttp等

2.3.1国外平台

由于 Q 的限制,在使用国外平台时,需要额外配置 httpConfig,如下:

AuthRequest authRequest = new AuthGoogleRequest(AuthConfig.builder()
.clientId("Client ID")
.clientSecret("Client Secret")
.redirectUri("应用回调地址")
// 针对国外平台配置代理
.httpConfig(HttpConfig.builder()
// Http 请求超时时间
.timeout(15000)
// host 和 port 请修改为开发环境的参数
.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10080)))
.build())
.build());

注意使用代理时,必须开启全局代理! 开启全局代理! 开启全局代理!,不能只开启浏览器代理。

2.3.2、HttpConfig 配置说明
  • timeout: http 请求超时时间
  • proxy
    • host: 本地一般为127.0.0.1,如果部署到服务器,可以配置为公网 IP
    • port: 需要根据使用的唯皮嗯软件修改,以我本地使用的某款工具为例,查看代理端口

​ 本地如果支持科学上网,就用自己本地的代理端口即可,如果不支持科学上网,可以去网上找一些免费的代理IP进行测试(请自行操作)。

​ 友情提示,经测试,需要单独配置 httpConfig 的平台有:

  • Github
  • Google
  • Facebook
  • Pinterest
  • Twitter
  • 其他待补充

2.4、API分解

JustAuth的核心就是一个个的request,每个平台都对应一个具体的request类,所以在使用之前,需要就具体的授权平台创建响应的request

// 创建授权request
AuthRequest authRequest = new AuthGiteeRequest(AuthConfig.builder()
.clientId("clientId")
.clientSecret("clientSecret")
.redirectUri("redirectUri")
.build());
2.4.1、获取授权链接
String authorizeUrl = authRequest.authorize("state");

获取到authorizeUrl后,可以手动实现redirect到authorizeUrl

伪代码

/**
*
* @param source 第三方授权平台,以本例为参考,该值为gitee(因为上面声明的AuthGiteeRequest)
*/
@RequestMapping("/render/{source}")
public void renderAuth(@PathVariable("source") String source, HttpServletResponse response) throws IOException {
AuthRequest authRequest = getAuthRequest(source);
String authorizeUrl = authRequest.authorize(AuthStateUtils.createState());
response.sendRedirect(authorizeUrl);
}

注:state建议必传!stateOAuth的流程中的主要作用就是保证请求完整性,防止CSRF风险,此处传的state将在回调时传回

2.4.2、登录(获取用户信息)
AuthResponse response = authRequest.login(callback);

授权登录后会返回code(auth_code(仅限支付宝)、authorization_code(仅限华为))、state,1.8.0版本后,用AuthCallback类作为回调接口的入参

伪代码

/**
*
* @param source 第三方授权平台,以本例为参考,该值为gitee(因为上面声明的AuthGiteeRequest)
*/
@RequestMapping("/callback/{source}")
public Object login(@PathVariable("source") String source, AuthCallback callback) {
AuthRequest authRequest = getAuthRequest(source);
AuthResponse response = authRequest.login(callback);
return response;
}

注:第三方平台中配置的授权回调地址,以本文为例,在创建授权应用时的回调地址应为:[host]/callback/gitee

2.4.3、刷新token

注:refresh功能,并不是每个平台都支持

AuthResponse response = authRequest.refresh(AuthToken.builder().refreshToken(token).build());

伪代码

/**
*
* @param source 第三方授权平台,以本例为参考,该值为gitee(因为上面声明的AuthGiteeRequest)
* @param token login成功后返回的refreshToken
*/
@RequestMapping("/refresh/{source}")
public Object refreshAuth(@PathVariable("source") String source, String token){
AuthRequest authRequest = getAuthRequest(source);
return authRequest.refresh(AuthToken.builder().refreshToken(token).build());
}
2.4.5、取消授权

注:revoke功能,并不是每个平台都支持

AuthResponse response =authRequest.revoke(AuthToken.builder().accessToken(token).build());

伪代码

/**
*
* @param source 第三方授权平台,以本例为参考,该值为gitee(因为上面声明的AuthGiteeRequest)
* @param token login成功后返回的accessToken
*/
@RequestMapping("/revoke/{source}/{token}")
public Object revokeAuth(@PathVariable("source") String source, @PathVariable("token") String token) throws IOException {
AuthRequest authRequest = getAuthRequest(source);
return authRequest.revoke(AuthToken.builder().accessToken(token).build());
}

三、基于SpringBoot项目的集成(使用的是授权码模式)

1、配置文件

代码如下:

justauth:
enabled: true
extend-auth-source-class: online.junmowen.boot.base.module.support.oauth2.constant.AuthExtendSource
type:
FEISHU:
client-id:
client-secret:
redirect-uri: http://localhost:8081/oauth/callback
MY_GITLAB:
client-id:
client-secret:
redirect-uri: http://localhost:8081/oauth/callback
GITEE:
client-id:
client-secret:
redirect-uri: http://localhost:8081/oauth/callback
cache:
# default type of cache is default, options: default, redis, custom
type: redis
# 默认:"JUSTAUTH:STATE:",type为default时无需配置
key-prefix: "JUNMOWEN:JUSTAUTH:STATE:"
# 默认:3 minutes,type为default时无需配置
timeout: 3m

PS:各平台的client_id和client_secret自行到各开发者平台或者应用去申请;同时本文集成了2种缓存实现,而使用的缓存是Redis,也支持自定义实现

  1. 默认实现由JustAuth提供AuthDefaultStateCache
  2. starter提供了基于redis的缓存实现AuthRedisStateCache
  3. 自定义缓存实现

2、各基本配置或者相关文件(无脑复制即可)

2.1、cache包
import me.zhyd.oauth.cache.AuthDefaultStateCache;
import me.zhyd.oauth.cache.AuthStateCache;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.lang.NonNull;
import org.springframework.util.StringUtils;

import java.util.concurrent.TimeUnit;

/**
* JustAuth 在Redis中存储和检索OAuth2认证过程中的状态信息,支持设置缓存键前缀和过期时间
* A redis implementation of {@link AuthStateCache}
*
* @author junmowen
* @since 2025-12-22
* @see AuthDefaultStateCache
*/
public class AuthRedisStateCache implements AuthStateCache {

private final StringRedisTemplate redisTemplate;

private final JustAuthCacheProperties cacheProperties;

public AuthRedisStateCache(StringRedisTemplate redisTemplate, JustAuthCacheProperties cacheProperties) {
this.redisTemplate = redisTemplate;
this.cacheProperties = cacheProperties;
}

@Override
public void cache(String key, String value) {
if (key == null || value == null) {
return;
}
this.cache(key, value, this.cacheProperties.getTimeout().toMillis());
}

@Override
public void cache(String key, String value, long timeout) {
if (key == null || value == null) {
return;
}
this.redisTemplate.opsForValue().set(this.getCacheKey(key), value, timeout, TimeUnit.MILLISECONDS);
}

@Override
public String get(String key) {
if (key == null) {
return null;
}
return this.redisTemplate.opsForValue().get(this.getCacheKey(key));
}

@Override
public boolean containsKey(String key) {
if (key == null) {
return false;
}
Long expire = this.redisTemplate.getExpire(this.getCacheKey(key), TimeUnit.MILLISECONDS);
return (expire != null && expire > 0L);
}

@NonNull
private String getCacheKey(String key) {
if (!StringUtils.hasText(this.cacheProperties.getKeyPrefix())) {
return JustAuthCacheProperties.DEFAULT_KEY_PREFIX + key;
}

if (this.cacheProperties.getKeyPrefix().endsWith(":")) {
return this.cacheProperties.getKeyPrefix() + key;
}

return this.cacheProperties.getKeyPrefix() + ":" + key;
}
}
import lombok.Data;
import me.zhyd.oauth.cache.AuthCacheConfig;
import online.junmowen.boot.base.module.support.oauth2.constant.CacheType;

import java.time.Duration;

/**
* JustAuth缓存相关的配置属性
* 包括缓存类型、键前缀和超时时间等配置项。默认使用"JUSTAUTH:STATE:"作为键前缀,默认超时时间为3分钟。
*
* @author junmowen
* @since 2025-12-22
*/
@Data
public class JustAuthCacheProperties {

/**
* 默认缓存键前缀
*/
public static final String DEFAULT_KEY_PREFIX = "JUSTAUTH:STATE:";

/**
* 缓存类型
*/
private CacheType type = CacheType.DEFAULT;

/**
* 缓存键前缀
*/
private String keyPrefix = DEFAULT_KEY_PREFIX;

/**
* 缓存条目过期时间
* 默认: 3分钟
*/
private Duration timeout = Duration.ofMillis(AuthCacheConfig.timeout);

}
2.2、config包

config包中的repository包

/**
* 统一管理各种认证源的配置信息
*
* @author junmowen
* @since 2025-12-22
*/
public interface AuthConfigRepository {

/**
* 返回所有可用的{@link AuthConfig}, 否则返回{@code null}
*
* @return 返回所有可用的{@link AuthConfig}, 否则返回{@code null}
*/
Map<String, AuthConfig> listAuthConfig();

/**
* 返回找到的{@link AuthConfig}, 否则返回{@code null}
*
* <p>
* <b>NOTE:</b> 配置标识不区分大小写
*
* @param authConfigId 配置标识,如{@link AuthDefaultSource}中的枚举名称
* @return 返回找到的{@link AuthConfig}, 否则返回{@code null}
*/
AuthConfig getAuthConfigById(String authConfigId);
}
/**
* AuthConfigRepository的内存实现,使用内存中的LinkedCaseInsensitiveMap存储认证配置信息
*
* @author junmowen
* @since 2025-12-22
*/
public class InMemoryAuthConfigRepository implements AuthConfigRepository {

private final Map<String, AuthConfig> authConfigs = new LinkedCaseInsensitiveMap<>();

public InMemoryAuthConfigRepository(Map<String, AuthConfig> authConfigs) {

if (!CollectionUtils.isEmpty(authConfigs)) {
this.authConfigs.putAll(authConfigs);
}
}

@Override
public Map<String, AuthConfig> listAuthConfig() {
return this.authConfigs;
}

@Override
public AuthConfig getAuthConfigById(String authConfigId) {
return this.authConfigs.get(authConfigId);
}

}

直接在config包下

import com.xkcoding.http.config.HttpConfig;
import com.xkcoding.http.constants.Constants;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import me.zhyd.oauth.config.AuthConfig;
import me.zhyd.oauth.config.AuthSource;
import online.junmowen.boot.base.module.support.oauth2.cache.JustAuthCacheProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
import org.springframework.util.StringUtils;

import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.Proxy.Type;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
* JustAuth的配置属性类,用于配置各种OAuth2认证源的参数
*
* @author junmowen
* @since 2025-12-22
*/
@Setter
@ConfigurationProperties(prefix = JustAuthProperties.JUSTAUTH_PREFIX)
public class JustAuthProperties {

public static final String JUSTAUTH_PREFIX = "justauth";

/**
* 是否启用JustAuth功能
*/
private boolean enabled = true;

/**
* 各种OAuth2认证源的配置映射
*/
@Getter
private Map<String, AuthConfig> type = new HashMap<>();

/**
* {@link AuthSource}的扩展实现类列表
*/
@Getter
private List<Class<? extends AuthSource>> extendAuthSourceClass = new ArrayList<>();

/**
* 缓存相关配置
*/
@Getter
@NestedConfigurationProperty
private JustAuthCacheProperties cache = new JustAuthCacheProperties();

/**
* HTTP连接相关配置
*/
@Getter
@NestedConfigurationProperty
private JustAuthHttpConfig httpConfig = new JustAuthHttpConfig();

public boolean getEnabled() {
return this.enabled;
}

/**
* HTTP代理配置类
*/
@Data
public static class JustAuthHttpProxyConfig {

/**
* 代理类型
*/
private Type type = Type.HTTP;

/**
* 代理主机名
*/
private String hostname;

/**
* 代理端口
*/
private int port;

}

/**
* HTTP连接配置类,继承自代理配置类
*/
@EqualsAndHashCode(callSuper = true)
@Data
public static class JustAuthHttpConfig extends JustAuthHttpProxyConfig {

/**
* 超时时长,单位毫秒
*/
private int timeout = Constants.DEFAULT_TIMEOUT;

/**
* 针对不同认证源的代理配置映射
*/
private Map<String, JustAuthHttpProxyConfig> proxy = new HashMap<>();

}

/**
* 获取所有认证源的配置信息
*
* @return 认证源配置映射
*/
public Map<String, AuthConfig> getAuthConfigs() {
return this.getType().entrySet()
.stream()
.map(this::configHttpConfig)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}

private Map.Entry<String, AuthConfig> configHttpConfig(Map.Entry<String, AuthConfig> entry) {
this.configHttpConfig(entry.getKey(), entry.getValue());
return entry;
}

private HttpConfig createHttpConfig(int timeout, JustAuthHttpProxyConfig proxyConfig) {
return HttpConfig.builder()
.timeout(timeout)
.proxy(new Proxy(proxyConfig.getType(),
new InetSocketAddress(proxyConfig.getHostname(), proxyConfig.getPort())))
.build();
}

private AuthConfig configHttpConfig(String source, AuthConfig authConfig) {
JustAuthHttpConfig authHttpConfig = this.getHttpConfig();
if (authHttpConfig == null) {
return authConfig;
}

JustAuthHttpProxyConfig authProxyConfig = authHttpConfig.getProxy()
.entrySet()
.stream()
.filter(entry -> entry.getKey().equalsIgnoreCase(source))
.findFirst()
.map(Map.Entry::getValue)
.orElse(authHttpConfig);

if (!StringUtils.hasText(authProxyConfig.getHostname())) {
return authConfig;
}

authConfig.setHttpConfig(this.createHttpConfig(authHttpConfig.getTimeout(), authProxyConfig));

return authConfig;
}

}
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.cache.AuthStateCache;
import me.zhyd.oauth.config.AuthSource;
import me.zhyd.oauth.request.AuthRequest;
import online.junmowen.boot.base.module.support.oauth2.config.repository.AuthConfigRepository;
import online.junmowen.boot.base.module.support.oauth2.config.repository.InMemoryAuthConfigRepository;
import online.junmowen.boot.base.module.support.oauth2.request.AuthRequestFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
* JustAuth的配置类,用于初始化认证相关的Bean
*
* @author junmowen
* @since 2025-12-22
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({AuthRequest.class, AuthSource.class})
@EnableConfigurationProperties(JustAuthProperties.class)
@ConditionalOnProperty(prefix = JustAuthProperties.JUSTAUTH_PREFIX, value = "enabled", matchIfMissing = true)
@Import({JustAuthStateCacheConfiguration.class})
@Slf4j
public class JustAuthConfiguration {

@Bean
@ConditionalOnMissingBean
public AuthRequestFactory authRequestFactory(JustAuthProperties properties,
AuthStateCache authStateCache, AuthConfigRepository authConfigRepository,
ObjectProvider<AuthSource> authSource, ObjectProvider<List<AuthSource>> authSourceList) {
log.info("初始化AuthRequestFactory");
Stream<AuthSource> authSourceFromList = authSourceList.orderedStream().flatMap(List::stream);
Stream<AuthSource> authSourceFromSingle = authSource.orderedStream();
List<AuthSource> authSources = Stream.concat(authSourceFromSingle, authSourceFromList)
.distinct()
.collect(Collectors.toList());

return new AuthRequestFactory(authConfigRepository, authStateCache, authSources, properties);
}

@Bean
@ConditionalOnMissingBean
public AuthConfigRepository authConfigRepository(JustAuthProperties properties) {
log.info("初始化AuthConfigRepository");
return new InMemoryAuthConfigRepository(properties.getAuthConfigs());
}

}
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.cache.AuthStateCache;
import online.junmowen.boot.base.module.support.oauth2.cache.AuthRedisStateCache;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;

/**
* Redis状态缓存的自动配置类
* 当满足特定条件时(使用Redis作为缓存),会自动创建StringRedisTemplate和AuthStateCache的Bean实例
*
* @author junmowen
* @since 2025-12-22
*/
@Configuration(proxyBeanMethods = false)
@AutoConfigureAfter(RedisAutoConfiguration.class)
@ConditionalOnClass({ AuthStateCache.class, RedisTemplate.class })
@ConditionalOnMissingBean(AuthStateCache.class)
@ConditionalOnProperty(prefix = JustAuthProperties.JUSTAUTH_PREFIX, value = "cache.type", havingValue = "redis")
@Slf4j
public class JustAuthRedisStateCacheConfiguration {


@Bean
@ConditionalOnMissingBean
@ConditionalOnSingleCandidate(RedisConnectionFactory.class)
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
log.info("创建StringRedisTemplate Bean");
return new StringRedisTemplate(redisConnectionFactory);
}

@Bean
@ConditionalOnMissingBean
public AuthStateCache authStateCache(StringRedisTemplate redisTemplate, JustAuthProperties properties) {
log.info("创建基于Redis的状态缓存Bean");
return new AuthRedisStateCache(redisTemplate, properties.getCache());
}

}
/**
* JustAuth状态缓存配置类
* 根据配置决定使用哪种缓存实现(默认缓存、Redis缓存或自定义缓存)
*
* @author junmowen
* @since 2025-12-22
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(AuthStateCache.class)
@Import(JustAuthRedisStateCacheConfiguration.class)
@Slf4j
public class JustAuthStateCacheConfiguration {

@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(prefix = JustAuthProperties.JUSTAUTH_PREFIX, value = "cache.type", havingValue = "default", matchIfMissing = true)
static class AuthDefaultStateCacheConfiguration {

@Bean
@ConditionalOnMissingBean
public AuthStateCache authStateCache() {
log.info("使用默认状态缓存实现");
return AuthDefaultStateCache.INSTANCE;
}
}

@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(prefix = JustAuthProperties.JUSTAUTH_PREFIX, value = "cache.type", havingValue = "custom")
static class AuthCustomStateCacheConfiguration {

@Bean
@ConditionalOnMissingBean
public AuthStateCache authStateCache() {
log.error("配置了使用自定义缓存,但未找到任何自定义的authStateCache bean");
throw new AuthException(
"justauth.cache.type=custom, but not found any custom authStateCache bean.");
}
}
}
2.3、constant包
import me.zhyd.oauth.cache.AuthDefaultStateCache;
import online.junmowen.boot.base.module.support.oauth2.cache.AuthRedisStateCache;

/**
* 缓存类型枚举类,定义了三种缓存策略
*
* @author junmowen
* @since 2025-12-22
*/
public enum CacheType {
/**
* 使用默认缓存 {@link AuthDefaultStateCache}
*/
DEFAULT,

/**
* 使用Redis缓存 {@link AuthRedisStateCache}
*/
REDIS,

/**
* 使用自定义缓存实现
*/
CUSTOM;
}

PS:如需对接自定义的平台或者私有化的平台,就需要加下面这个AuthExtendSource类,不然就不需要。我自己就对接私有化的gitlab,注意Gitlab的枚举名称不要和JustAuth内置的枚举名称一样,自己取一个就好,如:MY_GITLAB

import me.zhyd.oauth.config.AuthDefaultSource;
import me.zhyd.oauth.config.AuthSource;
import me.zhyd.oauth.request.AuthDefaultRequest;
import online.junmowen.boot.base.module.support.oauth2.request.AuthGitlabRequest;

/**
* {@link AuthSource} 的扩展实现,{@link AuthDefaultSource}为JustAuth提供的实现
* 扩展认证源枚举类
*
* @author junmowen
* @since 2025-12-22
* @see AuthDefaultSource
*/
public enum AuthExtendSource implements AuthSource {

/**
* GitLab
*/
MY_GITLAB {
@Override
public String authorize() {
return "http://10.126.126.3:9980/oauth/authorize";
}

@Override
public String accessToken() {
return "http://10.126.126.3:9980/oauth/token";
}

@Override
public String userInfo() {
return "http://10.126.126.3:9980/api/v4/user";
}

@Override
public Class<? extends AuthDefaultRequest> getTargetClass() {
return AuthGitlabRequest.class;
}
},

}

PS:同时一样的,如果有需要自己私有化的平台或者其他JustAuth非内置的平台,对应到上面AuthExtendSource中有多少个枚举值,就要创建出多少个AuthXXXRequest类出来。如:上面私有化的GitLab,就需要创建一个AuthGitlabRequest类出来。不需要就略过。

import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.cache.AuthStateCache;
import me.zhyd.oauth.config.AuthConfig;
import me.zhyd.oauth.enums.AuthUserGender;
import me.zhyd.oauth.enums.scope.AuthGitlabScope;
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 me.zhyd.oauth.utils.AuthScopeUtils;
import me.zhyd.oauth.utils.UrlBuilder;
import online.junmowen.boot.base.module.support.oauth2.constant.AuthExtendSource;

/**
* 私有化的gitlab认证请求类,继承自AuthDefaultRequest。用于私有化的gitlab登录逻辑
*
* @author junmowen
* @see AuthDefaultRequest
* @since 2025-12-22
*/
@Slf4j
public class AuthGitlabRequest extends AuthDefaultRequest {

public AuthGitlabRequest(AuthConfig config) {
super(config, AuthExtendSource.MY_GITLAB);
}

public AuthGitlabRequest(AuthConfig config, AuthStateCache authStateCache) {
super(config, AuthExtendSource.MY_GITLAB, authStateCache);
}

@Override
public AuthToken getAccessToken(AuthCallback authCallback) {
log.info("开始获取私有化的gitlab访问令牌,授权码: {}", authCallback.getCode());
String response = doPostAuthorizationCode(authCallback.getCode());
return getAuthToken(response);
}

@Override
public AuthUser getUserInfo(AuthToken authToken) {
log.info("获取私有化的gitlab用户信息,OpenId: {}", authToken.getOpenId());
String response = doGetUserInfo(authToken);
JSONObject object = JSONObject.parseObject(response);
return AuthUser.builder()
.rawUserInfo(object)
.uuid(object.getString("id"))
.username(object.getString("username"))
.nickname(object.getString("name"))
.avatar(object.getString("avatar_url"))
.blog(object.getString("web_url"))
.company(object.getString("organization"))
.location(object.getString("location"))
.email(object.getString("email"))
.remark(object.getString("bio"))
.gender(AuthUserGender.UNKNOWN)
.token(authToken)
.source(source.toString())
.build();
}

/**
* 检查响应内容是否正确
*
* @param object 请求响应内容
*/
private void checkResponse(JSONObject object) {
if ( object == null ) {
throw new AuthException("响应数据为空。");
}
// oauth/token 验证异常
if (object.containsKey("error")) {
throw new AuthException(object.getString("error_description"));
}
// user 验证异常
if (object.containsKey("message")) {
throw new AuthException(object.getString("message"));
}
}

private AuthToken getAuthToken(String response) {
log.debug("解析私有化的gitlab访问令牌响应: {}", response);
JSONObject accessTokenObject = JSONObject.parseObject(response);

this.checkResponse(accessTokenObject);

return AuthToken.builder()
.accessToken(accessTokenObject.getString("access_token"))
.refreshToken(accessTokenObject.getString("refresh_token"))
.idToken(accessTokenObject.getString("id_token"))
.tokenType(accessTokenObject.getString("token_type"))
.scope(accessTokenObject.getString("scope"))
.build();
}


/**
* 返回带{@code state}参数的授权url,授权回调时会带上这个{@code state}
*
* @param state state 验证授权流程的参数,可以防止csrf
* @return 返回授权地址
* @since 1.11.0
*/
@Override
public String authorize(String state) {
return UrlBuilder.fromBaseUrl(super.authorize(state))
.queryParam("scope", this.getScopes("+", false, AuthScopeUtils.getDefaultScopes(AuthGitlabScope.values())))
.build();
}

}
2.4、request包
/**
* 认证请求工厂类
* 用于根据不同的认证源创建相应的AuthRequest对象。支持扩展的认证源注册和管理。
* The factory class of {@link AuthRequest}
*
* @author junmowen
* @since 2025-12-22
*/
@Slf4j
public class AuthRequestFactory {

private final AuthConfigRepository authConfigRepository;

private final AuthStateCache authStateCache;

private final Map<String, AuthSource> extendAuthSources = new ConcurrentHashMap<>();

public AuthRequestFactory(AuthConfigRepository authConfigRepository, AuthStateCache authStateCache,
List<AuthSource> extendAuthSources, JustAuthProperties properties) {
this.authConfigRepository = authConfigRepository;
this.authStateCache = authStateCache;
if ( !CollectionUtils.isEmpty(extendAuthSources) ) {
extendAuthSources.forEach(this::registerExtendAuthSource);
}
this.mergeExtendAuthSources(properties);
}

private void mergeExtendAuthSources(JustAuthProperties properties) {
properties.getExtendAuthSourceClass()
.stream()
.flatMap(this::createInstanceFromClass)
.forEach(this::registerExtendAuthSource);
}

/**
* 获取当前配置的OAuth名称列表
*
* @return 当前配置的OAuth名称列表
*/
public List<String> getConfiguredOAuthNames() {
return this.authConfigRepository.listAuthConfig().keySet()
.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
}

/**
* 根据认证源获取对应的AuthRequest对象
*
* @param source OAuth2认证源
* @return AuthRequest对象,如果未找到则返回null
*/
public AuthRequest getAuthRequest(String source) {
log.info("正在创建认证源为 {} 的AuthRequest对象", source);
return AuthRequestBuilder.builder()
.source(source)
.authConfig(this::getAuthConfig)
.authStateCache(this.authStateCache)
.extendSource(this.getExtendAuthSources())
.build();
}

private AuthConfig getAuthConfig(String source) {
return this.authConfigRepository.getAuthConfigById(source);
}

private AuthSource[] getExtendAuthSources() {
return this.extendAuthSources.values().toArray(AuthSource[]::new);
}

private Stream<AuthSource> createInstanceFromClass(Class<? extends AuthSource> clazz) {
try {
if ( clazz.isEnum() ) {
return Arrays.stream(clazz.getEnumConstants());
} else {
return Stream.of(clazz.getConstructor().newInstance());
}
} catch (Exception ex) {
String message = String.format("类 %s 必须有默认的无参构造函数。", clazz.getCanonicalName());
log.error(message, ex);
throw new AuthException(message, ex);
}
}

/**
* 注册扩展的认证源
*
* @param authSource 认证源对象
*/
public void registerExtendAuthSource(AuthSource authSource) {
log.info("注册扩展认证源: {}", authSource.getName());

this.extendAuthSources.put(authSource.getName(), authSource);
}

/**
* 注销扩展的认证源
*
* @param authSource 认证源对象
*/
public void unregisterExtendAuthSource(AuthSource authSource) {
log.info("注销扩展认证源: {}", authSource.getName());
this.extendAuthSources.remove(authSource.getName());
}

}

相关包和文件位置如下:

后续的接口调用统一使用 AuthRequestFactory 类获取对应源的Request类即可;

@Resource
private AuthRequestFactory authRequestFactory;

3、接口实现(本文的代码项目框架基于优秀开源项目SmartAdmin)

使用JustAuth+Sa-Token的完成第三方登录和用户校验

import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.AllArgsConstructor;
import lombok.Getter;
import online.junmowen.boot.base.common.enumeration.BaseEnum;

/**
* 第三方登录源枚举类
*
* @projectName: jun-admin
* @package: online.junmowen.boot.admin.module.system.login
* @className: ThirdPartySources
* @author: Jun
* @version: 1.0
*/
@Getter
@AllArgsConstructor
public enum ThirdPartySources implements BaseEnum {

/**
* 飞书
*/
FEISHU("FEISHU", "飞书"),

/**
* GITLAB
*/
MY_GITLAB("MY_GITLAB", "极狐"),

/**
* GITEE
*/
GITEE("GITEE", "码云"),

;


@JsonValue
@EnumValue
private final String value;
private final String desc;

public static ThirdPartySources findByName(String name) {
ThirdPartySources result = null;
for (ThirdPartySources direction : ThirdPartySources.values()) {
if (direction.name().equalsIgnoreCase(name)) {
result = direction;
break;
}
}
return result;
}
}
@NoNeedLogin
@Operation(summary = "根据登录源进行第三方登录 @author junmowen")
@GetMapping("/login/thirdPartyLogin/{source}")
public ResponseDTO<String> thirdPartyLogin(@PathVariable ThirdPartySources source) {
return loginService.thirdPartyLogin(source);
}

@NoNeedLogin
@Operation(summary = "根据登录源进行第三方登录的回调 @author junmowen")
@PostMapping("/login/thirdPartyLoginCallback/{source}")
public ResponseDTO<LoginResultVO> thirdPartyLoginCallback(@PathVariable ThirdPartySources source, @RequestBody AuthCallback authCallback, HttpServletRequest request) {
String ip = JakartaServletUtil.getClientIP(request);
String userAgent = JakartaServletUtil.getHeaderIgnoreCase(request, RequestHeaderConst.USER_AGENT);
return loginService.thirdPartyLoginCallback(source, authCallback, ip, userAgent);
}
import cn.dev33.satoken.stp.StpInterface;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.lang.UUID;
import cn.hutool.core.util.NumberUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.extra.servlet.JakartaServletUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.model.AuthCallback;
import me.zhyd.oauth.model.AuthResponse;
import me.zhyd.oauth.model.AuthToken;
import me.zhyd.oauth.model.AuthUser;
import me.zhyd.oauth.request.AuthRequest;
import me.zhyd.oauth.utils.AuthStateUtils;
import online.junmowen.boot.admin.module.system.employee.domain.entity.EmployeeEntity;
import online.junmowen.boot.admin.module.system.employee.manager.EmployeeManager;
import online.junmowen.boot.admin.module.system.employee.service.EmployeeService;
import online.junmowen.boot.admin.module.system.login.constant.ThirdPartySources;
import online.junmowen.boot.admin.module.system.login.domain.LoginForm;
import online.junmowen.boot.admin.module.system.login.domain.LoginResultVO;
import online.junmowen.boot.admin.module.system.login.domain.RequestEmployee;
import online.junmowen.boot.admin.module.system.login.manager.LoginManager;
import online.junmowen.boot.admin.module.system.menu.domain.vo.MenuVO;
import online.junmowen.boot.admin.module.system.oauth.dao.EmployeeThirdPartyInformationDao;
import online.junmowen.boot.admin.module.system.oauth.domain.entity.EmployeeThirdPartyInformationEntity;
import online.junmowen.boot.admin.module.system.oauth.domain.form.EmployeeThirdPartyInformationAddForm;
import online.junmowen.boot.admin.module.system.oauth.service.EmployeeThirdPartyInformationService;
import online.junmowen.boot.admin.module.system.role.domain.vo.RoleVO;
import online.junmowen.boot.admin.module.system.role.service.RoleEmployeeService;
import online.junmowen.boot.admin.module.system.role.service.RoleMenuService;
import online.junmowen.boot.base.common.code.UserErrorCode;
import online.junmowen.boot.base.common.constant.RequestHeaderConst;
import online.junmowen.boot.base.common.constant.StringConst;
import online.junmowen.boot.base.common.domain.RequestUser;
import online.junmowen.boot.base.common.domain.ResponseDTO;
import online.junmowen.boot.base.common.domain.UserPermission;
import online.junmowen.boot.base.common.enumeration.UserTypeEnum;
import online.junmowen.boot.base.common.util.SmartBeanUtil;
import online.junmowen.boot.base.common.util.SmartEnumUtil;
import online.junmowen.boot.base.common.util.SmartIpUtil;
import online.junmowen.boot.base.common.util.SmartStringUtil;
import online.junmowen.boot.base.constant.LoginDeviceEnum;
import online.junmowen.boot.base.constant.RedisKeyConst;
import online.junmowen.boot.base.module.support.apiencrypt.service.ApiEncryptService;
import online.junmowen.boot.base.module.support.captcha.CaptchaService;
import online.junmowen.boot.base.module.support.captcha.domain.CaptchaVO;
import online.junmowen.boot.base.module.support.config.ConfigKeyEnum;
import online.junmowen.boot.base.module.support.config.ConfigService;
import online.junmowen.boot.base.module.support.loginlog.LoginLogResultEnum;
import online.junmowen.boot.base.module.support.loginlog.LoginLogService;
import online.junmowen.boot.base.module.support.loginlog.domain.LoginLogEntity;
import online.junmowen.boot.base.module.support.loginlog.domain.LoginLogVO;
import online.junmowen.boot.base.module.support.mail.MailService;
import online.junmowen.boot.base.module.support.mail.constant.MailTemplateCodeEnum;
import online.junmowen.boot.base.module.support.oauth2.request.AuthRequestFactory;
import online.junmowen.boot.base.module.support.redis.RedisService;
import online.junmowen.boot.base.module.support.securityprotect.domain.LoginFailEntity;
import online.junmowen.boot.base.module.support.securityprotect.service.Level3ProtectConfigService;
import online.junmowen.boot.base.module.support.securityprotect.service.SecurityLoginService;
import online.junmowen.boot.base.module.support.securityprotect.service.SecurityPasswordService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.stream.Collectors;

/**
* 登录
*
* @Author 1024创新实验室: 卓大
* @Date 2025-05-03 22:56:34
* @Wechat zhuoda1024
* @Email lab1024@163.com
* @Copyright <a href="https://1024lab.net">1024创新实验室</a>
*/
@Slf4j
@Service
public class LoginService implements StpInterface {

@Resource
private EmployeeService employeeService;

@Resource
private CaptchaService captchaService;

@Resource
private ConfigService configService;

@Resource
private LoginLogService loginLogService;

@Resource
private RoleEmployeeService roleEmployeeService;

@Resource
private RoleMenuService roleMenuService;

@Resource
private SecurityLoginService securityLoginService;

@Resource
private SecurityPasswordService protectPasswordService;

@Resource
private ApiEncryptService apiEncryptService;

@Resource
private Level3ProtectConfigService level3ProtectConfigService;

@Resource
private RedisService redisService;

@Resource
private LoginManager loginManager;

@Resource
private AuthRequestFactory authRequestFactory;

@Resource
private EmployeeThirdPartyInformationDao employeeThirdPartyInformationDao;

@Resource
private EmployeeManager employeeManager;

@Resource
private EmployeeThirdPartyInformationService employeeThirdPartyInformationService;

/**
* 移除邮箱验证码
*/
private void deleteEmailCode(Long employeeId) {
String redisVerificationCodeKey = redisService.generateRedisKey(RedisKeyConst.Support.LOGIN_VERIFICATION_CODE, UserTypeEnum.ADMIN_EMPLOYEE.getValue() + RedisKeyConst.SEPARATOR + employeeId);
redisService.delete(redisVerificationCodeKey);
}

public void clearLoginEmployeeCache(Long employeeId) {
loginManager.clearUserPermission(employeeId);
loginManager.clearUserLoginInfo(employeeId);
}

/**
* 根据登录源进行第三方登录
*
* @param source - 登录源
* @return - 授权地址
*/
public ResponseDTO<String> thirdPartyLogin(ThirdPartySources source) {
String thirdPartySource = getThirdPartySource(source);
if ( SmartStringUtil.isBlank(thirdPartySource) ) {
return ResponseDTO.error(UserErrorCode.PARAM_ERROR, "暂时不支持" + source + "授权登录~~~");
}
AuthRequest authRequest = authRequestFactory.getAuthRequest(thirdPartySource);
String authorize = authRequest.authorize(AuthStateUtils.createState());
return ResponseDTO.ok(authorize);
}

/**
* 根据登录源进行第三方登录的回调
*
* @param source - 登录源
* @return - 登录结果
*/
@Transactional(rollbackFor = Exception.class)
public ResponseDTO<LoginResultVO> thirdPartyLoginCallback(ThirdPartySources source, AuthCallback authCallback, String ip, String userAgent) {
String thirdPartySource = getThirdPartySource(source);
if ( SmartStringUtil.isBlank(thirdPartySource) ) {
return ResponseDTO.error(UserErrorCode.PARAM_ERROR, "暂时不支持" + source + "授权登录~~~");
}
AuthRequest authRequest = authRequestFactory.getAuthRequest(thirdPartySource);
AuthResponse<AuthUser> login = authRequest.login(authCallback);
if ( !login.ok() ) {
log.error("授权信息获取失败:{}", login);
return ResponseDTO.error(UserErrorCode.AUTH_ERROR, "第三方登录失败,请重试");
}

AuthUser authUser = login.getData();
if ( authUser == null ) {
log.error("第三方登录返回的用户信息为空");
return ResponseDTO.error(UserErrorCode.AUTH_ERROR, "第三方登录失败,用户信息为空");
}

String authUserSource = authUser.getSource();
String uuid = authUser.getUuid();
AuthToken authToken = authUser.getToken();
if ( authToken == null ) {
log.error("第三方登录返回的token为空");
return ResponseDTO.error(UserErrorCode.AUTH_ERROR, "第三方登录失败,token信息不完整");
}
if ( authToken.getOpenId() == null && uuid == null ) {
log.error("第三方登录返回的openId为空");
return ResponseDTO.error(UserErrorCode.AUTH_ERROR, "第三方登录失败,token信息不完整");
}
String openId = authToken.getOpenId() != null ? authToken.getOpenId() : uuid;

// 用户信息验证
EmployeeThirdPartyInformationEntity thirdPartyInformation = employeeThirdPartyInformationDao.selectOne(new LambdaQueryWrapper<EmployeeThirdPartyInformationEntity>()
.eq(EmployeeThirdPartyInformationEntity::getSocialType, authUserSource)
.eq(EmployeeThirdPartyInformationEntity::getSocialId, openId));

EmployeeEntity employee;
if ( thirdPartyInformation == null ) {
// 添加第三方用户信息
employee = employeeManager.getOne(new LambdaQueryWrapper<EmployeeEntity>()
.eq(EmployeeEntity::getActualName, authUser.getUsername()));
if ( employee == null ) {
return ResponseDTO.error(UserErrorCode.DATA_NOT_EXIST, "该用户不存在,请联系管理员~~~");
}
EmployeeThirdPartyInformationAddForm thirdPartyInformationAddForm = new EmployeeThirdPartyInformationAddForm();
thirdPartyInformationAddForm.setEmployeeId(employee.getEmployeeId());
thirdPartyInformationAddForm.setSocialId(openId);
thirdPartyInformationAddForm.setPhone(employee.getPhone());
thirdPartyInformationAddForm.setSocialType(source);
thirdPartyInformationAddForm.setAccessToken(authToken.getAccessToken());
thirdPartyInformationAddForm.setRefreshToken(authToken.getRefreshToken());
employeeThirdPartyInformationService.add(thirdPartyInformationAddForm);
} else {
Long employeeId = thirdPartyInformation.getEmployeeId();
employee = employeeManager.getById(employeeId);
if ( employee == null ) {
log.error("第三方登录信息对应的员工不存在,employeeId: {}", employeeId);
return ResponseDTO.error(UserErrorCode.DATA_NOT_EXIST, "员工信息不存在");
}
}

// 执行登录逻辑
return performLogin(employee, ip, userAgent);
}

/**
* 执行登录逻辑的公共方法
*/
private ResponseDTO<LoginResultVO> performLogin(EmployeeEntity employee, String ip, String userAgent) {
String saTokenLoginId = UserTypeEnum.ADMIN_EMPLOYEE.getValue() + StringConst.COLON + employee.getEmployeeId();
LoginDeviceEnum loginDeviceEnum = LoginDeviceEnum.PC;

// 登录
StpUtil.login(saTokenLoginId, String.valueOf(loginDeviceEnum.getDesc()));
// 移除邮箱验证码
deleteEmailCode(employee.getEmployeeId());
// 获取员工信息
RequestEmployee requestEmployee = loginManager.loadLoginInfo(employee);
// 移除登录失败
securityLoginService.removeLoginFail(employee.getEmployeeId(), UserTypeEnum.ADMIN_EMPLOYEE);
// 获取登录结果信息
String token = StpUtil.getTokenValue();
LoginResultVO loginResultVO = getLoginResult(requestEmployee, token);
// 保存登录记录
saveLoginLog(employee, ip, userAgent, StringConst.EMPTY, LoginLogResultEnum.LOGIN_SUCCESS, loginDeviceEnum);
// 设置 token
loginResultVO.setToken(token);
// 更新用户权限
loginManager.loadUserPermission(employee.getEmployeeId());
return ResponseDTO.ok(loginResultVO);
}

private String getThirdPartySource(ThirdPartySources source) {
return switch ( source ) {
case FEISHU -> ThirdPartySources.FEISHU.getValue();
case MY_GITLAB -> ThirdPartySources.MY_GITLAB.getValue();
case GITEE -> ThirdPartySources.GITEE.getValue();
};
}
}

PS:同时提供第三方用户信息表结构,供参考

/*
Navicat Premium Dump SQL

Source Server : 705
Source Server Type : MySQL
Source Server Version : 80406 (8.4.6)
Source Host : 10.126.126.3:3306
Source Schema : jun-cloud

Target Server Type : MySQL
Target Server Version : 80406 (8.4.6)
File Encoding : 65001

Date: 25/12/2025 10:59:09
*/

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for t_employee_third_party_information
-- ----------------------------
DROP TABLE IF EXISTS `t_employee_third_party_information`;
CREATE TABLE `t_employee_third_party_information` (
`id` int NOT NULL AUTO_INCREMENT COMMENT 'ID',
`employee_id` bigint NOT NULL COMMENT '员工系统ID',
`social_id` varchar(150) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '第三方平台的用户唯一标识(open_id/sub)',
`phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '手机号码',
`social_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '平台类型,如:wechat, github',
`access_token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '用户凭证',
`refresh_token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '刷新凭证',
`scope` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '授权范围',
`access_expired_time` datetime NULL DEFAULT NULL COMMENT '用户凭证过期时间',
`union_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '微信等平台特有的跨应用统一ID',
`refresh_expired_time` datetime NULL DEFAULT NULL COMMENT '刷新凭证过期时间',
`meta_info` json NULL COMMENT '扩展信息,如原始用户信息JSON',
`remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注',
`deleted_flag` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '逻辑删除标志位(0未删除,1已删除)',
`creator` bigint NULL DEFAULT NULL COMMENT '创建人',
`updater` bigint NULL DEFAULT NULL COMMENT '更新人',
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_social_platform`(`social_type` ASC, `social_id` ASC) USING BTREE COMMENT '防止同一平台同一账号重复绑定',
INDEX `idx_employee_id`(`employee_id` ASC) USING BTREE COMMENT '关联员工查询',
INDEX `idx_union_id`(`union_id` ASC) USING BTREE COMMENT 'union_id索引',
CONSTRAINT `fk_employee_third_party` FOREIGN KEY (`employee_id`) REFERENCES `t_employee` (`employee_id`) ON DELETE CASCADE ON UPDATE RESTRICT
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '员工第三方信息' ROW_FORMAT = DYNAMIC;

SET FOREIGN_KEY_CHECKS = 1;

四、扩展

1、OIDC 模式

​ OIDC 全称为 OpenID Connect,是对于 OAuth2.0 的一个扩展,毕竟在标准 OAuth2.0 模式下,要获取到用户信息需要经过多个端点请求,而某些平台的第三方登录,只需要获取这个用户在第三方的唯一标识,以及昵称头像之类即可,所以便出现了 OIDC 模式。

1.1、 最常见的 OIDC 用法

在授权码模式中的第一步骤,跳转往授权端点时,传递参数scope增加openid,例如 scope=email openid,多个用空格隔开。

在令牌端点时,Github服务端检测到scope含有openid,则多返回一个 id_token,例如

{
"access_token": "xxxx", // 新的访问令牌
"token_type": "Bearer", // 令牌类型,通常为 "Bearer"
"expires_in": 3600, // 访问令牌的过期时间(以秒为单位)
"refresh_token": "xxxx", // 新的刷新令牌,若我们系统支持的话
"id_token":"xxx" // 该值应该是个jwt格式字符串,scope包含openid时应返回
}

此时百度贴吧直接解析 id_token 中的 jwt 值即可获取到用户的一些基本信息,信息字段与用户信息端点基本应保持一致,也就是

{
"sub": "", // 一般表示用户id
"name": "", // 名称,姓名全称或用户名等等
"nickname": "", // 昵称
"picture": "", // 头像url地址
"address": "", // 地址
"gender": "", // 性别
"email": "", // 邮箱
"email_verified": true, // 邮箱是否验证
"phone_number": "", // 手机号
"phone_number_verified": true // 手机号是否验证
}

通过这种模式,既完全兼容原有的 oauth2.0标准,又能扩展出 id_token值,百度贴吧不需要再通过 access_token 去请求用户信息端点,直接解析jwt即可知道用户的信息。

1.2、 OIDC 简化模式

有人说这种模式仍然还是需要请求两次,那么有没有更简单一点的方法能直接获取到用户信息呢?当然有,回到第一步骤,百度贴吧跳往 Github 授权页,当 response_type=code 改为 response_type=id_token 即为 OIDC 模式(需要注意,在OAuth2.0规范中,response_type并不能传递id_token值,这是OIDC引申出的)。

https://github.com/login/oauth/authorize
?client_id=xxx
&response_type=id_token
&scope=profile
&redirect_uri=https://tieba.baidu.com/login

当 Github 授权成功后,重定向回百度贴吧时,附带的值就是 jwt 格式的 id_token,例如 https://tieba.baidu.com/login?id_token=xxx,这种模式不需要再请求令牌端点,也不需要请求用户信息端点,大大简化了流程。

这种模式十分适合授权登录场景,不过若百度贴吧后续还需要代表用户请求 Github 接口,例如查询用户所有仓库等等,这种简化的 OIDC 模式明显无法实现,毕竟没有拿到 access_token ,因此又延申出了混合模式。

2、混合模式

2.1、 OIDC 模式 + 简化模式

混合模式也很简单,实际就是百度贴吧跳往 Github 授权页,当 response_type=id_token 改为 response_type=token id_token 即为 OIDC 模式+简化模式结合,表示 Github 授权成功后,重定向回百度贴吧时,应该带上 OIDC 模式和简化模式应该传递的值,例如

https://tieba.baidu.com/login?id_token=xxx&access_token=xxx
2.2、 OIDC 模式 + 授权码模式

授权端点传递 response_type=id_token code 就表示 OIDC 模式+授权码模式结合,重定向时应附带

https://tieba.baidu.com/login?id_token=xxx&code=xxx
2.3、 其他任意组合模式

当然只要 response_type 可以组合的都行,但其实没有实际意义了。

3、额外安全措施防护

百度贴吧通过额外传递两个参数 statenonce 进行额外安全措施防护,这两个值不是必须传递,且也没要求一定得同时使用,例如

https://github.com/login/oauth/authorize
?client_id=xxx
&response_type=code
&scope=profile
&redirect_uri=https://tieba.baidu.com/login
&state=7t3ze8muocp
&nonce=bknslkj1tps
3.1、 state

state 应该是一个随机不重复字符串,Github 授权成功后,会原值返回给百度贴吧,百度贴吧应对此值进行逻辑判断,防止恶意第三方攻击者通过操纵或篡改授权请求和响应来进行 CSRF 攻击,提高安全性

https://tieba.baidu.com/login?code=xxx&state=7t3ze8muocp
3.2、 nonce

nonce 应该是一个随机不重复字符串,在 OIDC 模式下,Github 在进行授权时,应该判断 nonce 值是否已经被授权过,可以防止 ID 令牌(id_token)的重放攻击。在授权成功后,Github 应该将 nonce 值存放于 id_token 的 jwt 中,而非在重定向链接中显式传递,例如

https://tieba.baidu.com/login?id_token=xxx

而百度贴吧解析 id_token 中的信息,以及 nonce 值后,应该判断 nonce 值与自己发送的是否一致,防止 id_token 被串改。

{
"sub": "", // 一般表示用户id
"name": "", // 名称
"nonce": "bknslkj1tps" // 原值返回
}

而在授权码模式中,nonce 值应该在令牌端点时返回,例如

{
"access_token": "",
"nonce": "bknslkj1tps" // 原值返回
}

五、总结

总结一下,OAuth2.0 的核心基本围绕以下这三个端点

1. 授权端点(是个页面)

传参

https://example.com/login/oauth/authorize
?client_id=xxx
&response_type=code
&scope=email
&redirect_uri=https://example.com/callback
&state=7t3ze8muocp
&nonce=bknslkj1tps

成功后重定向传参

https://example.com/callback
?code=xxx

其中各种模式的核心便是 response_type传递的值,且多个值时用空格隔开,其决定了在重定向回回调地址时会传递什么值,例如

  • 授权码模式:code->code
  • 简化模式:token->access_token
  • OIDC简化模式:id_token->id_token

2. 令牌端点(是个接口)

通过 POST 请求接口 /oauth/token
传参

{
"client_id": "", // 必填,客户端id,身份认证用
"client_secret": "", // 必填,客户端secret,身份认证用

// 1.授权码模式
"grant_type": "authorization_code", // 必填,此时authorization_code表示授权码模式
"code": "", // 授权端点重定向传递的code值
// 2.密码模式
"grant_type": "password", // 必填,此时password表示密码模式
"username": "", // 用户名,密码模式需要
"password":"", // 密码,密码模式需要
// 3.客户端模式
"grant_type": "client_credentials", // 必填,此时client_credentials表示客户端模式
// 4.刷新令牌操作
"grant_type": "refresh_token", // 必填,此时refresh_token表示刷新令牌
"refresh_token": "", // 刷新令牌
}

返回

{
"access_token": "xxxx", // 访问令牌,必填
"token_type": "Bearer", // 令牌类型,通常为 "Bearer",必填
"expires_in": 3600, // 访问令牌的过期时间(以秒为单位),必填
"scope":"profile email", // 授权范围
"refresh_token": "xxxx", // 刷新令牌,若我们系统支持的话,scope包含offline_access应返回
"id_token":"xxx" // 该值应该是个jwt格式字符串,scope包含openid时应返回
}

其中核心便是 grant_type 传入的值,根据不同值,服务端需要取不同参数做不同的逻辑处理校验,而返回时除了必填的几个参数,其他则基本会根据scope返回不同值。

3.用户信息端点(是个接口)

通过 GET 请求接口 /userinfo,附带请求头 Authorization: Bearer ${access_token},且无需传参

回参

{
"sub": "", // 一般表示用户id
// scope=profile应返回
"name": "", // 名称,姓名全称或用户名等
"nickname": "", // 昵称
"picture": "", // 头像url地址
"gender": "", // 性别
"birthdate":"", // 用户的出生日期
// scope=address应返回
"address": "", // 地址
// scope=email应返回
"email": "", // 邮箱
"email_verified": true, // 邮箱是否验证
// scope=phone应返回
"phone_number": "", // 手机号
"phone_number_verified": true, // 手机号是否验证
}

六、最后

最后再说几句,OAuth2.0 并非指哪个安全框架,而是一个授权标准,OIDC 也不是一个新的安全框架,而是基于 OAuth2.0 的扩展而已。在实际项目中我们应该去理解 OAuth2.0 的各种模式认证流程,根据我们的业务场景去选择使用哪种授权模式,这样才能提升对于系统安全方面的认知。而在普通单体项目或简单的外包项目中,其实简单的 token + 过滤器 就已经能对系统的安全提供保护了,不一定要强行使用各种大而全但相对麻烦的安全框架。