Spring Security OAuth 2.0授權(quán)服務(wù)器結(jié)合Redis實(shí)現(xiàn)獲取accessToken速率限制

Spring Security OAuth 2.0授權(quán)服務(wù)器結(jié)合Redis實(shí)現(xiàn)獲取accessToken速率限制

概述

在生產(chǎn)環(huán)境中脐区,我們通常頒發(fā)給OAuth2客戶端有效期較長(zhǎng)的token蚪腐,但是授權(quán)服務(wù)無(wú)從知曉O(shè)Auth2客戶端服務(wù)是否頻繁獲取token,便于我們主動(dòng)控制token的頒發(fā)泽台,減少數(shù)據(jù)庫(kù)操作,本文我們將結(jié)合Redis實(shí)現(xiàn)滑動(dòng)窗口算法限制速率解決此問(wèn)題瓜贾。

先決條件

  • java 8+
  • Redis
  • Lua

授權(quán)服務(wù)器

本節(jié)中我們將使用Spring Authorization Server 搭建一個(gè)簡(jiǎn)單的授權(quán)服務(wù)器悍抑,并通過(guò)擴(kuò)展OAuth2TokenCustomizer實(shí)現(xiàn)access_token的速率限制。

Maven依賴

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
            <version>2.6.7</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-authorization-server</artifactId>
            <version>0.3.1</version>
        </dependency>

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

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <version>2.6.7</version>
        </dependency>

配置

首先添加spring.redis配置連接本地Redis服務(wù):

server:
  port: 8080

spring:
  redis:
    host: localhost
    database: 0
    port: 6379
    password: 123456
    timeout: 1800
    lettuce:
      pool:
        max-active: 20
        max-wait: 60
        max-idle: 5
        min-idle: 0
      shutdown-timeout: 100

接下來(lái)我們需要注冊(cè)一個(gè)OAuth2客戶端开呐,聲明客戶端如下:

    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("relive-client")
                .clientSecret("{noop}relive-client")
                .clientAuthenticationMethods(s -> {
                    s.add(ClientAuthenticationMethod.CLIENT_SECRET_POST);
                    s.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
                })
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .redirectUri("http://127.0.0.1:8070/login/oauth2/code/messaging-client-model")
                .scope("message.read")
                .clientSettings(ClientSettings.builder()
                        .requireAuthorizationConsent(false)
                        .requireProofKey(false)
                        .build())
                .tokenSettings(TokenSettings.builder()
                        .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
                        .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
                        .accessTokenTimeToLive(Duration.ofSeconds(30 * 60))
                        .refreshTokenTimeToLive(Duration.ofSeconds(60 * 60))
                        .reuseRefreshTokens(true)
                        .setting("accessTokenLimitTimeSeconds", 5 * 60)
                        .setting("accessTokenLimitRate", 3)
                        .build())
                .build();

        return new InMemoryRegisteredClientRepository(registeredClient);
    }

上述OAuth2客戶端信息如下:

特別注意:我們額外添加了兩個(gè)參數(shù)用于控制AccessToken的速率限制,accessTokenLimitTimeSeconds訪問(wèn)限制時(shí)間规求,accessTokenLimitRate訪問(wèn)限制次數(shù)筐付。
此外,我們?yōu)閱蝹€(gè)客戶端添加限制參數(shù)阻肿,由此可以針對(duì)不同OAuth2客戶端設(shè)置不同的速率限制或者取消瓦戚。

使用Spring Authorization Server提供的授權(quán)服務(wù)默認(rèn)配置,并將未認(rèn)證的授權(quán)請(qǐng)求重定向到登錄頁(yè)面:

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        return http.exceptionHandling(exceptions -> exceptions.
                authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))).build();
    }

其余常規(guī)配置本文將不再贅述丛塌,您可以參考以往文章或從文末鏈接中獲取源碼较解。


接下來(lái)我們將利用Redis sorted set數(shù)據(jù)結(jié)構(gòu)實(shí)現(xiàn)滑動(dòng)窗口算法用于access_token速率限制畜疾,我們將利用Lua腳本保證Redis操作的原子性,節(jié)省網(wǎng)絡(luò)開銷印衔。

redis.replicate_commands()

local key = KEYS[1]

local windowSize = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(redis.call("TIME")[1])

redis.call("zadd", key, now, now)
local start = math.max(0, now - windowSize)

local requestRate = tonumber(redis.call("zcount", key, start, now))

local result = true
if requestRate > rate then
  result = false
end

redis.call("zremrangebyscore", key, "-inf", "("..start)

return result

