refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
This commit is contained in:
+332
-1
@@ -4,6 +4,9 @@ import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -11,8 +14,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestVerifyTelegramAuthorization(t *testing.T) {
|
||||
@@ -31,6 +39,7 @@ func TestVerifyTelegramAuthorization(t *testing.T) {
|
||||
{name: "expired", authDate: now.Add(-telegramAuthorizationMaxAge - time.Second), wantErr: "expired"},
|
||||
{name: "too far in future", authDate: now.Add(telegramAuthorizationFutureSkew + time.Second), wantErr: "expired"},
|
||||
{name: "invalid signature", authDate: now, mutate: func(values url.Values) { values.Set("hash", "00") }, wantErr: "signature"},
|
||||
{name: "unsigned flow token query is rejected", authDate: now, mutate: func(values url.Values) { values.Set("flow_token", "must-be-in-path") }, wantErr: "signature"},
|
||||
{name: "duplicate parameter", authDate: now, mutate: func(values url.Values) { values["id"] = append(values["id"], "654321") }, wantErr: "duplicate"},
|
||||
}
|
||||
|
||||
@@ -61,8 +70,16 @@ func signedTelegramAuthorization(token string, authDate time.Time) url.Values {
|
||||
"first_name": {"Test"},
|
||||
"id": {"123456"},
|
||||
}
|
||||
signTelegramAuthorization(token, params)
|
||||
return params
|
||||
}
|
||||
|
||||
func signTelegramAuthorization(token string, params url.Values) {
|
||||
keys := make([]string, 0, len(params))
|
||||
for key := range params {
|
||||
if key == "hash" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
@@ -74,5 +91,319 @@ func signedTelegramAuthorization(token string, authDate time.Time) url.Values {
|
||||
mac := hmac.New(sha256.New, secret[:])
|
||||
_, _ = mac.Write([]byte(strings.Join(dataCheck, "\n")))
|
||||
params.Set("hash", hex.EncodeToString(mac.Sum(nil)))
|
||||
return params
|
||||
}
|
||||
|
||||
func createTelegramBindTestFlow(t *testing.T, db *gorm.DB, name string, status int, now time.Time) (*model.User, string) {
|
||||
t.Helper()
|
||||
user := &model.User{
|
||||
Username: name, Password: "password-placeholder", Role: common.RoleCommonUser,
|
||||
Status: status, Group: "default", AuthVersion: 1, AffCode: name,
|
||||
}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
session := &model.UserSession{
|
||||
SID: name + "-session", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion,
|
||||
Status: model.UserSessionStatusActive, RefreshHash: name + "-refresh-hash", LoginMethod: "password",
|
||||
CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(),
|
||||
}
|
||||
require.NoError(t, model.CreateUserSession(session))
|
||||
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
|
||||
Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: session.SID,
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return user, flowToken
|
||||
}
|
||||
|
||||
func assertTelegramBindRedirect(t *testing.T, response *httptest.ResponseRecorder, flowToken, errorCode string) {
|
||||
t.Helper()
|
||||
require.Equal(t, http.StatusFound, response.Code)
|
||||
location, err := url.Parse(response.Header().Get("Location"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/oauth/telegram", location.Path)
|
||||
assert.Equal(t, "error", location.Query().Get("telegram_bind"))
|
||||
assert.Equal(t, flowToken, location.Query().Get("flow_token"))
|
||||
assert.Equal(t, errorCode, location.Query().Get("error_code"))
|
||||
assert.Empty(t, location.Query().Get("error_description"))
|
||||
assert.Empty(t, location.Query().Get("message"))
|
||||
}
|
||||
|
||||
func TestTelegramBindFailureResponseContract(t *testing.T) {
|
||||
failures := []struct {
|
||||
name string
|
||||
errorCode string
|
||||
}{
|
||||
{name: "disabled", errorCode: telegramBindErrorDisabled},
|
||||
{name: "invalid request", errorCode: telegramBindErrorInvalidRequest},
|
||||
{name: "invalid flow", errorCode: telegramBindErrorFlowInvalid},
|
||||
{name: "invalid session", errorCode: telegramBindErrorSessionInvalid},
|
||||
{name: "already bound", errorCode: telegramBindErrorAlreadyBound},
|
||||
{name: "deleted user", errorCode: telegramBindErrorUserDeleted},
|
||||
{name: "disabled user", errorCode: telegramBindErrorUserDisabled},
|
||||
{name: "internal error", errorCode: telegramBindErrorInternal},
|
||||
}
|
||||
|
||||
for _, failure := range failures {
|
||||
t.Run(failure.name, func(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Params = gin.Params{{Key: "flow_token", Value: "flow token"}}
|
||||
context.Request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/flow-token", nil)
|
||||
|
||||
telegramBindFailure(context, failure.errorCode)
|
||||
|
||||
assertTelegramBindRedirect(t, response, "flow token", failure.errorCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramBindCommitsFlowAssertionAndBindingAtomically(t *testing.T) {
|
||||
previousDB := model.DB
|
||||
previousType := common.MainDatabaseType()
|
||||
previousRedis := common.RedisEnabled
|
||||
previousEnabled := common.TelegramOAuthEnabled
|
||||
previousToken := common.TelegramBotToken
|
||||
previousSecret := common.SessionSecret
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.UserSession{},
|
||||
&model.AuthFlow{},
|
||||
&model.ExternalIdentityClaim{},
|
||||
))
|
||||
model.DB = db
|
||||
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
|
||||
common.RedisEnabled = false
|
||||
common.TelegramOAuthEnabled = true
|
||||
common.TelegramBotToken = "telegram-bind-test-token"
|
||||
common.SessionSecret = "telegram-bind-session-secret"
|
||||
t.Cleanup(func() {
|
||||
model.DB = previousDB
|
||||
common.SetMainDatabaseType(previousType)
|
||||
common.RedisEnabled = previousRedis
|
||||
common.TelegramOAuthEnabled = previousEnabled
|
||||
common.TelegramBotToken = previousToken
|
||||
common.SessionSecret = previousSecret
|
||||
})
|
||||
|
||||
user := &model.User{
|
||||
Username: "telegram-bind-user", Password: "password-placeholder", Role: common.RoleCommonUser,
|
||||
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "telegram-bind-user",
|
||||
}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
now := time.Now()
|
||||
session := &model.UserSession{
|
||||
SID: "telegram-bind-session", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion,
|
||||
Status: model.UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password",
|
||||
CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(),
|
||||
}
|
||||
require.NoError(t, model.CreateUserSession(session))
|
||||
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
|
||||
Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: session.SID,
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
params := signedTelegramAuthorization(common.TelegramBotToken, now)
|
||||
router := gin.New()
|
||||
router.GET("/api/oauth/telegram/bind/:flow_token", TelegramBind)
|
||||
|
||||
common.TelegramOAuthEnabled = false
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/disabled-flow", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
assertTelegramBindRedirect(t, response, "disabled-flow", telegramBindErrorDisabled)
|
||||
common.TelegramOAuthEnabled = true
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/invalid-request", nil)
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
assertTelegramBindRedirect(t, response, "invalid-request", telegramBindErrorInvalidRequest)
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/missing-flow?"+params.Encode(), nil)
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
assertTelegramBindRedirect(t, response, "missing-flow", telegramBindErrorFlowInvalid)
|
||||
|
||||
invalidSessionFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
|
||||
Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: "missing-session",
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
request = httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/oauth/telegram/bind/"+invalidSessionFlowToken+"?"+params.Encode(),
|
||||
nil,
|
||||
)
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
assertTelegramBindRedirect(t, response, invalidSessionFlowToken, telegramBindErrorSessionInvalid)
|
||||
invalidSessionFlow, err := model.GetAuthFlow(invalidSessionFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, invalidSessionFlow.ConsumedAt)
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/"+flowToken+"?"+params.Encode(), nil)
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
assert.Equal(t, http.StatusFound, response.Code)
|
||||
assert.Equal(t, "/oauth/telegram?telegram_bind=success&flow_token="+url.QueryEscape(flowToken), response.Header().Get("Location"))
|
||||
var storedUser model.User
|
||||
require.NoError(t, db.First(&storedUser, user.Id).Error)
|
||||
assert.Equal(t, "123456", storedUser.TelegramId)
|
||||
var identityClaim model.ExternalIdentityClaim
|
||||
require.NoError(t, db.Where("provider = ? AND subject = ?", model.ExternalIdentityProviderTelegram, "123456").
|
||||
First(&identityClaim).Error)
|
||||
assert.Equal(t, user.Id, identityClaim.UserId)
|
||||
_, err = model.GetAuthFlow(flowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
|
||||
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
|
||||
|
||||
replayFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
|
||||
Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: session.SID,
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/"+replayFlowToken+"?"+params.Encode(), nil)
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
assertTelegramBindRedirect(t, response, replayFlowToken, telegramBindErrorInvalidRequest)
|
||||
replayFlow, err := model.GetAuthFlow(replayFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, replayFlow.ConsumedAt)
|
||||
|
||||
competingUser := &model.User{
|
||||
Username: "telegram-bind-competing-user", Password: "password-placeholder", Role: common.RoleCommonUser,
|
||||
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "telegram-bind-competing-user",
|
||||
}
|
||||
require.NoError(t, db.Create(competingUser).Error)
|
||||
competingSession := &model.UserSession{
|
||||
SID: "telegram-bind-competing-session", UserID: competingUser.Id, Version: 1,
|
||||
UserAuthVersion: competingUser.AuthVersion, Status: model.UserSessionStatusActive,
|
||||
RefreshHash: "competing-refresh-hash", LoginMethod: "password",
|
||||
CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(),
|
||||
}
|
||||
require.NoError(t, model.CreateUserSession(competingSession))
|
||||
competingFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
|
||||
Purpose: model.AuthFlowPurposeTelegramBind, UserId: competingUser.Id, SessionId: competingSession.SID,
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
competingParams := signedTelegramAuthorization(common.TelegramBotToken, now)
|
||||
competingParams.Set("first_name", "Competing")
|
||||
signTelegramAuthorization(common.TelegramBotToken, competingParams)
|
||||
request = httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/oauth/telegram/bind/"+competingFlowToken+"?"+competingParams.Encode(),
|
||||
nil,
|
||||
)
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
assertTelegramBindRedirect(t, response, competingFlowToken, telegramBindErrorAlreadyBound)
|
||||
|
||||
require.NoError(t, db.First(competingUser, competingUser.Id).Error)
|
||||
assert.Empty(t, competingUser.TelegramId)
|
||||
competingFlow, err := model.GetAuthFlow(competingFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, competingFlow.ConsumedAt)
|
||||
competingAssertion, competingAssertionExpiry, err := telegramAuthorizationClaim(competingParams, time.Now())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, model.ClaimExternalAuthAssertion(
|
||||
model.AuthFlowPurposeTelegramAssertion,
|
||||
competingAssertion,
|
||||
competingAssertionExpiry,
|
||||
))
|
||||
|
||||
disabledUser, disabledFlowToken := createTelegramBindTestFlow(
|
||||
t, db, "telegram-bind-disabled-user", common.UserStatusDisabled, now,
|
||||
)
|
||||
disabledParams := signedTelegramAuthorization(common.TelegramBotToken, now)
|
||||
disabledParams.Set("id", "disabled-telegram-id")
|
||||
disabledParams.Set("first_name", "Disabled")
|
||||
signTelegramAuthorization(common.TelegramBotToken, disabledParams)
|
||||
request = httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/oauth/telegram/bind/"+disabledFlowToken+"?"+disabledParams.Encode(),
|
||||
nil,
|
||||
)
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
assertTelegramBindRedirect(t, response, disabledFlowToken, telegramBindErrorUserDisabled)
|
||||
var storedDisabledUser model.User
|
||||
require.NoError(t, db.First(&storedDisabledUser, disabledUser.Id).Error)
|
||||
assert.Empty(t, storedDisabledUser.TelegramId)
|
||||
disabledFlow, err := model.GetAuthFlow(disabledFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, disabledFlow.ConsumedAt)
|
||||
disabledAssertion, disabledAssertionExpiry, err := telegramAuthorizationClaim(disabledParams, time.Now())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, model.ClaimExternalAuthAssertion(
|
||||
model.AuthFlowPurposeTelegramAssertion,
|
||||
disabledAssertion,
|
||||
disabledAssertionExpiry,
|
||||
))
|
||||
|
||||
deletedUser, deletedFlowToken := createTelegramBindTestFlow(
|
||||
t, db, "telegram-bind-deleted-user", common.UserStatusEnabled, now,
|
||||
)
|
||||
require.NoError(t, db.Delete(deletedUser).Error)
|
||||
deletedParams := signedTelegramAuthorization(common.TelegramBotToken, now)
|
||||
deletedParams.Set("id", "deleted-telegram-id")
|
||||
deletedParams.Set("first_name", "Deleted")
|
||||
signTelegramAuthorization(common.TelegramBotToken, deletedParams)
|
||||
request = httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/oauth/telegram/bind/"+deletedFlowToken+"?"+deletedParams.Encode(),
|
||||
nil,
|
||||
)
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
assertTelegramBindRedirect(t, response, deletedFlowToken, telegramBindErrorUserDeleted)
|
||||
deletedFlow, err := model.GetAuthFlow(deletedFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, deletedFlow.ConsumedAt)
|
||||
deletedAssertion, deletedAssertionExpiry, err := telegramAuthorizationClaim(deletedParams, time.Now())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, model.ClaimExternalAuthAssertion(
|
||||
model.AuthFlowPurposeTelegramAssertion,
|
||||
deletedAssertion,
|
||||
deletedAssertionExpiry,
|
||||
))
|
||||
|
||||
_, internalFlowToken := createTelegramBindTestFlow(
|
||||
t, db, "telegram-bind-internal-error", common.UserStatusEnabled, now,
|
||||
)
|
||||
internalParams := signedTelegramAuthorization(common.TelegramBotToken, now)
|
||||
internalParams.Set("id", "internal-error-telegram-id")
|
||||
internalParams.Set("first_name", "Internal")
|
||||
signTelegramAuthorization(common.TelegramBotToken, internalParams)
|
||||
forcedError := errors.New("forced telegram session query failure")
|
||||
const callbackName = "test:telegram-bind-session-query-failure"
|
||||
require.NoError(t, db.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) {
|
||||
if tx.Statement.Table != "user_sessions" {
|
||||
return
|
||||
}
|
||||
if _, inTransaction := tx.Statement.ConnPool.(gorm.TxCommitter); inTransaction {
|
||||
tx.AddError(forcedError)
|
||||
}
|
||||
}))
|
||||
request = httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/oauth/telegram/bind/"+internalFlowToken+"?"+internalParams.Encode(),
|
||||
nil,
|
||||
)
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
db.Callback().Query().Remove(callbackName)
|
||||
assertTelegramBindRedirect(t, response, internalFlowToken, telegramBindErrorInternal)
|
||||
assert.NotContains(t, response.Header().Get("Location"), forcedError.Error())
|
||||
internalFlow, err := model.GetAuthFlow(internalFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, internalFlow.ConsumedAt)
|
||||
internalAssertion, internalAssertionExpiry, err := telegramAuthorizationClaim(internalParams, time.Now())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, model.ClaimExternalAuthAssertion(
|
||||
model.AuthFlowPurposeTelegramAssertion,
|
||||
internalAssertion,
|
||||
internalAssertionExpiry,
|
||||
))
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user