一、OAuth2简介

所谓OAuth2其实就是Open Authorization,即开放授权,是一种授权机制或者说是一种协议。OAuth2允许用户授权第三方应用访问其存储在开放平台(授权服务器)中的数据而不需要提供密码。授权服务器根据OAuth2协议标准制订一套授权的API,第三方网站接入开放平台之后即可通过其提供的API来实现用户授权和获取授权服务器中用户的信息的功能。

二、授权码模式执行流程

1、网站接入开放平台

先到要接入的开放平台为目标网站进行申请,填写网站的详细信息如域名、名称、回调地址等,以获得对应的client_id和client_secret,以保证后续流程中可以正确对网站与用户进行验证与授权。

  1. client_id:应用的唯一标识
  2. client_secret:client_id对应的密钥,访问用户资源时用来验证应用的合法性。
  3. 回调地址:授权成功后跳转到的页面

2、设置开放平台登录按钮

前端将开放平台提供的授权登录界面地址放置在网站的登录界面中。之后用户点击之后即可进入三方开放平台的授权界面(这里需要用户先登录第三方平台),用户可以看到网站想要获取的权限,如果用户同意授权则会跳转到设置的回调地址。跳转的地址上面还会拼接上code参数,即授权码。

这里请求授权登录时需要带上多个参数,常见的有response_type参数表示返回的格式(code表示请求授权码,token表示直接返回令牌等),client_id参数表示是谁在请求,redirect_uri参数表示用户接受或拒绝请求后的跳转网址,scope参数表示要求授权的范围等。

3、请求令牌和用户信息

前端拿到授权码之后,将其传给后端。随后后端内部使用client_id,client_secret,授权码以及授权模式和回调地址去调用开放平台接口请求令牌(这里client_id和client_secret参数使用给开放平台确认你这个网站的身份的,client_secret是保密的,网站申请之后保存在后端)。三方开放平台接收到请求之后检验网站身份和授权码,确认成功之后就会颁发令牌。

响应体中一般会包含access_token即令牌,用于后续请求用户信息,以及expire_in表示令牌过期时间,refresh_token表示刷新令牌使用的凭证,scope表示允许授权的范围等信息。

拿到令牌之后,后端紧接着就可以携带该令牌去请求开放平台提供的或其个人信息的接口。不同平台的请求方式不同,返回的信息内容也不同。最终可以拿到开放平台的用户id,之后与当前网站的用户id进行关联。

  1. 如果是使用授权进行登录,那么就需要该开放平台对应的账号已经关联了目标网站的账号(或者说目标网站设置了登录即注册)。

  2. 也可以在用户登录之后使用这样一个流程进行与开放平台账号的绑定,以方便以后使用开放平台账号进行登录。

三、存在问题

CSRF(跨站请求伪造)攻击:

当你登录了某个网站之后,(访问恶意网站时)攻击者使用了你的身份,以你的名义向你已经登录的网站发送恶意请求。对于目标网站来说,这个请求时由已经登陆的你来发送的,也就是合法的,然而实际上确实攻击者进行的操作,比如以你的名义发送邮件,信息等。

1、攻击流程

  1. 攻击者访问提供OAuth2登录的第三方网站,并且使用某个开放平台进行授权绑定
  2. 攻击者同意授权之后通过拦截第三方网站请求,拿到网站进行后续申请令牌并进行账号绑定的url,其中含有攻击者自己的授权码code
  3. 攻击者构造一个Web页面,其中会触发前面拦截的那个请求
  4. 用户之前登录了第三方网站只是没有绑定社交账号,并且访问到这个Web页面,发送了那个申请令牌和账号绑定的请求
  5. 第三方网站将用户和攻击者在开放平台的账号关联起来(因为网站以为是用户发送的授权请求,而授权的账号是攻击者的账号),那么之后攻击者就可以使用自己的账号冒充用户在这个网站执行恶意请求。

2、漏洞分析

这里之所以会出现这样的问题,主要是因为OAuth2授权的过程分为了多个步骤,用户在请求令牌时携带的授权码code是有存在被替换掉的风险的。

