0%

JustAuth实战文档-高级篇

:heavy_plus_sign: 自定义缓存

在OAuth授权流程中,有一个存在感极弱但又非常重要的参数state,在相关文档中是这么对State参数解释的:

RECOMMENDED. An opaque value used by the client to maintain state between the request and callback. The authorization server includes this value when redirecting the user-agent back
to the client. The parameter SHOULD be used for preventing
cross-site request forgery as described in Section 10.12.

——以上内容节选自《The OAuth 2.0 Authorization Framework》4.1.1

简单翻译就是说:state是用于维护请求和回调之间状态的不透明值。这儿的“不透明”理解为“不可预测”更好些。在没有使用state时,已集成OAuth登录的网站极易受到CSRF攻击,关于实现CSRF攻击的细节和流程,这儿不作赘述,感兴趣的朋友可以参考:Cross-Site Request Forgery移花接木:针对OAuth2的CSRF攻击 两篇文章。

由此可见,state贯穿整个OAuth授权流程,能够确保流程之间的连续性和安全性。但因其是个非必选的参数,所以大多时候开发者都会忘记使用该参数,并且多个第三方平台的OAuth API中也是非必选state。

在OAuth流程中,code参数都是有时效性的,一般为10分钟有效期,如果超过10分钟未使用,则需要重新申请code信息。而对于state来说,OAuth官网文档中并未给出时效性的说明,也就是说只能客户端本身去对state做时效性和有效性作校验。

JustAuth考虑到这一点,在所有OAuth流程中一方面都会传递state参数用于确保流程的完整性,如果客户端没有手动指定,则会使用默认的唯一值;另一方面也会对state做缓存处理,确保state也有其时效性,防止state被重复利用。

JustAuth中默认使用了本地map的方式,实现对state的缓存,详情可参考 AuthDefaultCache

package me.zhyd.oauth.cache;

import lombok.Getter;
import lombok.Setter;

import java.io.Serializable;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
* 默认的缓存实现
*
* @author yadong.zhang (yadong.zhang0415(a)gmail.com)
* @since 1.9.3
*/
public class AuthDefaultCache implements AuthCache {

/**
* state cache
*/
private static Map<String, CacheState> stateCache = new ConcurrentHashMap<>();
private final ReentrantReadWriteLock cacheLock = new ReentrantReadWriteLock(true);
private final Lock writeLock = cacheLock.writeLock();
private final Lock readLock = cacheLock.readLock();

public AuthDefaultCache() {
if (AuthCacheConfig.schedulePrune) {
this.schedulePrune(AuthCacheConfig.timeout);
}
}

/**
* 设置缓存
*
* @param key 缓存KEY
* @param value 缓存内容
*/
@Override
public void set(String key, String value) {
set(key, value, AuthCacheConfig.timeout);
}

/**
* 设置缓存
*
* @param key 缓存KEY
* @param value 缓存内容
* @param timeout 指定缓存过期时间(毫秒)
*/
@Override
public void set(String key, String value, long timeout) {
writeLock.lock();
try {
stateCache.put(key, new CacheState(value, timeout));
} finally {
writeLock.unlock();
}
}

/**
* 获取缓存
*
* @param key 缓存KEY
* @return 缓存内容
*/
@Override
public String get(String key) {
readLock.lock();
try {
CacheState cacheState = stateCache.get(key);
if (null == cacheState || cacheState.isExpired()) {
return null;
}
return cacheState.getState();
} finally {
readLock.unlock();
}
}

/**
* 是否存在key,如果对应key的value值已过期,也返回false
*
* @param key 缓存KEY
* @return true:存在key,并且value没过期;false:key不存在或者已过期
*/
@Override
public boolean containsKey(String key) {
readLock.lock();
try {
CacheState cacheState = stateCache.get(key);
return null != cacheState && !cacheState.isExpired();
} finally {
readLock.unlock();
}
}

/**
* 清理过期的缓存
*/
@Override
public void pruneCache() {
Iterator<CacheState> values = stateCache.values().iterator();
CacheState cacheState;
while (values.hasNext()) {
cacheState = values.next();
if (cacheState.isExpired()) {
values.remove();
}
}
}

/**
* 定时清理
*
* @param delay 间隔时长,单位毫秒
*/
public void schedulePrune(long delay) {
AuthCacheScheduler.INSTANCE.schedule(this::pruneCache, delay);
}

@Getter
@Setter
private class CacheState implements Serializable {
private String state;
private long expire;

CacheState(String state, long expire) {
this.state = state;
// 实际过期时间等于当前时间加上有效期
this.expire = System.currentTimeMillis() + expire;
}

boolean isExpired() {
return System.currentTimeMillis() > this.expire;
}
}
}

提示

