feat(auth): refresh token 续期(B2) — 不透明哈希+单次旋转+/auth/refresh+/auth/logout
- auth/refresh.go: RefreshStore(Issue/Rotate/Revoke/RevokeAll/RevokeByUser), SHA-256哈希256-bit随机串, 单次旋转事务(并发重放fail-closed) - 迁移0008_refresh_tokens(id/user_id/role/token_hash UNIQUE/expires_at/revoked_at + 索引) - gateway: POST /v1/auth/refresh(旋转/续期) + /v1/auth/logout(单/全设备); login响应增refreshToken - config: AUTH_REFRESH_TTL(720h)/AUTH_REFRESH_ROTATE_DISABLED; decode.go 支持time.Duration - rotate-jwt成功后RevokeAll(响应refreshTokensRevoked), revoke-keys不动refresh - 14测试+真实二进制e2e, build/vet/test绿+交叉编译 - Docs/api-client v0.25 + api-reference §2.3/2.4 同步
This commit is contained in:
1 parent
7ecb43bd5d
commit
026b27b310
17 files changed
+1237
-13
No files matched your search
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config is the root configuration tree.
|
||||
@@ -48,6 +49,19 @@ type AuthConfig struct {
|
||||
AdminUser string `env:"ADMIN_USERNAME" default:"admin"`
|
||||
AdminPass string `env:"ADMIN_PASSWORD" default:"admin12345"`
|
||||
RegistrationEnabled bool `env:"REGISTRATION_ENABLED" default:"true"`
|
||||
|
||||
// RefreshTTL is how long an issued opaque refresh token stays valid. It is a
|
||||
// Go duration string (e.g. "720h" = 30 days). Access tokens keep their 24h
|
||||
// lifetime; the refresh token is the long-lived credential that renews them.
|
||||
RefreshTTL time.Duration `env:"AUTH_REFRESH_TTL" default:"720h"`
|
||||
|
||||
// RefreshRotateDisabled opts out of single-use refresh rotation. Rotation is
|
||||
// on by default (the safe default): a successful /auth/refresh revokes the
|
||||
// presented token and returns a fresh one, so a replayed token fails. Set
|
||||
// AUTH_REFRESH_ROTATE_DISABLED=true only for clients that cannot persist a
|
||||
// rotated token. The field is negative-named so its zero value (an
|
||||
// unset/undecoded config) means rotation is enabled.
|
||||
RefreshRotateDisabled bool `env:"AUTH_REFRESH_ROTATE_DISABLED" default:"false"`
|
||||
}
|
||||
|
||||
// DBConfig holds database connection settings. SQLite is the default; PGURL is
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writeEnvFile(t *testing.T, content string) string {
|
||||
@@ -130,6 +131,38 @@ func TestLoad_MissingDotEnvIsTolerated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_AuthRefreshDefaults(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if cfg.Auth.RefreshTTL != 720*time.Hour {
|
||||
t.Errorf("Auth.RefreshTTL = %v, want 720h", cfg.Auth.RefreshTTL)
|
||||
}
|
||||
if cfg.Auth.RefreshRotateDisabled {
|
||||
t.Errorf("Auth.RefreshRotateDisabled = true, want false (rotation on by default)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_AuthRefreshEnvOverrides(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||
t.Setenv("AUTH_REFRESH_TTL", "30m")
|
||||
t.Setenv("AUTH_REFRESH_ROTATE_DISABLED", "true")
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if cfg.Auth.RefreshTTL != 30*time.Minute {
|
||||
t.Errorf("Auth.RefreshTTL = %v, want 30m", cfg.Auth.RefreshTTL)
|
||||
}
|
||||
if !cfg.Auth.RefreshRotateDisabled {
|
||||
t.Errorf("Auth.RefreshRotateDisabled = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_CapacityDefaults(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||
|
||||
@@ -144,7 +177,6 @@ func TestLoad_CapacityDefaults(t *testing.T) {
|
||||
t.Errorf("Capacity.MinConfidence = %v, want 0.6", cfg.Capacity.MinConfidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_CapacityEnvOverrides(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||
t.Setenv("CAPACITY_ENABLED", "true")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// decodeValue populates the struct pointed to by v. Precedence, highest first:
|
||||
@@ -73,6 +74,11 @@ func setField(fv reflect.Value, raw string) error {
|
||||
case reflect.Bool:
|
||||
return setBool(fv, raw)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
// time.Duration is an int64 kind but is configured as a Go duration
|
||||
// string (e.g. "720h"), so it must be parsed before the integer path.
|
||||
if fv.Type() == reflect.TypeOf(time.Duration(0)) {
|
||||
return setDuration(fv, raw)
|
||||
}
|
||||
return setInt(fv, raw)
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return setFloat(fv, raw)
|
||||
@@ -122,6 +128,21 @@ func setFloat(fv reflect.Value, raw string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setDuration parses a Go duration string (e.g. "720h", "30m") into a
|
||||
// time.Duration field. An empty value sets the zero duration.
|
||||
func setDuration(fv reflect.Value, raw string) error {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
fv.SetInt(0)
|
||||
return nil
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration %q", raw)
|
||||
}
|
||||
fv.SetInt(int64(d))
|
||||
return nil
|
||||
}
|
||||
|
||||
func setStringSlice(fv reflect.Value, raw string) error {
|
||||
if fv.Type().Elem().Kind() != reflect.String {
|
||||
return fmt.Errorf("unsupported slice element kind %s", fv.Type().Elem().Kind())
|
||||
|
||||
Reference in new issue
Block a user