正如上面的攻击过程,攻击者使用自己在开放平台账号的授权码替换掉了用户请求令牌时的授权码,本来是用户在第三方网站的账号关联用户在开放平台的账号,现在就变成了关联到了攻击者在开放平台的账号,使得攻击者可以登录用户在第三方网站的账号。

其核心就在于发起授权的用户(攻击者)和最后进行账号绑定的用户(真实用户)不是同一个人。那么也就是说如果我们能在绑定账号时是需要校验是否是真实用户执行的操作,就可以避免掉这样的风险。

要实现这样的校验,那么可以通过在用户进行授权操作时,服务端为当前执行该操作的用户生成一个“身份证”,并存储下来。而后续用户要进行申请令牌和账号绑定时,需要传入这个“身份证”,如果执行操作的用户与传入的身份证匹配则允许请求,否则拒绝请求。

这样的情况下,攻击者拦截到的url中的身份证是自己当前这个用户对应的身份证,即使真实用户请求了这个url,那么在进行身份校验时也会发现当前用户与这个传入的身份证并不匹配,从而拒绝后续的令牌申请和账号绑定。

四、组件封装

1、AuthUrls

回想前面授权码登录的步骤,我们知道其实所有的OAuth2授权码模式都可以抽象出以下几个步骤:

  1. 请求开放平台(授权服务器)的授权页面,获得授权码
  2. 通过授权码请求开放平台获取令牌
  3. 使用令牌请求开放平台获取用户信息

也就是说不同的开放平台提供给第三方网站使用的请求地址虽然不同,但都是需要提供上面这几个接口。

那么我们自然可以抽象一个类,定义这样的几个接口,之后每一个的平台都需要去实现这几个接口,定义自己的请求地址。当然除了以上的必须的三个步骤,可能还会定义有令牌续期或者获取openId的API,所以也可以设置这些接口。但由于不是所有平台都有实现,所以则将其设置为默认方法,默认抛出未实现的异常,不要求一定要被实现,有需求则根据需要重写即可。

具体如下:

/**
 * <pre>
 * 获取开放平台授权、申请令牌等API地址的抽象接口
 * </pre>
 *
 * @author <a href="https://github.com/Ken-Chy129">Ken-Chy129</a>
 * @since 2023/3/15 18:58
 */
public interface AuthUrls {

    /**
     * 授权的api
     *
     * @return 开放平台授权api的地址
     */
    String authorize();

    /**
     * 获取accessToken的api
     *
     * @return 开放平台申请令牌api的地址
     */
    String accessToken();

    /**
     * 获取用户信息的api
     *
     * @return 开放平台获取用户信息api的url
     */
    String userInfo();

    /**
     * 刷新accessToken
     *
     * @return 开放平台刷新令牌api的url
     */
    default String refresh() {
        throw new AuthException(AuthExceptionCode.NOT_IMPLEMENTED);
    }

     /**
     * 部分平台可能会有获取openId的操作
     *
     * @return 开放平台获取openId的url
     */
    default String openId() {
        throw new AuthException(AuthExceptionCode.NOT_IMPLEMENTED);
    }
}

以下是几个平台实现的示例,这里采用的是枚举的方式进行实现,更加简洁与清晰:

/**
 * <pre>
 * 开放平台的枚举,实现获取API地址的接口
 * </pre>
 *
 * @author <a href="https://github.com/Ken-Chy129">Ken-Chy129</a>
 * @since 2023/3/15 18:59
 */
public enum AuthPlatformInfo implements AuthUrls {

    GITHUB {
        @Override
        public String authorize() {
            return "https://github.com/login/oauth/authorize";
        }

        @Override
        public String accessToken() {
            return "https://github.com/login/oauth/access_token";
        }

        @Override
        public String userInfo() {
            return "https://api.github.com/user";
        }
    },

    GITEE {
        @Override
        public String authorize() {
            return "https://gitee.com/oauth/authorize";
        }

        @Override
        public String accessToken() {
            return "https://gitee.com/oauth/token";
        }

        @Override
        public String userInfo() {
            return "https://gitee.com/api/v5/user";
        }
    },

