一杯茶的时间,上手第三方登录类库 JustAuth

3,330 阅读18分钟

JustAuth,如你所见,它仅仅是一个第三方授权登录的工具类库,它可以让我们脱离繁琐的第三方登录SDK,让登录变得So easy! 本专栏将会由浅入深,详细介绍如何使用JustAuth实现第三方登录,以及如何使用JustAuth的高级特性。

使用SpringBoot初始化项目

在教程正式开始前,我们要先准备好相应的软件环境。JustAuth

在IDEA下使用以下方式创建项目:依次点击File-New-Project然后选择Spring Initializr,根据提示进行操作,配置依赖时勾选spring-boot-starter-weblombok两个依赖,为了方便开发测试,我这儿多选了一个spring-boot-devtools

按照提示创建完成后,得到POM如下

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.6.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>me.zhyd.justauth</groupId>
    <artifactId>justauth-tutorial</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>justauth-tutorial</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

等待项目编译完成后,我们开始正式集成JustAuth。

添加JustAuth依赖

正式开始前,建议朋友们先看一下JustAuth的用户文档:docs.justauth.whnb.wang, 重点关注快速开始这个章节。

这个章节中包含了关于OAuth和JustAuth相关的重要信息,再次建议优先查看该章节内容。

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

  1. 申请注册第三方平台的开发者账号
  2. 创建第三方平台的应用,获取配置信息(accessKey, secretKey, redirectUri)
  3. 使用该工具实现授权登陆

当然, 第三步和前两步谁先谁后都无所谓,您可以先实现代码再申请第三方应用,也可以先申请第三方应用,再集成代码。

接下来我们按照文档上的第一步指示,先添加pom依赖。

// ...
        <dependency>
            <groupId>me.zhyd.oauth</groupId>
            <artifactId>JustAuth</artifactId>
            <version>1.15.1</version>
        </dependency>
        // ...

依赖添加完成后,等待项目编译完成,接下来我们就开始正式接入JustAuth。

集成JustAuth的API

注意,以下代码中,我们的请求链接中是通过动态参数{source}去取的,这样可以方便的让我们集成任意平台,比如集成gitee时, 我们的请求地址就是:http://localhost:8080/oauth/render/gitee, 而回调地址就是http://localhost:8080/oauth/callback/gitee。

当然,例子中只是举例告诉大家可以这么用,但如果各位只需要集成单一平台的话, 可以直接将{souce}改为平台名,如gitee

package me.zhyd.justauth;

import me.zhyd.oauth.config.AuthConfig;
import me.zhyd.oauth.model.AuthCallback;
import me.zhyd.oauth.request.AuthGiteeRequest;
import me.zhyd.oauth.request.AuthRequest;
import me.zhyd.oauth.utils.AuthStateUtils;
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 {

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

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

    /**
     * 获取授权Request
     *
     * @return AuthRequest
     */
    private AuthRequest getAuthRequest() {
        return new AuthGiteeRequest(AuthConfig.builder()
                .clientId("clientId")
                .clientSecret("clientSecret")
                .redirectUri("redirectUri")
                .build());
    }

}

接下来我们就需要去gitee上创建我们的OAuth应用。登录gitee后,我们点击右上角用户头像,选择设置,然后点击第三方应用进入第三方应用管理页面,点击右上角的创建应用按钮,进入应用创建页面

我们按照提示填入我们的应用信息即可。

应用名称: 一般填写自己的网站名称即可

应用描述: 一般填写自己的应用描述即可

应用主页: 填写自己的网站首页地址

应用回调地址: 重点,该地址为用户授权后需要跳转到的自己网站的地址,默认携带一个code参数

权限: 根据页面提示操作,默认勾选第一个就行,因为我们只需要获取用户信息即可

以上信息输入完成后,点击确定按钮创建应用。创建完成后,点击进入应用详情页,可以看到应用的密钥等信息

复制以下三个信息:Client IDClient Secret应用回调地址

自定义HTTP工具

将上一步获取到配置信息配置AuthConfig中,如下:

// ...
     */
    private AuthRequest getAuthRequest() {
        return new AuthGiteeRequest(AuthConfig.builder()
                .clientId("4c504cd2e1b1dbaba8dc1187d8070adf679acab17b2bc9cf6dfa76b9ae06aadc")
                .clientSecret("fa5857175723475e4675e36af9eafde338545c1a0dfa49d1e0cc78f9c3ce5ebe")
                .redirectUri("http://localhost:8080/oauth/callback/gitee")
                .build());
    }

}

以上工作完成后,我们直接启动项目,然后在浏览器中访问http://localhost:8080/oauth/render/gitee,当出现以下页面时,表示我们已经集成成功,并且已跳转到了第三方的授权页面。

注意,如果您当前没有在浏览器中登录过您的账号,您将会看到如下页面:

我们点击“同意授权”后,第三方应用(Gitee)将会生成一个code授权码,连带着我们先前传过去的state一并回调到我们配置的redirectUri接口中。