关于JustAuth校验state的流程,可以参考:https://segmentfault.com/a/1190000020712258。

另外,由于开发者可能本身并不想使用map的形式作为缓存工具,或者说开发者现有项目中已经用到了其他缓存组件,比如Redis,想直接使用项目里已有的缓存组件实现state缓存,因此JustAuth也支持开发者自定义缓存实现。

本文以Redis为例,实现自定义的缓存。

首先在项目中添加Redis依赖

pom.xml
<!-- ... -->
[tuture-add] <!-- 自定义缓存实现 -->
[tuture-add] <dependency>
[tuture-add] <groupId>org.springframework.boot</groupId>
[tuture-add] <artifactId>spring-boot-starter-data-redis</artifactId>
[tuture-add] </dependency>
<!-- ... -->

然后在配置文件中添加redis的基本配置

src/main/resources/application.properties
 ...
[tuture-add]spring.redis.database=0
[tuture-add]spring.redis.host=localhost
[tuture-add]spring.redis.port=6379
[tuture-add]spring.redis.password=

接下来实现JustAuth对外提供的缓存接口AuthStateCache,我们使用RedisTemplate实现对state的缓存。

src/main/java/me/zhyd/justauth/cache/AuthStateRedisCache.java
package me.zhyd.justauth.cache;

import me.zhyd.oauth.cache.AuthCacheConfig;
import me.zhyd.oauth.cache.AuthStateCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.concurrent.TimeUnit;

/**
* 扩展Redis版的state缓存
*
* @author yadong.zhang (yadong.zhang0415(a)gmail.com)
* @version 1.0.0
* @date 2020/4/25 14:21
* @since 1.0.0
*/
@Component
public class AuthStateRedisCache implements AuthStateCache {

@Autowired
private RedisTemplate<String, String> redisTemplate;

private ValueOperations<String, String> valueOperations;

@PostConstruct
public void init() {
valueOperations = redisTemplate.opsForValue();
}

/**
* 存入缓存,默认3分钟
*
* @param key 缓存key
* @param value 缓存内容
*/
@Override
public void cache(String key, String value) {
valueOperations.set(key, value, AuthCacheConfig.timeout, TimeUnit.MILLISECONDS);
}

/**
* 存入缓存
*
* @param key 缓存key
* @param value 缓存内容
* @param timeout 指定缓存过期时间(毫秒)
*/
@Override
public void cache(String key, String value, long timeout) {
valueOperations.set(key, value, timeout, TimeUnit.MILLISECONDS);
}

/**
* 获取缓存内容
*
* @param key 缓存key
* @return 缓存内容
*/
@Override
public String get(String key) {
return valueOperations.get(key);
}

/**
* 是否存在key,如果对应key的value值已过期,也返回false
*
* @param key 缓存key
* @return true:存在key,并且value没过期;false:key不存在或者已过期
*/
@Override
public boolean containsKey(String key) {
return redisTemplate.hasKey(key);
}
}

注意

AuthCacheConfig为JustAuth默认的缓存配置类,AuthCacheConfig.timeout为内置的缓存过期时间,默认3分钟有效期

JustAuth对外提供的Request类,默认都会支持两种构造参数,一种是只需传入AuthConfig,JustAuth默认使用内置缓存;另外一种则是支持传入AuthConfig和AuthStateCache

接下来我们需要将上一步创建的stateCache实现类注入到JustAuth的Request中。

src/main/java/me/zhyd/justauth/JustAuthController.java
// ...
[tuture-add] /**
[tuture-add] * 注入自定义的缓存实现类
[tuture-add] */
[tuture-add] @Autowired
[tuture-add] private AuthStateRedisCache stateRedisCache;
// ...
private AuthRequest getAuthRequest() {
return new AuthGiteeRequest(AuthConfig.builder()
.clientId("4c504cd2e1b1dbaba8dc1187d8070adf679acab17b2bc9cf6dfa76b9ae06aadc")
.clientSecret("fa5857175723475e4675e36af9eafde338545c1a0dfa49d1e0cc78f9c3ce5ebe")
.redirectUri("http://localhost:8080/oauth/callback/gitee")
// ...
[tuture-add] .build(), stateRedisCache);
}
// ...

我们通过断点看一下是否已启用我们自定义的缓存实现。

可以看到,当前使用的缓存实现就是我们自定义的缓存。

注意

AuthCacheConfig.timeout为内置的缓存过期时间,默认3分钟有效期。主要基于正常流程考虑,一个授权流程的时间一般不会太长,因此综合考虑下,为了保证OAuth流程能够正常走完,且保证state的有效性,系统默认3分钟有效期。可以通过重新赋值 AuthCacheConfig.timeout实现缓存时间自定义或者在实现public void cache(String key, String value)方法时自定义缓存过期时间。