    QQ {
        @Override
        public String authorize() {
            return "https://graph.qq.com/oauth2.0/authorize";
        }

        @Override
        public String accessToken() {
            return "https://graph.qq.com/oauth2.0/token";
        }

        @Override
        public String userInfo() {
            return "https://graph.qq.com/user/get_user_info";
        }

        @Override
        public String refresh() {
            return "https://graph.qq.com/oauth2.0/token";
        }

        @Override
        public String openId() {
            return "https://graph.qq.com/oauth2.0/me";
        }

    }

2、AuthRequest

可以拿到开放平台请求的地址之后,我们就可以进行请求。如上面所说,整个授权流程其实无非就那几个步骤,这对所有的开放平台都是一致的,即用户访问开放平台的授权页面进行授权,之后前台拿到授权码进行令牌的获取以及使用令牌完成用户信息的获取。实际开发中一般是两个接口,一个接口用于返回开放平台的授权地址给前端(因为这里需要生成state并保存在后台,所以不是一个固定的地址让前端可以写死,而是需要每次从后端请求),一个接口用于是通过授权码和state完成实际的授权操作。这里为了让方法更加灵活,提高复用性,不与具体业务强耦合,所以我们只是定义几个通用的接口,用户可以使用它们来完成具体业务的开发,如下:

  1. authorizeUrl():获取开放平台的授权地址(分为携带state和不写携带state两种方式)
  2. getAccessToken(AuthCallback callback):通过授权码获取用户令牌
  3. getUserInfo(String accessToken):根据用户访问令牌获取用户信息
  4. revoke(AuthToken authToken):撤销授权
  5. refresh(AuthToken authToken):刷新令牌

其中前面三个接口是所有平台都必须实现的通用方法,后面两个接口并非所有开放平台都有提供对应的API所以并不要求一定要实现。

具体的代码如下:

/**
 * <pre>
 * 默认请求接口,所有开放平台请求都需要实现该接口
 * </pre>
 *
 * @author <a href="https://github.com/Ken-Chy129">Ken-Chy129</a>
 * @since 2023/3/15 17:21
 */
public interface AuthRequest {

    /**
     * 获取开放平台的授权地址(不携带state)<br>
     * 一般用于注册或者已授权的用户进行登录
     * 
     * @return 开放平台的授权地址
     */
    String authorizeUrl();

    /**
     * 获取开放平台的授权地址(携带state)<br>
     * 一般用于已经登录的用户授权关联开放平台,携带state以防止csrf攻击
     * 
     * @param authorizer 授权者唯一标识,用于与state相关联
     * @return 开放平台的授权地址
     */
    String authorizeUrl(String authorizer);

    /**
     * 通过授权码获取用户令牌,无需校验state
     * 
     * @param callback 用于接收回调参数的实体(包括授权码和state等)
     * @return 用户访问令牌
     */
    AuthResponse<AuthToken> getAccessToken(AuthCallback callback);

    /**
     * 通过授权码获取用户令牌,需校验state
     * 
     * @param authorizer 授权者唯一标识
     * @param callback 用于接收回调参数的实体(包括授权码和state等)
     * @return 用户访问令牌
     */
    AuthResponse<AuthToken> getAccessToken(String authorizer, AuthCallback callback);
  
    /**
     * 根据用户访问令牌获取用户信息
     *
     * @param authToken 授权令牌封装体
     * @return 用户信息
     */
    AuthResponse<AuthUserInfo> getUserInfo(AuthToken authToken);

    /**
     * 撤销授权,非必要实现
     *
     * @param authToken 授权令牌封装体
     * @return 是否撤销成功
     */
    default AuthResponse<Boolean> revoke(AuthToken authToken) {
        throw new AuthException(AuthExceptionCode.NOT_IMPLEMENTED);
    }