上述Lua腳本遵循以下步驟:

  • 將當(dāng)前時(shí)間(秒)作為value和score 添加進(jìn)有序集合(sorted set)中
  • 計(jì)算窗口長(zhǎng)度啡捶,統(tǒng)計(jì)窗口中成員總數(shù),該總數(shù)表示該窗口長(zhǎng)度中已請(qǐng)求次數(shù)
  • 判斷請(qǐng)求次數(shù)是否超過(guò)閾值
  • 移除已失效成員

RedisAccessTokenLimiterTokenSettings獲取參數(shù)accessTokenLimitTimeSeconds奸焙,accessTokenLimitRate瞎暑,由RedisTemplate執(zhí)行Lua腳本,并傳遞參數(shù)信息与帆。

@Slf4j
public class RedisAccessTokenLimiter implements AccessTokenLimiter {
    private static final String ACCESS_TOKEN_LIMIT_TIME_SECONDS = "accessTokenLimitTimeSeconds";
    private static final String ACCESS_TOKEN_LIMIT_RATE = "accessTokenLimitRate";
    private final RedisTemplate<String, Object> redisTemplate;
    private final RedisScript<Boolean> script;

    public RedisAccessTokenLimiter(RedisTemplate<String, Object> redisTemplate, RedisScript<Boolean> script) {
        Assert.notNull(redisTemplate, "redisTemplate can not be null");
        Assert.notNull(script, "script can not be null");
        this.redisTemplate = redisTemplate;
        this.script = script;
    }


    @Override
    public boolean isAllowed(RegisteredClient registeredClient) {

        TokenSettings tokenSettings = registeredClient.getTokenSettings();
        if (tokenSettings == null || tokenSettings.getSetting(ACCESS_TOKEN_LIMIT_TIME_SECONDS) == null ||
                tokenSettings.getSetting(ACCESS_TOKEN_LIMIT_RATE) == null) {
            return true;
        }
        int accessTokenLimitTimeSeconds = tokenSettings.getSetting(ACCESS_TOKEN_LIMIT_TIME_SECONDS);

        int accessTokenLimitRate = tokenSettings.getSetting(ACCESS_TOKEN_LIMIT_RATE);

        String clientId = registeredClient.getClientId();

        try {
            List<String> keys = getKeys(clientId);

            return redisTemplate.execute(this.script, keys, accessTokenLimitTimeSeconds, accessTokenLimitRate);
        } catch (Exception e) {
            /*
             * 我們不希望硬依賴 Redis 來(lái)允許訪問(wèn)了赌。 確保設(shè)置
             * 一個(gè)警報(bào),知道發(fā)生了許多次玄糟。
             */
            log.error("Error determining if user allowed from redis", e);
        }
        return true;
    }

    static List<String> getKeys(String id) {
        // 在key周圍使用 `{}` 以使用 Redis Key hash tag
        // 這允許使用 redis 集群
        String prefix = "access_token_rate_limiter.{" + id;

        String key = prefix + "}.client";
        return Arrays.asList(key);
    }

}

已知OAuth2TokenCustomizer提供了自定義OAuth2Token的屬性的能力勿她,但是在本示例中我們將使用OAuth2TokenCustomizer作為擴(kuò)展點(diǎn),使用AccessTokenLimiter提供了速率限制阵翎,當(dāng)請(qǐng)求超過(guò)閾值時(shí)逢并,將拋出OAuth2AuthenticationException異常。


public class AccessTokenRestrictionCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {
    private static final String DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1";
    private final AccessTokenLimiter tokenLimiter;

    public AccessTokenRestrictionCustomizer(AccessTokenLimiter tokenLimiter) {
        Assert.notNull(tokenLimiter, "accessTokenLimiter can not be null");
        this.tokenLimiter = tokenLimiter;
    }

    /**
     * 通過(guò){@link AccessTokenLimiter} 為OAuth2 客戶端模式訪問(wèn)令牌添加訪問(wèn)限制
     *
     * @param context
     */
    @Override
    public void customize(JwtEncodingContext context) {
        if (AuthorizationGrantType.CLIENT_CREDENTIALS.equals(context.getAuthorizationGrantType())) {
            RegisteredClient registeredClient = context.getRegisteredClient();
            if (registeredClient == null) {
                OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, "OAuth 2.0 Parameter: " + OAuth2ParameterNames.CLIENT_ID, DEFAULT_ERROR_URI);
                throw new OAuth2AuthenticationException(error);
            }


            boolean requiresGenerateToken = this.tokenLimiter.isAllowed(registeredClient);
            if (!requiresGenerateToken) {
                OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.ACCESS_DENIED,
                        "The token generation fails, and the same client is prohibited from repeatedly obtaining the token within a short period of time.", null);
                throw new OAuth2AuthenticationException(error);
            }
        }

    }
}