:heavy_plus_sign: 集成自有的Gitlab私服登录

JustAuth发展到现在,基本上已经涵盖了国内外大多数知名的网站。JustAuth也一直以它的,备受各位开发者的喜爱和支持。

但现在OAuth技术越来越成熟,越来越多的个人站长或者企业都开始搭建自己的OAuth授权平台,那么针对这种情况,JustAuth并不能做到面面俱到,也无法集成所有支持OAuth的网站(这也是不现实的)。

既然考虑到有这种需求,那么就要想办法解决、想办法填补漏洞,不为了自己,也为了陪伴JustAuth一路走来的所有朋友们。


JustAuth
开发团队也在v1.12.0版本中新加入了一大特性,就是可以支持任意支持OAuth的网站通过JustAuth实现便捷的OAuth登录!

本节内容,将会演示如何集成自有的Gitlab私服实现第三方登录。


准备Gitlab私服

首先我们要有一个可用的Gitlab服私服,如果没有请自行解决。

创建OAuth应用

创建完成后将会得到如下内容:

复制Application Id、Secret和Callback url备用

实现AuthSource接口

AuthSource.java是提供OAuth平台的API地址的统一接口,提供以下接口:

  • AuthSource#authorize(): 获取授权url. 必须实现

  • AuthSource#accessToken(): 获取accessToken的url. 必须实现

  • AuthSource#userInfo(): 获取用户信息的url. 必须实现

  • AuthSource#revoke(): 获取取消授权的url. 非必须实现接口(部分平台不支持)

  • AuthSource#refresh(): 获取刷新授权的url. 非必须实现接口(部分平台不支持)

注:

注意

  1. 当通过JustAuth扩展实现第三方授权时,请参考AuthDefaultSource自行创建对应的枚举类并实现AuthSource接口
  2. 如果不是使用的枚举类,那么在授权成功后获取用户信息时,需要单独处理source字段的赋值
  3. 如果扩展了对应枚举类时,在me.zhyd.oauth.request.AuthRequest#login(AuthCallback)中可以通过xx.toString()获取对应的source
src/main/java/me/zhyd/justauth/ext/AuthCustomSource.java
package me.zhyd.justauth.ext;

import me.zhyd.oauth.config.AuthSource;

/**
* 自定义的AuthSource,用来集成自有的OAUTH系统
*
* @author yadong.zhang (yadong.zhang0415(a)gmail.com)
* @version 1.0.0
* @date 2020/4/25 17:37
* @since 1.0.0
*/
public enum AuthCustomSource implements AuthSource {

/**
* 自己搭建的gitlab私服
*/
MYGITLAB {
/**
* 授权的api
*
* @return url
*/
@Override
public String authorize() {
return "http://gitlab.demo.dev/oauth/authorize";
}

/**
* 获取accessToken的api
*
* @return url
*/
@Override
public String accessToken() {
return "http://gitlab.demo.dev/oauth/token";
}

/**
* 获取用户信息的api
*
* @return url
*/
@Override
public String userInfo() {
return "http://gitlab.demo.dev/api/v4/user";
}
}
}

注意,文中的gitlab服务url已被我脱敏,请使用者换成自己的gitlab服务域名,比如:你的私服域名是https://gitlab.zhyd.me,那就将上文中的http://gitlab.demo.dev全部替换成https://gitlab.zhyd.me即可。

创建自定义的Request

这儿直接参考AuthGitlabRequest即可,完整代码如下:

src/main/java/me/zhyd/justauth/ext/AuthMyGitlabRequest.java
package me.zhyd.justauth.ext;

import com.alibaba.fastjson.JSONObject;
import me.zhyd.oauth.cache.AuthStateCache;
import me.zhyd.oauth.config.AuthConfig;
import me.zhyd.oauth.enums.AuthUserGender;
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.UrlBuilder;