    /**
     * 刷新access token(续期),非必要实现
     *
     * @param authToken 登录成功后返回的Token信息
     * @return 用户访问令牌 
     */
    default AuthResponse<AuthToken> refresh(AuthToken authToken) {
        throw new AuthException(AuthExceptionCode.NOT_IMPLEMENTED);
    }
}

3、AuthPlatformConfig

请求开放平台提供的API接口时需要携带平台的appKey或者appSecret等,这些是在对接之前在开放平台进行申请时得到的,此外申请时还需要设置回调地址。这些是每个平台都需要的,故我们将这些信息封装成开放平台的配置类,之后在使用之前配置并构造这样一个对象以供请求时使用。

/**
 * <pre>
 * 开放平台配置类
 * </pre>
 *
 * @author <a href="https://github.com/Ken-Chy129">Ken-Chy129</a>
 * @since 2023/3/15 19:31
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AuthPlatformConfig {

    /**
     * 客户端id:对应各平台的appKey
     */
    private String clientId;

    /**
     * 客户端Secret:对应各平台的appSecret
     */
    private String clientSecret;

    /**
     * 登录成功后的回调地址
     */
    private String redirectUri;
  
}

4、DefaultAuthRequest

而为了方便后续的使用,我们在这个接口的基础上定义一个实现了上面接口的默认的授权请求抽象类。其中定义了一些通用的实现方法,后续对于具体平台的实现只需要继承这个抽象类即可,通过复用其中的方法,将大大简化了我们的开发任务。

那么这个抽象类该如何定义呢?

首先在实现上面接口的时候,先让我们都需要获得对应开放平台的API地址以及接入开放平台时的配置信息,所以这里我们需要两个属性AuthUrlsAuthPlatformConfig,在构造请求对象时将开放平台的API地址对象和配置类对象传入进去。此外由于还需要后台还需要存储state,所以我们需要定义缓存对象来操作state(这里这个缓存也抽象出常见的set和get方法,然后可以定义不同的实现策略,如直接使用HashMap进行操作,或者引入Redis缓存等)。故这个类需要有三个属性:

  1. AuthUrls:接口,使用时传入对应平台的实现
  2. AuthPlatformConfig:使用平台对应的配置信息创建这个对象
  3. AuthStateCache:接口,使用时传入想要使用的实现策略

之后我们就可以来实现AuthRequest接口中的方法了。

首先是authorizeUrl()方法。在实现这个方法的时候,如果需要state,那么我们需要进行state的生成,以及将其与授权者关联并存储在缓存中。那在这里我们就可以抽象出两个通用方法,一个是generateAndPutState(String authorizer)方法,用于在调用authorizeUrl(String authorizer)方法时生成state,并关联authorizer存入AuthStateCache对象中。一个是checkState(String authorizer, String state)方法用于在调用getAccessToken()接口前校验当前操作的用户以及前面完成授权的用户是否为同一个。这个方法实际上就是用于构造访问开放平台授权页面的URL,而基本上开放平台遵循OAuth2设计API时采用的都是同一套参数名称,我们将这套参数名称定义在常量类中,然后在此处直接使用这套标准的名称进行构造。那么后续接入开放平台时基本无需重写该方法,直接调用即可。

而像getAccessToken(AuthCallback callback)这样的方法,它其实主要的工作是向开放平台提供的API发起请求,不管对于哪一个开放平台,工作流程都是固定的,即构造请求URL、发送请求、解析返回结果、判断是否请求失败、请求成功则封装请求结果返回。既然如此,那我们可以采用模板设计模式,将这样一套逻辑固定设计为final方法,而暴露其中的子步骤供子类重写。不同的平台构造请求时需要传递的参数可能不一致,或者判断请求是否失败的方式可能不一致,所以我们允许将这些接口下方给给使用者实现。当然我们在这个类中也可以定义默认的实现,因为大多平台的请求URL以及请求出错时的错误字段也基本一致,而对于封装请求结果这个步骤在不同平台中的处理方式基本不同,所以该方法一定需要子类进行重写,抽象类中不提供实现。这样后续在使用时对于大多数平台只需要实现封装请求结果这个步骤即可,之后便能够直接调用,而如果某些平台在其他处理步骤上有所差异则根据具体情况选择重写某些默认实现。

