From 9632604958466c1edd5a163632bcef95d01dda24 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 2 Sep 2026 22:15:21 +0800 Subject: [PATCH] feat(auth): implement decoupled sliding-window rate limiting for login and oauth --- backend/core/contracts/limiter.go | 36 ++++++ backend/docs/docs.go | 19 ++- backend/docs/swagger.json | 19 ++- backend/docs/swagger.yaml | 15 ++- backend/go.mod | 58 +++++---- backend/go.sum | 58 +++++++++ backend/pkg/limiter/memory.go | 119 +++++++++++++++++ backend/pkg/limiter/memory_test.go | 119 +++++++++++++++++ backend/plugins/domain/auth/handlers.go | 15 +++ .../domain/auth/oauth_rate_limit_test.go | 113 ++++++++++++++++ backend/plugins/domain/auth/plugin.go | 2 + backend/plugins/domain/auth/plugin_test.go | 8 ++ backend/plugins/domain/auth/repository.go | 26 +++- backend/plugins/domain/user/errs.go | 4 +- backend/plugins/domain/user/handlers.go | 53 +++++++- .../domain/user/login_rate_limit_test.go | 122 ++++++++++++++++++ backend/plugins/domain/user/plugin.go | 2 + backend/plugins/domain/user/repository.go | 24 +++- backend/plugins/infra/cache/limiter.go | 59 +++++++++ backend/plugins/infra/cache/limiter_test.go | 84 ++++++++++++ backend/plugins/infra/cache/plugin.go | 10 ++ backend/plugins/infra/cache_memory/plugin.go | 2 + .../plugins/infra/cache_memory/plugin_test.go | 10 ++ 23 files changed, 927 insertions(+), 50 deletions(-) create mode 100644 backend/core/contracts/limiter.go create mode 100644 backend/pkg/limiter/memory.go create mode 100644 backend/pkg/limiter/memory_test.go create mode 100644 backend/plugins/domain/auth/oauth_rate_limit_test.go create mode 100644 backend/plugins/domain/user/login_rate_limit_test.go create mode 100644 backend/plugins/infra/cache/limiter.go create mode 100644 backend/plugins/infra/cache/limiter_test.go diff --git a/backend/core/contracts/limiter.go b/backend/core/contracts/limiter.go new file mode 100644 index 00000000..9d5b2718 --- /dev/null +++ b/backend/core/contracts/limiter.go @@ -0,0 +1,36 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package contracts defines unified service interfaces and DTOs for cross-plugin communication. +package contracts + +import ( + "context" + "time" +) + +// Rate specifies a rate limit of Limit events permitted within a Period. +type Rate struct { + Limit int `json:"limit"` + Period time.Duration `json:"period"` +} + +// RateLimitResult holds the outcome of a rate limit check. +type RateLimitResult struct { + Allowed bool `json:"allowed"` + Remaining int `json:"remaining"` + ResetAfter time.Duration `json:"reset_after"` + RetryAfter time.Duration `json:"retry_after"` +} + +// LimiterService defines the rate limiting service contract for cross-plugin communication. +type LimiterService interface { + // Allow checks whether 1 event for the given key is permitted under the specified rate. + Allow(ctx context.Context, key string, rate Rate) (*RateLimitResult, error) + + // AllowN checks whether n events for the given key are permitted under the specified rate. + AllowN(ctx context.Context, key string, rate Rate, n int) (*RateLimitResult, error) + + // Reset clears the rate limit state for the given key. + Reset(ctx context.Context, key string) error +} diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 79ac24d2..4fb284e5 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -5702,7 +5702,7 @@ const docTemplate = `{ }, "/api/v1/user/login": { "post": { - "description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。", + "description": "使用用户名和密码登录系统,验证通过后建立 Session 并返回用户信息。", "consumes": [ "application/json" ], @@ -5712,7 +5712,7 @@ const docTemplate = `{ "tags": [ "user" ], - "summary": "用户密码登录", + "summary": "用户登录", "parameters": [ { "description": "登录请求参数", @@ -5737,6 +5737,12 @@ const docTemplate = `{ "$ref": "#/definitions/response.Any" } }, + "429": { + "description": "登录尝试过于频繁", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, "500": { "description": "服务内部错误", "schema": { @@ -5872,6 +5878,12 @@ const docTemplate = `{ "$ref": "#/definitions/response.Any" } }, + "429": { + "description": "注册尝试过于频繁", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, "500": { "description": "服务内部错误", "schema": { @@ -6081,7 +6093,8 @@ const docTemplate = `{ "type": "string" }, "id": { - "type": "integer" + "type": "string", + "example": "0" }, "is_admin": { "type": "boolean" diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 85a0289a..97b0bf07 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -5695,7 +5695,7 @@ }, "/api/v1/user/login": { "post": { - "description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。", + "description": "使用用户名和密码登录系统,验证通过后建立 Session 并返回用户信息。", "consumes": [ "application/json" ], @@ -5705,7 +5705,7 @@ "tags": [ "user" ], - "summary": "用户密码登录", + "summary": "用户登录", "parameters": [ { "description": "登录请求参数", @@ -5730,6 +5730,12 @@ "$ref": "#/definitions/response.Any" } }, + "429": { + "description": "登录尝试过于频繁", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, "500": { "description": "服务内部错误", "schema": { @@ -5865,6 +5871,12 @@ "$ref": "#/definitions/response.Any" } }, + "429": { + "description": "注册尝试过于频繁", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, "500": { "description": "服务内部错误", "schema": { @@ -6074,7 +6086,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "string", + "example": "0" }, "is_admin": { "type": "boolean" diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index d895e98e..284d2e54 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -28,7 +28,8 @@ definitions: gender: type: string id: - type: integer + example: "0" + type: string is_admin: type: boolean location: @@ -4810,7 +4811,7 @@ paths: post: consumes: - application/json - description: 使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。 + description: 使用用户名和密码登录系统,验证通过后建立 Session 并返回用户信息。 parameters: - description: 登录请求参数 in: body @@ -4829,11 +4830,15 @@ paths: description: 用户名或密码错误 schema: $ref: '#/definitions/response.Any' + "429": + description: 登录尝试过于频繁 + schema: + $ref: '#/definitions/response.Any' "500": description: 服务内部错误 schema: $ref: '#/definitions/response.Any' - summary: 用户密码登录 + summary: 用户登录 tags: - user /api/v1/user/logout: @@ -4913,6 +4918,10 @@ paths: description: 参数错误、用户名已存在或注册已关闭 schema: $ref: '#/definitions/response.Any' + "429": + description: 注册尝试过于频繁 + schema: + $ref: '#/definitions/response.Any' "500": description: 服务内部错误 schema: diff --git a/backend/go.mod b/backend/go.mod index 5e85a362..3074113e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,14 +1,14 @@ module Wavelet -go 1.25.7 +go 1.25.10 require ( github.com/ClickHouse/clickhouse-go/v2 v2.48.0 github.com/alicebob/miniredis/v2 v2.38.0 github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.3 - github.com/aws/aws-sdk-go-v2 v1.43.4 - github.com/aws/aws-sdk-go-v2/config v1.32.35 - github.com/aws/aws-sdk-go-v2/credentials v1.19.34 + github.com/aws/aws-sdk-go-v2 v1.45.1 + github.com/aws/aws-sdk-go-v2/config v1.33.1 + github.com/aws/aws-sdk-go-v2/credentials v1.20.1 github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5 github.com/bwmarrin/snowflake v0.3.0 github.com/coreos/go-oidc/v3 v3.20.0 @@ -29,7 +29,7 @@ require ( github.com/shopspring/decimal v1.4.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.1 github.com/studio-b12/gowebdav v0.13.0 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 @@ -44,7 +44,7 @@ require ( go.opentelemetry.io/otel/sdk v1.45.0 go.opentelemetry.io/otel/trace v1.45.0 go.uber.org/zap v1.28.0 - golang.org/x/crypto v0.54.0 + golang.org/x/crypto v0.55.0 golang.org/x/image v0.44.0 golang.org/x/mod v0.38.0 golang.org/x/oauth2 v0.36.0 @@ -64,19 +64,19 @@ require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/andybalholm/brotli v1.2.2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect - github.com/aws/smithy-go v1.27.6 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect + github.com/aws/smithy-go v1.28.1 // indirect github.com/boj/redistore v1.4.1 // indirect github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.2 // indirect @@ -109,6 +109,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/go-redis/redis_rate/v10 v10.0.1 // indirect github.com/go-resty/resty/v2 v2.6.0 // indirect github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect @@ -136,6 +137,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/nikoksr/notify v1.6.0 // indirect github.com/paulmach/orb v0.13.0 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/pierrec/lz4/v4 v4.1.27 // indirect @@ -151,9 +153,9 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/tidwall/gjson v1.9.3 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 // indirect @@ -166,17 +168,17 @@ require ( go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/arch v0.29.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect - golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect - google.golang.org/grpc v1.83.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/mysql v1.6.0 // indirect modernc.org/libc v1.74.3 // indirect diff --git a/backend/go.sum b/backend/go.sum index 5c8473c9..2c3a10df 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -86,40 +86,68 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE= github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= +github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks= +github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 h1:aiuaKlDweRC5qExJondpWjOgyzMHpofpwspGXUtwn4c= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16/go.mod h1:nG/LOlmox9BDe9HvQnXWzgcK8uKbgBMZ/Hp5pVt/21I= github.com/aws/aws-sdk-go-v2/config v1.32.35 h1:UEzXuET8E42lxBPijuACu/tEK7v5lFPlk0Q+GT5WD9E= github.com/aws/aws-sdk-go-v2/config v1.32.35/go.mod h1:KaMtJpFa2JlL2BStjjHQVwQpzZEmw+ND/EgVrfFoo2g= +github.com/aws/aws-sdk-go-v2/config v1.33.1 h1:bq9jze1hQ5YTCLoVxNnbp0T7rglrlOE7N9YsHqjGkEw= +github.com/aws/aws-sdk-go-v2/config v1.33.1/go.mod h1:2A3HQwG4zaL5Tm80rc6RZj8LmWWv4WYT5v8raSz/L7A= github.com/aws/aws-sdk-go-v2/credentials v1.19.34 h1:y6GkSmcv5myd1ngrYbGmiLlwQqB6TQhOuN/tbSSuWDY= github.com/aws/aws-sdk-go-v2/credentials v1.19.34/go.mod h1:w3dTcnDVoQIewjo7JG45hduAToikiIFLC4FIO7fndvw= +github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ= +github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 h1:+S7kbJoLDDQ5tE+lHrUBgMkzC8NLgsaioS2F3dVoFAE= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35/go.mod h1:Ak7xXviIARfFdNUJ9Etb0bdVDt/KAvKjMGJVLWXDzik= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3YM0rSmk4opFXKqg/k= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 h1:Q1TF1J9jVD+vFo0LzNnmNdQ9EAt52TS+MQlq9Ir+Yxo= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28/go.mod h1:4KqXXC/p1hrotmouDFbrRoWaLy962b9PMUReCG6+uWo= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 h1:EUIwBoN+q7UmhAejxgD27APiRjh1vwCFo53gSqdT0BM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36/go.mod h1:6u00gmlTGR6W0b2k9NBrld7MnOEmf1Spqx0VVt6AqyE= github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5 h1:HpN6GgZ3T8pSvRp81ZsgumNjlvRsa+9M0ZL2o6W4uLY= github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5/go.mod h1:5FTZoQxhmLEiCAtYVk6V+t0iS/B5yGZVLZ3Wq5FDJZI= github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 h1:cOJELVNrq5Q3Udry2GLuHUM7MhwpeaQRdYaoa6GI/yI= github.com/aws/aws-sdk-go-v2/service/signin v1.5.4/go.mod h1:f4LxzKBtaTxD7xh3PiVg3CE1tchQemfmghaJr+NbK2c= +github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE= +github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI= github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 h1:AMW7a7S8iQaHjBYZdU3PCq4GKRPijTPRAc7e6XtEThY= github.com/aws/aws-sdk-go-v2/service/sso v1.33.4/go.mod h1:QQNsFV1DVXoXcZt18FS8lI8rtUrlDyAuWZLQ5shunv4= +github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM= +github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 h1:AsbZcJAQPRmHDJG8K1N0pof/1zPWjVT8TFlTWuGLSvo= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4/go.mod h1:6imqztH0//t0mKbl6yWl7swSEl7F/w32oAmqB3vP1ag= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c= github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTwWJNTv3IMex30Y+4= github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc= +github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0= +github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk= github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -274,6 +302,8 @@ github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= +github.com/go-redis/redis_rate/v10 v10.0.1 h1:calPxi7tVlxojKunJwQ72kwfozdy25RjA0bCj1h0MUo= +github.com/go-redis/redis_rate/v10 v10.0.1/go.mod h1:EMiuO9+cjRkR7UvdvwMO7vbgqJkltQHtwbdIQvaBKIU= github.com/go-resty/resty/v2 v2.6.0 h1:joIR5PNLM2EFqqESUjCMGXrWmXNHEU9CEiK813oKYS4= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= @@ -524,6 +554,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nikoksr/notify v1.6.0 h1:H9pyvNyo4/47vn7uPTBbb+ld6Y/KmpXo9Ghf8cGqAlU= +github.com/nikoksr/notify v1.6.0/go.mod h1:GBrx8S2GI0ZtXdobxNmXJjPJN4P6qXbjPW78zlLSg6s= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -647,6 +679,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ= github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= @@ -662,10 +696,16 @@ github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI= github.com/tidwall/gjson v1.9.3 h1:hqzS9wAHMO+KVBBkLxYdkEeeFHuqr95GfClRLKlgK0E= github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= @@ -746,6 +786,8 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho= golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -762,6 +804,8 @@ golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -860,6 +904,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1007,11 +1053,15 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -1072,6 +1122,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1208,6 +1260,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1: google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1240,6 +1294,8 @@ google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACu google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -1257,6 +1313,8 @@ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/pkg/limiter/memory.go b/backend/pkg/limiter/memory.go new file mode 100644 index 00000000..425a282b --- /dev/null +++ b/backend/pkg/limiter/memory.go @@ -0,0 +1,119 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package limiter provides in-memory rate limiting utilities. +package limiter + +import ( + "Wavelet/core/contracts" + "context" + "sync" + "time" +) + +type memoryEntry struct { + timestamps []time.Time + lastSeen time.Time +} + +func (e *memoryEntry) prune(cutoff time.Time) { + validIdx := len(e.timestamps) + for i, ts := range e.timestamps { + if ts.After(cutoff) { + validIdx = i + break + } + } + if validIdx > 0 && validIdx <= len(e.timestamps) { + e.timestamps = e.timestamps[validIdx:] + } +} + +func (e *memoryEntry) calcBlockedResult(limit int, period time.Duration, now time.Time) *contracts.RateLimitResult { + currentCount := len(e.timestamps) + if currentCount == 0 { + return &contracts.RateLimitResult{ + Allowed: false, + Remaining: limit, + ResetAfter: period, + RetryAfter: 0, + } + } + + oldest := e.timestamps[0] + retryAfter := max(0, oldest.Add(period).Sub(now)) + + newest := e.timestamps[currentCount-1] + resetAfter := max(0, newest.Add(period).Sub(now)) + + return &contracts.RateLimitResult{ + Allowed: false, + Remaining: limit - currentCount, + ResetAfter: resetAfter, + RetryAfter: retryAfter, + } +} + +// MemoryLimiter implements contracts.LimiterService using an in-memory sliding window algorithm. +type MemoryLimiter struct { + mu sync.Mutex + entries map[string]*memoryEntry +} + +// NewMemoryLimiter creates a new in-memory rate limiter. +func NewMemoryLimiter() *MemoryLimiter { + return &MemoryLimiter{ + entries: make(map[string]*memoryEntry), + } +} + +// Allow checks whether 1 event for key is permitted under rate. +func (m *MemoryLimiter) Allow(ctx context.Context, key string, rate contracts.Rate) (*contracts.RateLimitResult, error) { + return m.AllowN(ctx, key, rate, 1) +} + +// AllowN checks whether n events for key are permitted under rate. +func (m *MemoryLimiter) AllowN(_ context.Context, key string, rate contracts.Rate, n int) (*contracts.RateLimitResult, error) { + if rate.Limit <= 0 || rate.Period <= 0 || n <= 0 { + return &contracts.RateLimitResult{Allowed: true}, nil + } + + m.mu.Lock() + defer m.mu.Unlock() + + now := time.Now() + cutoff := now.Add(-rate.Period) + + entry, ok := m.entries[key] + if !ok { + entry = &memoryEntry{} + m.entries[key] = entry + } + entry.lastSeen = now + entry.prune(cutoff) + + if len(entry.timestamps)+n > rate.Limit { + return entry.calcBlockedResult(rate.Limit, rate.Period, now), nil + } + + for i := 0; i < n; i++ { + entry.timestamps = append(entry.timestamps, now) + } + + remaining := max(0, rate.Limit-len(entry.timestamps)) + + return &contracts.RateLimitResult{ + Allowed: true, + Remaining: remaining, + ResetAfter: rate.Period, + RetryAfter: 0, + }, nil +} + +// Reset clears rate limit state for key. +func (m *MemoryLimiter) Reset(_ context.Context, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.entries, key) + return nil +} diff --git a/backend/pkg/limiter/memory_test.go b/backend/pkg/limiter/memory_test.go new file mode 100644 index 00000000..c7220fcb --- /dev/null +++ b/backend/pkg/limiter/memory_test.go @@ -0,0 +1,119 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package limiter + +import ( + "Wavelet/core/contracts" + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMemoryLimiter_Basic(t *testing.T) { + ctx := context.Background() + lim := NewMemoryLimiter() + + rate := contracts.Rate{ + Limit: 3, + Period: 100 * time.Millisecond, + } + + // 1st request + res, err := lim.Allow(ctx, "test_key", rate) + require.NoError(t, err) + assert.True(t, res.Allowed) + assert.Equal(t, 2, res.Remaining) + + // 2nd request + res, err = lim.Allow(ctx, "test_key", rate) + require.NoError(t, err) + assert.True(t, res.Allowed) + assert.Equal(t, 1, res.Remaining) + + // 3rd request + res, err = lim.Allow(ctx, "test_key", rate) + require.NoError(t, err) + assert.True(t, res.Allowed) + assert.Equal(t, 0, res.Remaining) + + // 4th request - should be blocked + res, err = lim.Allow(ctx, "test_key", rate) + require.NoError(t, err) + assert.False(t, res.Allowed) + assert.Equal(t, 0, res.Remaining) + assert.Greater(t, res.RetryAfter, time.Duration(0)) + + // Reset + err = lim.Reset(ctx, "test_key") + require.NoError(t, err) + + // Immediately allowed after reset + res, err = lim.Allow(ctx, "test_key", rate) + require.NoError(t, err) + assert.True(t, res.Allowed) + assert.Equal(t, 2, res.Remaining) +} + +func TestMemoryLimiter_WindowSlide(t *testing.T) { + ctx := context.Background() + lim := NewMemoryLimiter() + + rate := contracts.Rate{ + Limit: 2, + Period: 50 * time.Millisecond, + } + + res, err := lim.Allow(ctx, "slide_key", rate) + require.NoError(t, err) + assert.True(t, res.Allowed) + + res, err = lim.Allow(ctx, "slide_key", rate) + require.NoError(t, err) + assert.True(t, res.Allowed) + + res, err = lim.Allow(ctx, "slide_key", rate) + require.NoError(t, err) + assert.False(t, res.Allowed) + + // Wait for window to slide + time.Sleep(60 * time.Millisecond) + + res, err = lim.Allow(ctx, "slide_key", rate) + require.NoError(t, err) + assert.True(t, res.Allowed) +} + +func TestMemoryLimiter_Concurrency(t *testing.T) { + ctx := context.Background() + lim := NewMemoryLimiter() + + rate := contracts.Rate{ + Limit: 100, + Period: time.Second, + } + + var wg sync.WaitGroup + allowedCount := int32(0) + var mu sync.Mutex + + for i := 0; i < 200; i++ { + wg.Add(1) + go func() { + defer wg.Done() + res, err := lim.Allow(ctx, "concurrent_key", rate) + if err == nil && res.Allowed { + mu.Lock() + allowedCount++ + mu.Unlock() + } + }() + } + + wg.Wait() + assert.Equal(t, int32(100), allowedCount) +} diff --git a/backend/plugins/domain/auth/handlers.go b/backend/plugins/domain/auth/handlers.go index d96fe321..8f30cf67 100644 --- a/backend/plugins/domain/auth/handlers.go +++ b/backend/plugins/domain/auth/handlers.go @@ -125,6 +125,21 @@ func reserveOAuthStateSlot(ctx context.Context, sessionHash string) error { if sessionHash == "" { return nil } + if limiter := getLimiter(ctx); limiter != nil { + key := fmt.Sprintf(oauthStateLimitKeyFormat, sessionHash) + res, err := limiter.Allow(ctx, key, contracts.Rate{ + Limit: oauthStateLimitMax, + Period: OAuthStateCacheKeyExpiration, + }) + if err != nil { + return err + } + if !res.Allowed { + return errors.New(errOAuthStateRateLimited) + } + return nil + } + cache := getCache(ctx) if cache == nil { return nil diff --git a/backend/plugins/domain/auth/oauth_rate_limit_test.go b/backend/plugins/domain/auth/oauth_rate_limit_test.go new file mode 100644 index 00000000..93265907 --- /dev/null +++ b/backend/plugins/domain/auth/oauth_rate_limit_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/limiter" + "Wavelet/pkg/response" + "Wavelet/plugins/domain/auth" + database "Wavelet/plugins/infra/database" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOAuthRateLimiting(t *testing.T) { + gin.SetMode(gin.TestMode) + + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + + testDB := setupTestDB(t) + require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx)) + + // Provide in-memory limiter service + memLimiter := limiter.NewMemoryLimiter() + core.Provide[contracts.LimiterService](ctx, memLimiter) + + require.NoError(t, auth.New().Apply(ctx)) + + // Create an active OIDC source + authSrc := auth.AuthSource{ + ID: 1, + Name: "google", + Type: "oidc", + DisplayName: "Google", + ClientID: "client-id-123", + ClientSecret: "client-secret-456", + OpenIDDiscoveryURL: "https://accounts.google.com", + IsActive: true, + } + require.NoError(t, testDB.Create(&authSrc).Error) + + router := gin.New() + router.Use(response.ErrorHandlerMiddleware()) + store := cookie.NewStore([]byte("test-session-secret-123")) + router.Use(sessions.Sessions("wavelet_session_id", store)) + router.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(core.WithAppContext(c.Request.Context(), ctx.Root())) + c.Next() + }) + + for _, rd := range ctx.Router().Routes() { + handlers := make([]gin.HandlerFunc, 0, len(rd.Middlewares)+len(rd.Handlers)) + for _, m := range rd.Middlewares { + if h, ok := m.(gin.HandlerFunc); ok { + handlers = append(handlers, h) + } else if fn, ok := m.(func(*gin.Context)); ok { + handlers = append(handlers, fn) + } + } + for _, raw := range rd.Handlers { + if h, ok := raw.(gin.HandlerFunc); ok { + handlers = append(handlers, h) + } else if fn, ok := raw.(func(*gin.Context)); ok { + handlers = append(handlers, fn) + } + } + router.Handle(rd.Method, rd.Path, handlers...) + } + + // 10 state slots are allowed per session (oauthStateLimitMax = 10) + // We'll simulate 10 requests with the same cookie + var cookies []*http.Cookie + for i := 1; i <= 10; i++ { + req := httptest.NewRequest(http.MethodGet, "/api/v1/oauth/login?source=google", nil) + for _, ck := range cookies { + req.AddCookie(ck) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if len(w.Result().Cookies()) > 0 { + cookies = w.Result().Cookies() + } + } + + // 11th request for the same session should be rate limited + { + req := httptest.NewRequest(http.MethodGet, "/api/v1/oauth/login?source=google", nil) + for _, ck := range cookies { + req.AddCookie(ck) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + + var resp map[string]any + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Equal(t, "请求授权过于频繁,请稍后重试", resp["error_msg"]) + } +} diff --git a/backend/plugins/domain/auth/plugin.go b/backend/plugins/domain/auth/plugin.go index fac1c7a1..95e74c1e 100644 --- a/backend/plugins/domain/auth/plugin.go +++ b/backend/plugins/domain/auth/plugin.go @@ -89,9 +89,11 @@ func (p *Plugin) Apply(ctx *core.Context) error { core.Bind[contracts.DBService](ctx, setDBService) core.Bind[contracts.CacheService](ctx, setCacheService) + core.Bind[contracts.LimiterService](ctx, setLimiterService) ctx.OnDispose(func() error { setDBService(nil) setCacheService(nil) + setLimiterService(nil) return nil }) diff --git a/backend/plugins/domain/auth/plugin_test.go b/backend/plugins/domain/auth/plugin_test.go index a4f81cd2..dcf09f36 100644 --- a/backend/plugins/domain/auth/plugin_test.go +++ b/backend/plugins/domain/auth/plugin_test.go @@ -62,6 +62,13 @@ func hashToken(token string) string { return hex.EncodeToString(h.Sum(nil)) } +type testSystemConfig struct { + Key string `gorm:"primaryKey"` + Value string +} + +func (testSystemConfig) TableName() string { return "w_system_configs" } + func setupTestDB(t *testing.T) *gorm.DB { t.Helper() dbPath := filepath.Join(t.TempDir(), "auth_test.db") @@ -73,6 +80,7 @@ func setupTestDB(t *testing.T) *gorm.DB { &testAccessToken{}, &auth.AuthSource{}, &auth.ExternalAccount{}, + &testSystemConfig{}, )) return testDB diff --git a/backend/plugins/domain/auth/repository.go b/backend/plugins/domain/auth/repository.go index 5f1adf00..1310085f 100644 --- a/backend/plugins/domain/auth/repository.go +++ b/backend/plugins/domain/auth/repository.go @@ -15,10 +15,12 @@ import ( ) var ( - dbMu sync.RWMutex - dbSvc contracts.DBService - cacheMu sync.RWMutex - cacheSvc contracts.CacheService + dbMu sync.RWMutex + dbSvc contracts.DBService + cacheMu sync.RWMutex + cacheSvc contracts.CacheService + limiterMu sync.RWMutex + limiterSvc contracts.LimiterService ) func setDBService(s contracts.DBService) { @@ -33,6 +35,12 @@ func setCacheService(s contracts.CacheService) { cacheSvc = s } +func setLimiterService(s contracts.LimiterService) { + limiterMu.Lock() + defer limiterMu.Unlock() + limiterSvc = s +} + func getDB(ctx context.Context) *gorm.DB { if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil { return s.DB(ctx) @@ -56,6 +64,16 @@ func getCache(ctx context.Context) contracts.CacheService { return s } +func getLimiter(ctx context.Context) contracts.LimiterService { + if s, err := core.InjectFrom[contracts.LimiterService](ctx); err == nil && s != nil { + return s + } + limiterMu.RLock() + s := limiterSvc + limiterMu.RUnlock() + return s +} + // GetAccessTokenByHash 按令牌哈希读取访问令牌记录(仅取鉴权所需字段) func GetAccessTokenByHash(ctx context.Context, tokenHash string) (*CachedToken, error) { var row struct { diff --git a/backend/plugins/domain/user/errs.go b/backend/plugins/domain/user/errs.go index 9f132948..75c5073c 100644 --- a/backend/plugins/domain/user/errs.go +++ b/backend/plugins/domain/user/errs.go @@ -8,7 +8,9 @@ const ( errInvalidParams = "无效的请求参数" errUserNotFound = "用户不存在" //nolint:gosec // error message, not hardcoded credentials - errPasswordMismatch = "用户名或密码错误" + errPasswordMismatch = "用户名或密码错误" + errTooManyLoginAttempts = "登录尝试过于频繁,请稍后重试" + //nolint:gosec // error message, not hardcoded credentials //nolint:gosec // error message, not hardcoded credentials errOldPasswordIncorrect = "原密码不正确" //nolint:gosec // error message, not hardcoded credentials diff --git a/backend/plugins/domain/user/handlers.go b/backend/plugins/domain/user/handlers.go index 7a602d66..21512306 100644 --- a/backend/plugins/domain/user/handlers.go +++ b/backend/plugins/domain/user/handlers.go @@ -8,6 +8,7 @@ import ( "Wavelet/pkg/idgen" "Wavelet/pkg/logger" "Wavelet/pkg/response" + "Wavelet/pkg/util" "context" "crypto/rand" "crypto/sha256" @@ -16,6 +17,7 @@ import ( "net/http" "strconv" "sync" + "time" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -79,15 +81,16 @@ func invalidateTokenCache(ctx context.Context, tokenHash string) { } } -// Login 用户密码登录 -// @Summary 用户密码登录 -// @Description 使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。 +// Login 用户登录 +// @Summary 用户登录 +// @Description 使用用户名和密码登录系统,验证通过后建立 Session 并返回用户信息。 // @Tags user // @Accept json // @Produce json // @Param request body user.loginRequest true "登录请求参数" // @Success 200 {object} response.Any "登录成功,返回用户信息" // @Failure 400 {object} response.Any "用户名或密码错误" +// @Failure 429 {object} response.Any "登录尝试过于频繁" // @Failure 500 {object} response.Any "服务内部错误" // @Router /api/v1/user/login [post] func Login(c *gin.Context) { @@ -97,8 +100,28 @@ func Login(c *gin.Context) { return } - user, err := GetUserByUsername(c.Request.Context(), req.Username) + ctx := c.Request.Context() + clientIP := c.ClientIP() + rateKey := "auth:login:ip:" + clientIP + if clientIP == "" { + rateKey = "auth:login:user:" + req.Username + } + + limiter := getLimiter(ctx) + if limiter != nil { + res, err := limiter.Allow(ctx, rateKey, contracts.Rate{ + Limit: 5, + Period: 1 * time.Minute, + }) + if err == nil && !res.Allowed { + response.AbortTooManyRequests(c, errTooManyLoginAttempts) + return + } + } + + user, err := GetUserByUsername(ctx, req.Username) if err != nil { + util.DummyCheckPassword(req.Password) response.AbortUnauthorized(c, errPasswordMismatch) return } @@ -108,9 +131,13 @@ func Login(c *gin.Context) { return } + if limiter != nil { + _ = limiter.Reset(ctx, rateKey) + } + if user.ID == 0 { newID := idgen.NextUint64ID() - if err := getDB(c.Request.Context()).Model(&User{}).Where("username = ?", user.Username).Update("id", newID).Error; err == nil { + if err := getDB(ctx).Model(&User{}).Where("username = ?", user.Username).Update("id", newID).Error; err == nil { user.ID = newID } } @@ -122,7 +149,7 @@ func Login(c *gin.Context) { user.NeedChangePassword = needChange sess.Set("need_change_password", needChange) if err := sess.Save(); err != nil { - logger.ErrorF(c.Request.Context(), "save session failed on login: %v", err) + logger.ErrorF(ctx, "save session failed on login: %v", err) } c.JSON(http.StatusOK, response.OK(user)) @@ -137,6 +164,7 @@ func Login(c *gin.Context) { // @Param request body user.registerRequest true "注册请求参数" // @Success 200 {object} response.Any "注册并登录成功,返回用户信息" // @Failure 400 {object} response.Any "参数错误、用户名已存在或注册已关闭" +// @Failure 429 {object} response.Any "注册尝试过于频繁" // @Failure 500 {object} response.Any "服务内部错误" // @Router /api/v1/user/register [post] func Register(c *gin.Context) { @@ -146,6 +174,19 @@ func Register(c *gin.Context) { return } + ctx := c.Request.Context() + clientIP := c.ClientIP() + if limiter := getLimiter(ctx); limiter != nil && clientIP != "" { + res, err := limiter.Allow(ctx, "auth:register:ip:"+clientIP, contracts.Rate{ + Limit: 10, + Period: 1 * time.Minute, + }) + if err == nil && !res.Allowed { + response.AbortTooManyRequests(c, errTooManyLoginAttempts) + return + } + } + newUser := &User{ Username: req.Username, Email: req.Email, diff --git a/backend/plugins/domain/user/login_rate_limit_test.go b/backend/plugins/domain/user/login_rate_limit_test.go new file mode 100644 index 00000000..966c8a51 --- /dev/null +++ b/backend/plugins/domain/user/login_rate_limit_test.go @@ -0,0 +1,122 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package user_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/limiter" + "Wavelet/pkg/response" + "Wavelet/plugins/domain/auth" + "Wavelet/plugins/domain/user" + database "Wavelet/plugins/infra/database" + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserLoginRateLimiting(t *testing.T) { + gin.SetMode(gin.TestMode) + + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + + testDB := setupTestDB(t) + require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx)) + + // Provide in-memory limiter service + memLimiter := limiter.NewMemoryLimiter() + core.Provide[contracts.LimiterService](ctx, memLimiter) + + require.NoError(t, auth.New().Apply(ctx)) + require.NoError(t, user.New().Apply(ctx)) + + // Create a test user + userSvc, err := core.Inject[contracts.UserService](ctx) + require.NoError(t, err) + createdUser, err := userSvc.CreateUser(context.Background(), contracts.CreateUserRequest{ + Username: "ratelimit_user", + Password: "CorrectPassword123!", + }) + require.NoError(t, err) + require.NotNil(t, createdUser) + + router := gin.New() + router.Use(response.ErrorHandlerMiddleware()) + store := cookie.NewStore([]byte("test-secret-key-session")) + router.Use(sessions.Sessions("wavelet_session", store)) + router.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(core.WithAppContext(c.Request.Context(), ctx.Root())) + c.Next() + }) + + for _, rd := range ctx.Router().Routes() { + handlers := make([]gin.HandlerFunc, 0, len(rd.Middlewares)+len(rd.Handlers)) + for _, m := range rd.Middlewares { + if h, ok := m.(gin.HandlerFunc); ok { + handlers = append(handlers, h) + } else if fn, ok := m.(func(*gin.Context)); ok { + handlers = append(handlers, fn) + } + } + for _, raw := range rd.Handlers { + if h, ok := raw.(gin.HandlerFunc); ok { + handlers = append(handlers, h) + } else if fn, ok := raw.(func(*gin.Context)); ok { + handlers = append(handlers, fn) + } + } + router.Handle(rd.Method, rd.Path, handlers...) + } + + loginBody, _ := json.Marshal(map[string]string{ + "username": "ratelimit_user", + "password": "WrongPassword!", + }) + + // Make 5 failed login attempts (Limit is 5) + for i := 1; i <= 5; i++ { + req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", bytes.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "192.168.1.100:12345" + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code, "attempt %d should be 401 Unauthorized", i) + } + + // 6th attempt from the same IP should be blocked with 429 Too Many Requests + { + req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", bytes.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "192.168.1.100:12345" + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusTooManyRequests, w.Code, "6th attempt should be 429 Too Many Requests") + + var resp map[string]any + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Equal(t, "登录尝试过于频繁,请稍后重试", resp["error_msg"]) + } + + // Another IP is not blocked + { + req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", bytes.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "192.168.1.101:12345" + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code, "different IP should receive 401, not 429") + } +} diff --git a/backend/plugins/domain/user/plugin.go b/backend/plugins/domain/user/plugin.go index 744e7b90..d66864ae 100644 --- a/backend/plugins/domain/user/plugin.go +++ b/backend/plugins/domain/user/plugin.go @@ -79,10 +79,12 @@ func (p *Plugin) Apply(ctx *core.Context) error { core.Bind[contracts.DBService](ctx, SetDBService) core.Bind[contracts.CacheService](ctx, SetCacheService) core.Bind[contracts.TaskService](ctx, SetTaskService) + core.Bind[contracts.LimiterService](ctx, SetLimiterService) ctx.OnDispose(func() error { SetDBService(nil) SetCacheService(nil) SetTaskService(nil) + SetLimiterService(nil) return nil }) diff --git a/backend/plugins/domain/user/repository.go b/backend/plugins/domain/user/repository.go index b77a4e0a..a40e68a3 100644 --- a/backend/plugins/domain/user/repository.go +++ b/backend/plugins/domain/user/repository.go @@ -18,8 +18,10 @@ import ( ) var ( - dbMu sync.RWMutex - dbSvc contracts.DBService + dbMu sync.RWMutex + dbSvc contracts.DBService + limiterMu sync.RWMutex + limiterSvc contracts.LimiterService ) // SetDBService sets the active DBService contract for the user domain plugin. @@ -29,6 +31,13 @@ func SetDBService(s contracts.DBService) { dbSvc = s } +// SetLimiterService sets the active LimiterService contract for the user domain plugin. +func SetLimiterService(s contracts.LimiterService) { + limiterMu.Lock() + defer limiterMu.Unlock() + limiterSvc = s +} + func getDB(ctx context.Context) *gorm.DB { if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil { return s.DB(ctx) @@ -44,6 +53,17 @@ func getDB(ctx context.Context) *gorm.DB { return nil } +func getLimiter(ctx context.Context) contracts.LimiterService { + if s, err := core.InjectFrom[contracts.LimiterService](ctx); err == nil && s != nil { + return s + } + + limiterMu.RLock() + s := limiterSvc + limiterMu.RUnlock() + return s +} + // GetUserByID 通过 ID 获取用户 func GetUserByID(ctx context.Context, id uint64) (*User, error) { var u User diff --git a/backend/plugins/infra/cache/limiter.go b/backend/plugins/infra/cache/limiter.go new file mode 100644 index 00000000..3a843e5c --- /dev/null +++ b/backend/plugins/infra/cache/limiter.go @@ -0,0 +1,59 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache + +import ( + "Wavelet/core/contracts" + "context" + + "github.com/go-redis/redis_rate/v10" + "github.com/redis/go-redis/v9" +) + +type redisLimiterImpl struct { + limiter *redis_rate.Limiter + keyPrefix string +} + +func newRedisLimiter(client redis.UniversalClient, keyPrefix string) contracts.LimiterService { + return &redisLimiterImpl{ + limiter: redis_rate.NewLimiter(client), + keyPrefix: keyPrefix, + } +} + +func (r *redisLimiterImpl) prefixedKey(key string) string { + if r.keyPrefix != "" { + return r.keyPrefix + "limiter:" + key + } + return PrefixedKey("limiter:" + key) +} + +func (r *redisLimiterImpl) Allow(ctx context.Context, key string, rate contracts.Rate) (*contracts.RateLimitResult, error) { + return r.AllowN(ctx, key, rate, 1) +} + +func (r *redisLimiterImpl) AllowN(ctx context.Context, key string, rate contracts.Rate, n int) (*contracts.RateLimitResult, error) { + limit := redis_rate.Limit{ + Rate: rate.Limit, + Period: rate.Period, + Burst: rate.Limit, + } + + res, err := r.limiter.AllowN(ctx, r.prefixedKey(key), limit, n) + if err != nil { + return nil, err + } + + return &contracts.RateLimitResult{ + Allowed: res.Allowed > 0, + Remaining: res.Remaining, + ResetAfter: res.ResetAfter, + RetryAfter: res.RetryAfter, + }, nil +} + +func (r *redisLimiterImpl) Reset(ctx context.Context, key string) error { + return r.limiter.Reset(ctx, r.prefixedKey(key)) +} diff --git a/backend/plugins/infra/cache/limiter_test.go b/backend/plugins/infra/cache/limiter_test.go new file mode 100644 index 00000000..26c30aad --- /dev/null +++ b/backend/plugins/infra/cache/limiter_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/infra/cache" + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRedisLimiterService(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + + rdb := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + }) + defer func() { _ = rdb.Close() }() + + p := cache.New( + cache.WithRedis(rdb), + cache.WithKeyPrefix("test:"), + ) + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(map[string]any{ + "redis.enabled": true, + })) + require.NoError(t, ctx.Config().Resolve()) + require.NoError(t, p.Apply(ctx)) + + limiter, err := core.Inject[contracts.LimiterService](ctx) + require.NoError(t, err) + require.NotNil(t, limiter) + + testCtx := context.Background() + rate := contracts.Rate{ + Limit: 3, + Period: time.Minute, + } + + // 1st request + res, err := limiter.Allow(testCtx, "user:123", rate) + require.NoError(t, err) + assert.True(t, res.Allowed) + assert.Equal(t, 2, res.Remaining) + + // 2nd request + res, err = limiter.Allow(testCtx, "user:123", rate) + require.NoError(t, err) + assert.True(t, res.Allowed) + assert.Equal(t, 1, res.Remaining) + + // 3rd request + res, err = limiter.Allow(testCtx, "user:123", rate) + require.NoError(t, err) + assert.True(t, res.Allowed) + assert.Equal(t, 0, res.Remaining) + + // 4th request - blocked + res, err = limiter.Allow(testCtx, "user:123", rate) + require.NoError(t, err) + assert.False(t, res.Allowed) + assert.Equal(t, 0, res.Remaining) + assert.Greater(t, res.RetryAfter, time.Duration(0)) + + // Reset + err = limiter.Reset(testCtx, "user:123") + require.NoError(t, err) + + // Allowed again after reset + res, err = limiter.Allow(testCtx, "user:123", rate) + require.NoError(t, err) + assert.True(t, res.Allowed) +} diff --git a/backend/plugins/infra/cache/plugin.go b/backend/plugins/infra/cache/plugin.go index 0155da2d..d5717688 100644 --- a/backend/plugins/infra/cache/plugin.go +++ b/backend/plugins/infra/cache/plugin.go @@ -8,6 +8,7 @@ import ( "Wavelet/core" "Wavelet/core/contracts" "Wavelet/pkg/cache/ram" + "Wavelet/pkg/limiter" "Wavelet/pkg/util" "context" "encoding/json" @@ -139,6 +140,15 @@ func (p *Plugin) Apply(ctx *core.Context) error { } core.Provide[contracts.CacheService](ctx, svc) + + var limiterSvc contracts.LimiterService + if redisClient != nil { + limiterSvc = newRedisLimiter(redisClient, p.keyPrefix) + } else { + limiterSvc = limiter.NewMemoryLimiter() + } + core.Provide[contracts.LimiterService](ctx, limiterSvc) + return nil } diff --git a/backend/plugins/infra/cache_memory/plugin.go b/backend/plugins/infra/cache_memory/plugin.go index 74c815ef..660ba67b 100644 --- a/backend/plugins/infra/cache_memory/plugin.go +++ b/backend/plugins/infra/cache_memory/plugin.go @@ -7,6 +7,7 @@ package cache_memory import ( "Wavelet/core" "Wavelet/core/contracts" + "Wavelet/pkg/limiter" ) const defaultRAMCapacity = 10000 @@ -79,5 +80,6 @@ func (p *Plugin) Apply(ctx *core.Context) error { } core.Provide[contracts.CacheService](ctx, svc) + core.Provide[contracts.LimiterService](ctx, limiter.NewMemoryLimiter()) return nil } diff --git a/backend/plugins/infra/cache_memory/plugin_test.go b/backend/plugins/infra/cache_memory/plugin_test.go index 66c5fdba..71cdc283 100644 --- a/backend/plugins/infra/cache_memory/plugin_test.go +++ b/backend/plugins/infra/cache_memory/plugin_test.go @@ -78,4 +78,14 @@ func TestCacheMemoryPlugin(t *testing.T) { var tempVal string err = cacheSvc.Get(reqCtx, "temp_key", &tempVal) assert.ErrorIs(t, err, contracts.ErrCacheMiss) + + // 6. LimiterService + limiterSvc, err := core.Inject[contracts.LimiterService](ctx) + require.NoError(t, err) + require.NotNil(t, limiterSvc) + + rateRes, err := limiterSvc.Allow(reqCtx, "key_a", contracts.Rate{Limit: 2, Period: time.Minute}) + require.NoError(t, err) + assert.True(t, rateRes.Allowed) + assert.Equal(t, 1, rateRes.Remaining) }