/**
* 自定义的OAuth平台的Request
*
* @author yadong.zhang (yadong.zhang0415(a)gmail.com)
* @version 1.0.0
* @date 2020/4/25 17:39
* @since 1.0.0
*/
public class AuthMyGitlabRequest extends AuthDefaultRequest {

public AuthMyGitlabRequest(AuthConfig config) {
super(config, AuthCustomSource.MYGITLAB);
}

public AuthMyGitlabRequest(AuthConfig config, AuthStateCache authStateCache) {
super(config, AuthCustomSource.MYGITLAB, authStateCache);
}

@Override
protected AuthToken getAccessToken(AuthCallback authCallback) {
String responseBody = doPostAuthorizationCode(authCallback.getCode());
JSONObject object = JSONObject.parseObject(responseBody);

this.checkResponse(object);

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

@Override
protected AuthUser getUserInfo(AuthToken authToken) {
String responseBody = doGetUserInfo(authToken);
JSONObject object = JSONObject.parseObject(responseBody);

this.checkResponse(object);

return AuthUser.builder()
.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) {
// oauth/token 验证异常
if (object.containsKey("error")) {
throw new AuthException(object.getString("error_description"));
}
// user 验证异常
if (object.containsKey("message")) {
throw new AuthException(object.getString("message"));
}
}

/**
* 返回带{@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", "read_user+openid")
.build();
}
}

修改JustAuthController

这儿我们对JustAuthController最一下修改,让其支持多平台,完整代码如下:

src/main/java/me/zhyd/justauth/JustAuthController.java
package me.zhyd.justauth;

import me.zhyd.justauth.cache.AuthStateRedisCache;
[tuture-add]import me.zhyd.justauth.ext.AuthMyGitlabRequest;
import me.zhyd.oauth.config.AuthConfig;
[tuture-add]import me.zhyd.oauth.exception.AuthException;
import me.zhyd.oauth.model.AuthCallback;
[tuture-add]import me.zhyd.oauth.request.AuthDingTalkRequest;
import me.zhyd.oauth.request.AuthGiteeRequest;
import me.zhyd.oauth.request.AuthRequest;
import me.zhyd.oauth.utils.AuthStateUtils;
import org.springframework.beans.factory.annotation.Autowired;
[tuture-add]import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
* 实战演示如何使用JustAuth实现第三方登录
*
* @author yadong.zhang (yadong.zhang0415(a)gmail.com)
* @version 1.0.0
* @since 1.0.0
*/
@RestController
@RequestMapping("/oauth")
public class JustAuthController {

/**
* 注入自定义的缓存实现类
*/
@Autowired
private AuthStateRedisCache stateRedisCache;

/**
* 获取授权链接并跳转到第三方授权页面
*
* @param response response
* @throws IOException response可能存在的异常
*/
@RequestMapping("/render/{source}")
// ...
[tuture-add] public void renderAuth(@PathVariable("source") String source, HttpServletResponse response) throws IOException {
[tuture-add] AuthRequest authRequest = getAuthRequest(source);
String authorizeUrl = authRequest.authorize(AuthStateUtils.createState());
response.sendRedirect(authorizeUrl);
}

/**
* 用户在确认第三方平台授权(登录)后, 第三方平台会重定向到该地址,并携带code、state等参数
*
* @param callback 第三方回调时的入参
* @return 第三方平台的用户信息
*/
@RequestMapping("/callback/{source}")
// ...
[tuture-add] public Object login(@PathVariable("source") String source, AuthCallback callback) {
[tuture-add] AuthRequest authRequest = getAuthRequest(source);
return authRequest.login(callback);
}

/**
* 获取授权Request
*
* @return AuthRequest
*/
// ...
[tuture-add] private AuthRequest getAuthRequest(String source) {
[tuture-add] AuthRequest authRequest = null;
[tuture-add] switch (source) {
[tuture-add] case "gitee":
[tuture-add] authRequest = new AuthGiteeRequest(AuthConfig.builder()
[tuture-add] .clientId("4c504cd2e1b1dbaba8dc1187d8070adf679acab17b2bc9cf6dfa76b9ae06aadc")
[tuture-add] .clientSecret("fa5857175723475e4675e36af9eafde338545c1a0dfa49d1e0cc78f9c3ce5ebe")
[tuture-add] .redirectUri("http://localhost:8080/oauth/callback/gitee")
[tuture-add] .build(), stateRedisCache);
[tuture-add] break;
[tuture-add] case "mygitlab":
[tuture-add] authRequest = new AuthMyGitlabRequest(AuthConfig.builder()
[tuture-add] .clientId("6ff1e2ccc356a4c193b663a2fbd4be34807e97a630e6e225d8d980ee9406d4a1")
[tuture-add] .clientSecret("d935d24579e689c7cc8b41407a1b7886e45e8da3cd40fb2694bc8a00c0430c4e")
[tuture-add] .redirectUri("http://localhost:8080/oauth/callback/mygitlab")
[tuture-add] .build());
[tuture-add] break;
[tuture-add] default:
[tuture-add] break;
[tuture-add] }
[tuture-add] if (null == authRequest) {
[tuture-add] throw new AuthException("未获取到有效的Auth配置");
[tuture-add] }
[tuture-add] return authRequest;
}

}

重启项目,浏览器端访问http://localhost:8080/oauth/render/mygitlab将会看到如下页面:

点击Authorize后就完成了gitlab私服的登录

至此,我们就实现了集成自有Gitlab私服登录的功能

图雀社区 微信公众号

扫一扫关注上方公众号,拉学习群和答疑解惑

  • 本文作者: 图雀社区
  • 本文链接: https://tuture.co/2020/04/25/732567c/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!