具体示例如下:

/**
 * <pre>
 * 默认授权类
 * </pre>
 *
 * @author <a href="https://github.com/Ken-Chy129">Ken-Chy129</a>
 * @since 2023/3/15 18:50
 */
public abstract class DefaultAuthRequest implements AuthRequest {

    protected final AuthUrls source;

    protected final AuthPlatformConfig config;

    protected final AuthStateCache cache;

    public DefaultAuthRequest(AuthPlatformConfig config, AuthUrls source) {
        this(source, config, DefaultAuthStateCache.INSTANCE);
    }

    public DefaultAuthRequest(AuthUrls source, AuthPlatformConfig config, AuthStateCache cache) {
        this.source = source;
        this.config = config;
        this.cache = cache;
    }

    @Override
    public String authorizeUrl() {
        return authorizeUrl(null);
    }

    @Override
    public String authorizeUrl(String authorizer) {
        return UrlBuilder.fromBaseUrl(source.authorize())
                .add(AuthConstant.Authorize.RESPONSE_TYPE, "code")
                .add(AuthConstant.CLIENT_ID, config.getClientId())
                .add(AuthConstant.REDIRECT_URI, config.getRedirectUri())
                .add(AuthConstant.Authorize.STATE, authorizer != null ? generateAndPutState(authorizer) : null)
                .build();
    }

    @Override
    public final AuthResponse<AuthToken> getAccessToken(AuthCallback callback) {
        return getAccessToken(null, callback);
    }

    @Override
    public final AuthResponse<AuthToken> getAccessToken(String authorizer, AuthCallback callback) {
        try {
            // 如果传入了授权者,则表明需要校验state
            if (authorizer != null) {
                checkState(authorizer, callback.getState());
            }
            // 根据不同平台的参数需求不同,可选择重写生成请求路径的策略
            String response = HttpClientUtil.doAuthGet(generateAccessTokenRequest(callback.getCode()));
            Map<String, String> responseMap = HttpClientUtil.parseResponseEntity(response);
            // 请求发送错误时不同平台响应不同,故委托给使用者实现,如果有错误则抛出异常
            parseResponseException(responseMap);
            // 请求成功则对响应结果进行封装
            AuthToken authToken = parseAccessToken(responseMap);
            return new AuthResponse<AuthToken>().exceptionStatus(AuthExceptionCode.SUCCESS, authToken);
        } catch (AuthException e) {
            return new AuthResponse<>(e.getCode(), e.getMsg(), null);
        }
    }

    @Override
    public final AuthResponse<AuthUserInfo> getUserInfo(AuthToken authToken) {
        try {
            setOpenId(authToken);
            String response = HttpClientUtil.doAuthGet(generateUserInfoRequest(authToken));
            Map<String, String> responseMap = HttpClientUtil.parseResponseEntityJson(response);
            if (authToken.getOpenId() != null) {
                responseMap.put("openid", authToken.getOpenId());
            }
            parseResponseException(responseMap);
            AuthUserInfo authUserInfo = parseUserInfo(responseMap);
            authUserInfo.setRawUserInfo(JSON.parseObject(response));
            authUserInfo.setToken(authToken);
            return new AuthResponse<>(AuthExceptionCode.SUCCESS.getCode(), AuthExceptionCode.SUCCESS.getMsg(), authUserInfo);
        } catch (AuthException e) {
            return new AuthResponse<>(e.getCode(), e.getMsg(), null);
        }
    }

    @Override
    public final AuthResponse<AuthToken> refresh(AuthToken authToken) {
        try {
            String response = HttpClientUtil.doAuthGet(generateUserInfoRequest(authToken));
            Map<String, String> responseMap = HttpClientUtil.parseResponseEntityJson(response);
            parseResponseException(responseMap);
            AuthToken newAuthToken = parseAccessToken(responseMap);
            return new AuthResponse<>(AuthExceptionCode.SUCCESS.getCode(), AuthExceptionCode.SUCCESS.getMsg(), newAuthToken);
        } catch (AuthException e) {
            return new AuthResponse<>(e.getCode(), e.getMsg(), null);
        }
    }

