mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-18 23:25:10 +08:00
feat: Implement admin settings UI, enhance admin authentication with password hashing, and add new streaming runtime logic for Claude and OpenAI adapters with extensive compatibility tests.
This commit is contained in:
@@ -3,7 +3,9 @@ package auth
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
@@ -17,7 +19,22 @@ import (
|
||||
|
||||
var warnOnce sync.Once
|
||||
|
||||
type AdminConfigReader interface {
|
||||
AdminPasswordHash() string
|
||||
AdminJWTExpireHours() int
|
||||
AdminJWTValidAfterUnix() int64
|
||||
}
|
||||
|
||||
func AdminKey() string {
|
||||
return effectiveAdminKey(nil)
|
||||
}
|
||||
|
||||
func effectiveAdminKey(store AdminConfigReader) string {
|
||||
if store != nil {
|
||||
if hash := strings.TrimSpace(store.AdminPasswordHash()); hash != "" {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("DS2API_ADMIN_KEY")); v != "" {
|
||||
return v
|
||||
}
|
||||
@@ -27,14 +44,24 @@ func AdminKey() string {
|
||||
return "admin"
|
||||
}
|
||||
|
||||
func jwtSecret() string {
|
||||
func jwtSecret(store AdminConfigReader) string {
|
||||
if v := strings.TrimSpace(os.Getenv("DS2API_JWT_SECRET")); v != "" {
|
||||
return v
|
||||
}
|
||||
return AdminKey()
|
||||
if store != nil {
|
||||
if hash := strings.TrimSpace(store.AdminPasswordHash()); hash != "" {
|
||||
return hash
|
||||
}
|
||||
}
|
||||
return effectiveAdminKey(store)
|
||||
}
|
||||
|
||||
func jwtExpireHours() int {
|
||||
func jwtExpireHours(store AdminConfigReader) int {
|
||||
if store != nil {
|
||||
if n := store.AdminJWTExpireHours(); n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("DS2API_JWT_EXPIRE_HOURS")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return n
|
||||
@@ -44,27 +71,44 @@ func jwtExpireHours() int {
|
||||
}
|
||||
|
||||
func CreateJWT(expireHours int) (string, error) {
|
||||
return CreateJWTWithStore(expireHours, nil)
|
||||
}
|
||||
|
||||
func CreateJWTWithStore(expireHours int, store AdminConfigReader) (string, error) {
|
||||
if expireHours <= 0 {
|
||||
expireHours = jwtExpireHours()
|
||||
expireHours = jwtExpireHours(store)
|
||||
}
|
||||
issuedAt := time.Now().Unix()
|
||||
// If sessions were invalidated in this same second, move iat forward by
|
||||
// one second so newly minted tokens remain valid with strict cutoff checks.
|
||||
if store != nil {
|
||||
if validAfter := store.AdminJWTValidAfterUnix(); validAfter >= issuedAt {
|
||||
issuedAt = validAfter + 1
|
||||
}
|
||||
}
|
||||
expireAt := time.Unix(issuedAt, 0).Add(time.Duration(expireHours) * time.Hour).Unix()
|
||||
header := map[string]any{"alg": "HS256", "typ": "JWT"}
|
||||
payload := map[string]any{"iat": time.Now().Unix(), "exp": time.Now().Add(time.Duration(expireHours) * time.Hour).Unix(), "role": "admin"}
|
||||
payload := map[string]any{"iat": issuedAt, "exp": expireAt, "role": "admin"}
|
||||
h, _ := json.Marshal(header)
|
||||
p, _ := json.Marshal(payload)
|
||||
headerB64 := rawB64Encode(h)
|
||||
payloadB64 := rawB64Encode(p)
|
||||
msg := headerB64 + "." + payloadB64
|
||||
sig := signHS256(msg)
|
||||
sig := signHS256(msg, store)
|
||||
return msg + "." + rawB64Encode(sig), nil
|
||||
}
|
||||
|
||||
func VerifyJWT(token string) (map[string]any, error) {
|
||||
return VerifyJWTWithStore(token, nil)
|
||||
}
|
||||
|
||||
func VerifyJWTWithStore(token string, store AdminConfigReader) (map[string]any, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, errors.New("invalid token format")
|
||||
}
|
||||
msg := parts[0] + "." + parts[1]
|
||||
expected := signHS256(msg)
|
||||
expected := signHS256(msg, store)
|
||||
actual, err := rawB64Decode(parts[2])
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid signature")
|
||||
@@ -84,10 +128,23 @@ func VerifyJWT(token string) (map[string]any, error) {
|
||||
if int64(exp) < time.Now().Unix() {
|
||||
return nil, errors.New("token expired")
|
||||
}
|
||||
if store != nil {
|
||||
validAfter := store.AdminJWTValidAfterUnix()
|
||||
if validAfter > 0 {
|
||||
iat, _ := payload["iat"].(float64)
|
||||
if int64(iat) <= validAfter {
|
||||
return nil, errors.New("token expired")
|
||||
}
|
||||
}
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func VerifyAdminRequest(r *http.Request) error {
|
||||
return VerifyAdminRequestWithStore(r, nil)
|
||||
}
|
||||
|
||||
func VerifyAdminRequestWithStore(r *http.Request, store AdminConfigReader) error {
|
||||
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
return errors.New("authentication required")
|
||||
@@ -96,17 +153,65 @@ func VerifyAdminRequest(r *http.Request) error {
|
||||
if token == "" {
|
||||
return errors.New("authentication required")
|
||||
}
|
||||
if token == AdminKey() {
|
||||
if VerifyAdminCredential(token, store) {
|
||||
return nil
|
||||
}
|
||||
if _, err := VerifyJWT(token); err == nil {
|
||||
if _, err := VerifyJWTWithStore(token, store); err == nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
func signHS256(msg string) []byte {
|
||||
h := hmac.New(sha256.New, []byte(jwtSecret()))
|
||||
func VerifyAdminCredential(candidate string, store AdminConfigReader) bool {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" {
|
||||
return false
|
||||
}
|
||||
if store != nil {
|
||||
hash := strings.TrimSpace(store.AdminPasswordHash())
|
||||
if hash != "" {
|
||||
return verifyAdminPasswordHash(candidate, hash)
|
||||
}
|
||||
}
|
||||
key := effectiveAdminKey(store)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(candidate), []byte(key)) == 1
|
||||
}
|
||||
|
||||
func UsingDefaultAdminKey(store AdminConfigReader) bool {
|
||||
if store != nil && strings.TrimSpace(store.AdminPasswordHash()) != "" {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv("DS2API_ADMIN_KEY")) == ""
|
||||
}
|
||||
|
||||
func HashAdminPassword(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func verifyAdminPasswordHash(candidate, encoded string) bool {
|
||||
encoded = strings.TrimSpace(strings.ToLower(encoded))
|
||||
if encoded == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(encoded, "sha256:") {
|
||||
want := strings.TrimPrefix(encoded, "sha256:")
|
||||
sum := sha256.Sum256([]byte(candidate))
|
||||
got := hex.EncodeToString(sum[:])
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(candidate), []byte(encoded)) == 1
|
||||
}
|
||||
|
||||
func signHS256(msg string, store AdminConfigReader) []byte {
|
||||
h := hmac.New(sha256.New, []byte(jwtSecret(store)))
|
||||
_, _ = h.Write([]byte(msg))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package auth
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func TestJWTCreateVerify(t *testing.T) {
|
||||
@@ -27,3 +29,58 @@ func TestVerifyAdminRequest(t *testing.T) {
|
||||
t.Fatalf("expected token accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyJWTWithStoreValidAfter(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{"admin":{"password_hash":"`+HashAdminPassword("oldpass")+`"}}`)
|
||||
store := config.LoadStore()
|
||||
token, err := CreateJWTWithStore(1, store)
|
||||
if err != nil {
|
||||
t.Fatalf("create jwt failed: %v", err)
|
||||
}
|
||||
if _, err := VerifyJWTWithStore(token, store); err != nil {
|
||||
t.Fatalf("verify before invalidation failed: %v", err)
|
||||
}
|
||||
if err := store.Update(func(c *config.Config) error {
|
||||
c.Admin.JWTValidAfterUnix = 1<<62 - 1
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("set valid-after failed: %v", err)
|
||||
}
|
||||
if _, err := VerifyJWTWithStore(token, store); err == nil {
|
||||
t.Fatal("expected token invalid after valid-after update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyJWTWithStoreSameSecondInvalidationAndRelogin(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{"admin":{"password_hash":"`+HashAdminPassword("oldpass")+`"}}`)
|
||||
store := config.LoadStore()
|
||||
|
||||
oldToken, err := CreateJWTWithStore(1, store)
|
||||
if err != nil {
|
||||
t.Fatalf("create old jwt failed: %v", err)
|
||||
}
|
||||
oldPayload, err := VerifyJWTWithStore(oldToken, store)
|
||||
if err != nil {
|
||||
t.Fatalf("verify old jwt before invalidation failed: %v", err)
|
||||
}
|
||||
oldIAT, _ := oldPayload["iat"].(float64)
|
||||
|
||||
if err := store.Update(func(c *config.Config) error {
|
||||
c.Admin.JWTValidAfterUnix = int64(oldIAT)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("set valid-after failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := VerifyJWTWithStore(oldToken, store); err == nil {
|
||||
t.Fatal("expected old token invalid when iat == valid-after")
|
||||
}
|
||||
|
||||
newToken, err := CreateJWTWithStore(1, store)
|
||||
if err != nil {
|
||||
t.Fatalf("create new jwt failed: %v", err)
|
||||
}
|
||||
if _, err := VerifyJWTWithStore(newToken, store); err != nil {
|
||||
t.Fatalf("expected new token valid after invalidation cutoff: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user