注意:上述示例中我們使用OAuth 2.0 客戶端模式贮喧。

測(cè)試

本示例中我們限制access_token請(qǐng)求5分鐘響應(yīng)3次筒狠,我們將使用以下單元測(cè)試簡(jiǎn)單測(cè)試。

    @Test
    public void authorizationWhenObtainingTheAccessTokenSucceeds() throws Exception {
        MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
        parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue());
        parameters.set(OAuth2ParameterNames.CLIENT_ID, "relive-client");
        parameters.set(OAuth2ParameterNames.CLIENT_SECRET, "relive-client");
        this.mockMvc.perform(post("/oauth2/token")
                .params(parameters))
                .andExpect(status().is2xxSuccessful());


    }

    @Test
    public void authorizationWhenTokenAccessRestrictionIsTriggeredThrowOAuth2AuthenticationException() throws Exception {
        MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
        parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue());
        parameters.set(OAuth2ParameterNames.CLIENT_ID, "relive-client");
        parameters.set(OAuth2ParameterNames.CLIENT_SECRET, "relive-client");
        this.mockMvc.perform(post("/oauth2/token")
                .params(parameters))
                .andExpect(status().isBadRequest())
                .andExpect(result -> assertEquals("{\"error_description\":\"The token generation fails, and the same client is prohibited from repeatedly obtaining the token within a short period of time.\",\"error\":\"access_denied\"}", result.getResponse().getContentAsString()));
    }

結(jié)論

可能有人會(huì)有疑問(wèn)箱沦,一般服務(wù)都會(huì)由網(wǎng)關(guān)限流辩恼,為什么使用本示例中方式。當(dāng)然谓形,從實(shí)現(xiàn)上并不妨礙我們?cè)诰W(wǎng)關(guān)中進(jìn)行限制灶伊,這只是一個(gè)選擇問(wèn)題。后續(xù)文章中我將會(huì)介紹如何通過(guò)Spring Cloud Gateway結(jié)合授權(quán)服務(wù)對(duì)OAuth2客戶端進(jìn)行速率限制寒跳。

與往常一樣聘萨,本文中使用的源代碼可在 GitHub 上獲得。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末童太,一起剝皮案震驚了整個(gè)濱河市米辐,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌书释,老刑警劉巖翘贮,帶你破解...
    沈念sama閱讀 218,941評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異爆惧,居然都是意外死亡狸页,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門扯再,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)芍耘,“玉大人址遇,你說(shuō)我怎么就攤上這事≌海” “怎么了倔约?”我有些...
    開封第一講書人閱讀 165,345評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)窃页。 經(jīng)常有香客問(wèn)我跺株,道長(zhǎng),這世上最難降的妖魔是什么脖卖? 我笑而不...
    開封第一講書人閱讀 58,851評(píng)論 1 295
  • 正文 為了忘掉前任乒省,我火速辦了婚禮,結(jié)果婚禮上畦木,老公的妹妹穿的比我還像新娘袖扛。我一直安慰自己,他們只是感情好十籍,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,868評(píng)論 6 392
  • 文/花漫 我一把揭開白布蛆封。 她就那樣靜靜地躺著,像睡著了一般勾栗。 火紅的嫁衣襯著肌膚如雪惨篱。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,688評(píng)論 1 305
  • 那天围俘,我揣著相機(jī)與錄音砸讳,去河邊找鬼。 笑死界牡,一個(gè)胖子當(dāng)著我的面吹牛簿寂,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播宿亡,決...
    沈念sama閱讀 40,414評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼常遂,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了挽荠?” 一聲冷哼從身側(cè)響起克胳,我...
    開封第一講書人閱讀 39,319評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎圈匆,沒(méi)想到半個(gè)月后漠另,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,775評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡臭脓,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,945評(píng)論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了腹忽。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片来累。...
    茶點(diǎn)故事閱讀 40,096評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡砚作,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出嘹锁,到底是詐尸還是另有隱情葫录,我是刑警寧澤,帶...
    沈念sama閱讀 35,789評(píng)論 5 346
  • 正文 年R本政府宣布领猾,位于F島的核電站米同,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏摔竿。R本人自食惡果不足惜面粮,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,437評(píng)論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望继低。 院中可真熱鬧熬苍,春花似錦、人聲如沸袁翁。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)粱胜。三九已至柄驻,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間焙压,已是汗流浹背鸿脓。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評(píng)論 1 271
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留冗恨,地道東北人答憔。 一個(gè)月前我還...
    沈念sama閱讀 48,308評(píng)論 3 372
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像掀抹,于是被迫代替她去往敵國(guó)和親虐拓。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,037評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容