    /**
     * 通过授权码生成请求令牌的Get请求封装体
     *
     * @param code 授权码
     * @return 请求令牌的Get请求封装体
     */
    protected abstract AuthGet generateAccessTokenRequest(String code);

    /**
     * 通过访问令牌生成请求用户信息的Get请求封装体
     *
     * @param authToken 授权令牌封装体
     * @return 请求用户信息的Get请求封装体
     */
    protected abstract AuthGet generateUserInfoRequest(AuthToken authToken);

    /**
     * 通过刷新令牌请求访问令牌的Get请求封装体
     *
     * @param authToken 授权令牌封装体
     * @return 刷新访问令牌的Get请求封装体
     */
    protected AuthGet generateRefreshRequest(AuthToken authToken) {
        throw new AuthException(AuthExceptionCode.NOT_IMPLEMENTED);
    }

    /**
     * 默认的请求错误信息处理
     *
     * @param responseMap 原始响应体的键值对
     */
    protected void parseResponseException(Map<String, String> responseMap) {
        String[] errorCodeFields = new String[]{"error", "error_code"};
        String[] errorMsgFields = new String[]{"error_msg", "error_description"};
        String errorCode, errorMsg;
        for (String errorCodeField : errorCodeFields) {
            if ((errorCode = responseMap.get(errorCodeField)) != null) {
                for (String errorMsgField : errorMsgFields) {
                    if ((errorMsg = responseMap.get(errorMsgField)) != null) {
                        throw new AuthException(Integer.parseInt(responseMap.get(errorCode)), responseMap.get(errorMsg));
                    }
                }
            }
        }
    }

    /**
     * 默认的令牌解析
     * 
     * @param responseMap 原始响应体的键值对
     * @return 授权令牌封装体
     */
    protected AuthToken parseAccessToken(Map<String, String> responseMap) {
        return AuthToken.builder()
                .accessToken(responseMap.get(AuthConstant.Token.ACCESS_TOKEN))
                .expireIn(Integer.parseInt(responseMap.get(AuthConstant.Token.EXPIRE)))
                .refreshToken(responseMap.get(AuthConstant.Token.REFRESH_TOKEN))
                .scope(responseMap.get(AuthConstant.Token.SCOPE))
                .build();
    }
  
    /**
     * 根据不同开放平台响应结果的不同封装AuthUserInfo
     *
     * @param responseMap 原始响应体的键值对
     * @return 封装后的AuthUserInfo对象
     */
    protected abstract AuthUserInfo parseUserInfo(Map<String, String> responseMap);
  
    /**
     * 提供给需要先请求openId的平台调用
     *
     * @param authToken 授权令牌封装体
     */
    protected void setOpenId(AuthToken authToken) {}
  
    /**
     * 生成随机字符串作为state,并与userId关联保存在缓存中<br>
     * 如果不使用state则不需要重写
     *
     * @param authorizer 授权者唯一标识
     * @return 随机字符串state
     */
    protected String generateAndPutState(String authorizer) {
        String state = UUID.randomUUID().toString();
        cache.set(authorizer, state);
        return state;
    }

    /**
     * 根据userId校验state是否合法,即判断是否是当前用户执行的操作,如果不合法则抛出异常
     *
     * @param authorizer 当前操作的用户的唯一标识
     * @param state      调用该操作时传入的身份证明,可以在开放平台确认授权后的回调地址参数中拿到
     */
    protected void checkState(String authorizer, String state) {
        if (state == null || state.isBlank() || cache.get(authorizer).equals(state)) {
            throw new AuthException(AuthExceptionCode.ILLEGAL_STATUS);
        }
    }

}

此处只是提供了部分的代码,具体完整的实现可前往我的Github 查看。

后续还会提供集成SpringBoot的实现,自定义一个spring-boot-starter,方便项目中直接引入,开箱即用!