如上图,进入回调接口后,我们可以断点跟踪到第三方平台传回的信息:state和code。

如果您是新项目,这儿可能会出现一个小小的问题:

2020-04-21 23:50:02 http-nio-8080-exec-4 me.zhyd.oauth.log.Log(error:45) [ERROR] - Failed to login with oauth authorization.
com.xkcoding.http.exception.SimpleHttpException: HTTP 实现类未指定!
	at com.xkcoding.http.HttpUtil.checkHttpNotNull(HttpUtil.java:70)
	at com.xkcoding.http.HttpUtil.post(HttpUtil.java:119)
	at me.zhyd.oauth.request.AuthDefaultRequest.doPostAuthorizationCode(AuthDefaultRequest.java:213)
	at me.zhyd.oauth.request.AuthGiteeRequest.getAccessToken(AuthGiteeRequest.java:31)
	at me.zhyd.oauth.request.AuthDefaultRequest.login(AuthDefaultRequest.java:79)
	at me.zhyd.justauth.JustAuthController.login(JustAuthController.java:47)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:190)
	at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:138)
	at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:105)
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:879)
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:793)
	at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1040)
	at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:943)
	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
	at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898)
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:634)
	at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:741)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
	at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
	at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
	at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
	at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
	at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:202)
	at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:96)
	at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:541)
	at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:139)
	at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92)
	at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)
	at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:343)
	at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:373)
	at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65)
	at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:868)
	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1594)
	at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
	at java.lang.Thread.run(Thread.java:748)

这是因为JustAuth从v1.14.0开始默认集成了的simple-http作为HTTP通用接口(更新说明见JustAuth 1.14.0版本正式发布!完美解耦HTTP工具开始JustAuth将不会默认集成hutool-http,如果开发者的项目是全新的或者项目内没有集成HTTP实现工具,还需要添加对应的HTTP实现类,JustAuth提供了三种备选的pom依赖:

hutool-http

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-http</artifactId>
    <version>5.2.5</version>
</dependency>

httpclient

<dependency>
    <groupId>org.apache.httpcomponents</groupId>
      <artifactId>httpclient</artifactId>
      <version>4.5.12</version>
</dependency>

okhttp

<dependency>
  <groupId>com.squareup.okhttp3</groupId>
  <artifactId>okhttp</artifactId>
  <version>4.4.1</version>
</dependency>

添加完HTTP工具依赖后,重启项目,重新访问http://localhost:8080/oauth/render/gitee链接,然后进行授权。

授权完成后,您将会看到如下页面:

自定义缓存

在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的流程,可以参考:segmentfault.com/a/119000002…

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

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

首先在项目中添加Redis依赖

// ...
        <!-- 自定义缓存实现 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
    // ...

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

 // ...
spring.redis.database=0
spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=

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

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中。

// ...
    /**
     * 注入自定义的缓存实现类
     */
    @Autowired
    private AuthStateRedisCache stateRedisCache;
 // ...
    private AuthRequest getAuthRequest() {
        return new AuthGiteeRequest(AuthConfig.builder()
                .clientId("4c504cd2e1b1dbaba8dc1187d8070adf679acab17b2bc9cf6dfa76b9ae06aadc")
                .clientSecret("fa5857175723475e4675e36af9eafde338545c1a0dfa49d1e0cc78f9c3ce5ebe")
                .redirectUri("http://localhost:8080/oauth/callback/gitee")
                // ...
                .build(), stateRedisCache);
    }
 // ...

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

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

注意

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

集成自有的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
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即可,完整代码如下:

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最一下修改,让其支持多平台,完整代码如下:

package me.zhyd.justauth;

import me.zhyd.justauth.cache.AuthStateRedisCache;
import me.zhyd.justauth.ext.AuthMyGitlabRequest;
import me.zhyd.oauth.config.AuthConfig;
import me.zhyd.oauth.exception.AuthException;
import me.zhyd.oauth.model.AuthCallback;
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;
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}")
    // ...
    public void renderAuth(@PathVariable("source") String source, HttpServletResponse response) throws IOException {
        AuthRequest authRequest = getAuthRequest(source);
        String authorizeUrl = authRequest.authorize(AuthStateUtils.createState());
        response.sendRedirect(authorizeUrl);
    }

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

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

}

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

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

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

新计划:支持更多语言SDK

最后,发布一条启示,招募更多 NodeJS、Python、PHP、Go 社区开发者:

JustAuth组织:github.com/justauth

JustAuth 计划开源 以下版本的 SDK:

  • NodeJS
  • Python
  • PHP
  • Go

最近的一版,将优先考虑推出 NodeJS 版的 JustAuth,如果感兴趣的小伙伴,欢迎报名,欢迎一起来做。还是那句话:和一群志同道合的朋友一起做一件喜欢的事情,是一种幸福。

如有兴趣,请加wx:rz04151123(添加备注: JustAuth开发),拉你进专属开发者交流群。

想要学习更多精彩的实战技术教程?来图雀社区逛逛吧。