一、前置知识 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_id 和 client_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)
<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,平台支持获取unionid, unionid 在 AuthToken 中(如果支持),在登录完成后,可以通过 response.getData().getToken().getUnionId() 获取
Google:uuid 为用户的 sub,sub为Google的所有账户体系中用户唯一的身份标识符,详见:OpenID Connect
注:建议通过uuid + source的方式唯一确定一个用户,这样可以解决用户身份归属的问题。因为 单个用户ID 在某一平台中是唯一的,但不能保证在所有平台中都是唯一的
2.3 基本流程 使用JustAuth总共分三步(这三步也适合于JustAuth支持的任何一个平台 ):
申请注册第三方平台的开发者账号
创建第三方平台的应用,获取配置信息(accessKey, secretKey, redirectUri)
使用该工具实现授权登陆
AuthRequest authRequest = new AuthGiteeRequest (AuthConfig.builder() .clientId("clientId" ) .clientSecret("clientSecret" ) .redirectUri("redirectUri" ) .build()); authRequest.authorize("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() .timeout(15000 ) .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
AuthRequest authRequest = new AuthGiteeRequest (AuthConfig.builder() .clientId("clientId" ) .clientSecret("clientSecret" ) .redirectUri("redirectUri" ) .build());
2.4.1、获取授权链接 String authorizeUrl = authRequest.authorize("state" );
获取到authorizeUrl后,可以手动实现redirect到authorizeUrl上
伪代码
@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建议必传!state在OAuth的流程中的主要作用就是保证请求完整性,防止CSRF 风险,此处传的state将在回调时传回
2.4.2、登录(获取用户信息) AuthResponse response = authRequest.login(callback);
授权登录后会返回code(auth_code(仅限支付宝)、authorization_code(仅限华为))、state,1.8.0版本后,用AuthCallback类作为回调接口的入参
伪代码
@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());
伪代码
@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());
伪代码
@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: type: redis key-prefix: "JUNMOWEN:JUSTAUTH:STATE:" timeout: 3m
PS:各平台的client_id和client_secret自行到各开发者平台或者应用去申请;同时本文集成了2种缓存实现,而使用的缓存是Redis,也支持自定义实现
默认实现由JustAuth提供AuthDefaultStateCache
starter提供了基于redis的缓存实现AuthRedisStateCache
自定义缓存实现
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;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;@Data public class JustAuthCacheProperties { public static final String DEFAULT_KEY_PREFIX = "JUSTAUTH:STATE:" ; private CacheType type = CacheType.DEFAULT; private String keyPrefix = DEFAULT_KEY_PREFIX; private Duration timeout = Duration.ofMillis(AuthCacheConfig.timeout); }
2.2、config包 config包中的repository包
public interface AuthConfigRepository { Map<String, AuthConfig> listAuthConfig () ; AuthConfig getAuthConfigById (String authConfigId) ; }
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;@Setter @ConfigurationProperties(prefix = JustAuthProperties.JUSTAUTH_PREFIX) public class JustAuthProperties { public static final String JUSTAUTH_PREFIX = "justauth" ; private boolean enabled = true ; @Getter private Map<String, AuthConfig> type = new HashMap <>(); @Getter private List<Class<? extends AuthSource >> extendAuthSourceClass = new ArrayList <>(); @Getter @NestedConfigurationProperty private JustAuthCacheProperties cache = new JustAuthCacheProperties (); @Getter @NestedConfigurationProperty private JustAuthHttpConfig httpConfig = new JustAuthHttpConfig (); public boolean getEnabled () { return this .enabled; } @Data public static class JustAuthHttpProxyConfig { private Type type = Type.HTTP; private String hostname; private int port; } @EqualsAndHashCode(callSuper = true) @Data public static class JustAuthHttpConfig extends JustAuthHttpProxyConfig { private int timeout = Constants.DEFAULT_TIMEOUT; private Map<String, JustAuthHttpProxyConfig> proxy = new HashMap <>(); } 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;@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;@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()); } }
@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;public enum CacheType { DEFAULT, 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;public enum AuthExtendSource implements AuthSource { 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;@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(); } private void checkResponse (JSONObject object) { if ( object == null ) { throw new AuthException ("响应数据为空。" ); } if (object.containsKey("error" )) { throw new AuthException (object.getString("error_description" )); } 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(); } @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包 @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); } public List<String> getConfiguredOAuthNames () { return this .authConfigRepository.listAuthConfig().keySet() .stream() .map(String::toUpperCase) .collect(Collectors.toList()); } 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); } } public void registerExtendAuthSource (AuthSource authSource) { log.info("注册扩展认证源: {}" , authSource.getName()); this .extendAuthSources.put(authSource.getName(), 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;@Getter @AllArgsConstructor public enum ThirdPartySources implements BaseEnum { FEISHU("FEISHU" , "飞书" ), MY_GITLAB("MY_GITLAB" , "极狐" ), 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;@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); } 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); } @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); 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、额外安全措施防护 百度贴吧通过额外传递两个参数 state 和 nonce 进行额外安全措施防护,这两个值不是必须传递,且也没要求一定得同时使用,例如
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 + 过滤器 就已经能对系统的安全提供保护了,不一定要强行使用各种大而全但相对麻烦的安全框架。