feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -18,6 +18,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -31,6 +32,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -53,7 +55,17 @@ func setupSecurityEnrollmentTest(t *testing.T) (*model.User, service.AuthIdentit
|
||||
dialect = "sqlite"
|
||||
}
|
||||
dsn := os.Getenv("TEST_" + strings.ToUpper(dialect) + "_DSN")
|
||||
db, _ := newAuditTestDatabase(t, dialect, dsn)
|
||||
var db *gorm.DB
|
||||
if dialect == "sqlite" {
|
||||
// Match common.SQLitePath: take the writer lock before transaction reads.
|
||||
// Deferred transactions can fail to upgrade their read lock when concurrent
|
||||
// proof consumption overlaps account deletion or session revocation.
|
||||
var err error
|
||||
db, err = gorm.Open(sqlite.Open(t.TempDir()+"/security.db?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)&_txlock=immediate"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
db, _ = newAuditTestDatabase(t, dialect, dsn)
|
||||
}
|
||||
logDB, _ := newAuditTestDatabase(t, dialect, dsn)
|
||||
db.Logger = logger.Default.LogMode(logger.Silent)
|
||||
logDB.Logger = logger.Default.LogMode(logger.Silent)
|
||||
@@ -64,9 +76,10 @@ func setupSecurityEnrollmentTest(t *testing.T) (*model.User, service.AuthIdentit
|
||||
var version string
|
||||
require.NoError(t, db.Raw(versionQuery).Scan(&version).Error)
|
||||
t.Logf("database: %s %s", dialect, version)
|
||||
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.AuthFlow{}, &model.UserOAuthBinding{}, &model.Option{}))
|
||||
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.AuthFlow{}, &model.UserOAuthBinding{}, &model.Option{}, &model.UserAccessToken{}))
|
||||
require.NoError(t, logDB.AutoMigrate(&model.AuditLog{}))
|
||||
model.DB, model.LOG_DB = db, logDB
|
||||
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
|
||||
dbType := common.DatabaseTypeSQLite
|
||||
if dialect == "mysql" {
|
||||
dbType = common.DatabaseTypeMySQL
|
||||
@@ -126,28 +139,61 @@ func issueSecurityEnrollmentProof(t *testing.T, identity service.AuthIdentity, o
|
||||
return proof
|
||||
}
|
||||
|
||||
// withAccessTokenID sets the :id route parameter the way the router would.
|
||||
func withAccessTokenID(handler gin.HandlerFunc, id int) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(id)}}
|
||||
handler(c)
|
||||
}
|
||||
}
|
||||
|
||||
type accessTokenEndpoint struct {
|
||||
name, method, path, body string
|
||||
scope string
|
||||
context []byte
|
||||
handler gin.HandlerFunc
|
||||
}
|
||||
|
||||
// accessTokenMutationEndpoints lists every proof-guarded access token
|
||||
// operation for one scoped token of the test user.
|
||||
func accessTokenMutationEndpoints(tokenID int) []accessTokenEndpoint {
|
||||
return []accessTokenEndpoint{
|
||||
{"create", "POST", "/api/user/access_tokens", `{"name":"ci","scopes":["profile:read"],"expires_at":0}`, service.VerificationScopeAccessTokenGenerate, []byte(`{"scopes":["profile:read"],"expires_at":0}`), CreateAccessToken},
|
||||
{"update", "PATCH", fmt.Sprintf("/api/user/access_tokens/%d", tokenID), `{"name":"ci","scopes":["usage:read"]}`, service.VerificationScopeAccessTokenUpdate, []byte(fmt.Sprintf(`{"token_id":%d,"scopes":["usage:read"]}`, tokenID)), withAccessTokenID(UpdateAccessToken, tokenID)},
|
||||
{"delete", "DELETE", fmt.Sprintf("/api/user/access_tokens/%d", tokenID), "", service.VerificationScopeAccessTokenRevoke, []byte(fmt.Sprintf(`{"token_id":%d}`, tokenID)), withAccessTokenID(DeleteAccessToken, tokenID)},
|
||||
{"legacy", "DELETE", "/api/user/access_tokens/legacy", "", service.VerificationScopeAccessTokenRevoke, []byte(`{"legacy":true}`), RevokeLegacyAccessToken},
|
||||
}
|
||||
}
|
||||
|
||||
// assertAccessTokensUnchanged checks that the seeded scoped token keeps its
|
||||
// name and grant and that the legacy token still authenticates.
|
||||
func assertAccessTokensUnchanged(t *testing.T, userID int, scopedID int, legacy string) {
|
||||
t.Helper()
|
||||
tokens, err := model.ListUserAccessTokens(userID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tokens, 1)
|
||||
assert.Equal(t, scopedID, tokens[0].Id)
|
||||
assert.Equal(t, "test token", tokens[0].Name)
|
||||
assert.Equal(t, []string{"profile:read"}, tokens[0].GetScopes())
|
||||
stored, err := model.ValidateAccessToken(legacy)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, stored)
|
||||
assert.Equal(t, userID, stored.Id)
|
||||
}
|
||||
|
||||
func TestSecurityEnrollmentAccessTokenRequiresProofBeforeMutation(t *testing.T) {
|
||||
user, identity := setupSecurityEnrollmentTest(t)
|
||||
require.NoError(t, model.UpdateUserAccessToken(user.Id, "existing-system-token"))
|
||||
for _, endpoint := range []struct {
|
||||
method string
|
||||
handler gin.HandlerFunc
|
||||
}{
|
||||
{"GET", GenerateAccessToken},
|
||||
{"POST", GenerateAccessToken},
|
||||
{"DELETE", RevokeAccessToken},
|
||||
} {
|
||||
t.Run(endpoint.method, func(t *testing.T) {
|
||||
response := securityEnrollmentRequest(endpoint.method, "/api/user/token", "", "", identity, endpoint.handler)
|
||||
require.NoError(t, model.DB.Model(user).Update("access_token", "existing-system-token").Error)
|
||||
_, scoped := createScopedAccessToken(t, user.Id, 0, "profile:read")
|
||||
for _, endpoint := range accessTokenMutationEndpoints(scoped.Id) {
|
||||
t.Run(endpoint.name, func(t *testing.T) {
|
||||
response := securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, "", identity, endpoint.handler)
|
||||
var body securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
||||
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||
assert.False(t, body.Success)
|
||||
assert.Equal(t, "SECURITY_PROOF_REQUIRED", body.Code)
|
||||
stored, err := model.ValidateAccessToken("existing-system-token")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, stored)
|
||||
assert.Equal(t, user.Id, stored.Id)
|
||||
assertAccessTokensUnchanged(t, user.Id, scoped.Id, "existing-system-token")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -197,7 +243,7 @@ func TestSecurityEnrollmentAccessTokenMethodPolicy(t *testing.T) {
|
||||
if !test.password {
|
||||
passwordScope = service.VerificationScopePasswordSet
|
||||
}
|
||||
for _, scope := range []string{service.VerificationScopeAccessTokenGenerate, service.VerificationScopeAccessTokenRevoke,
|
||||
for _, scope := range []string{service.VerificationScopeAccessTokenGenerate, service.VerificationScopeAccessTokenUpdate, service.VerificationScopeAccessTokenRevoke,
|
||||
service.VerificationScopeAccountBind, service.VerificationScopeAccountUnbind, passwordScope} {
|
||||
requirements, err := service.GetVerificationRequirements(identity, scope)
|
||||
require.NoError(t, err)
|
||||
@@ -218,6 +264,12 @@ func TestSecurityEnrollmentAccessTokenMethodPolicy(t *testing.T) {
|
||||
input.Context = []byte(`{"provider":"email","email":"new@example.com"}`)
|
||||
case service.VerificationScopeAccountUnbind:
|
||||
input.Context = []byte(`{"provider_id":1}`)
|
||||
case service.VerificationScopeAccessTokenGenerate:
|
||||
input.Context = []byte(`{"scopes":["profile:read"],"expires_at":0}`)
|
||||
case service.VerificationScopeAccessTokenUpdate:
|
||||
input.Context = []byte(`{"token_id":1,"scopes":["profile:read"]}`)
|
||||
case service.VerificationScopeAccessTokenRevoke:
|
||||
input.Context = []byte(`{"token_id":1}`)
|
||||
}
|
||||
_, err := service.VerifySecurityInput(identity, input)
|
||||
assert.ErrorIs(t, err, service.ErrProofMethod)
|
||||
@@ -229,94 +281,102 @@ func TestSecurityEnrollmentAccessTokenMethodPolicy(t *testing.T) {
|
||||
|
||||
func TestSecurityEnrollmentAccessTokenLifecycleConsumesProofs(t *testing.T) {
|
||||
user, identity := setupSecurityEnrollmentTest(t)
|
||||
require.NoError(t, model.UpdateUserAccessToken(user.Id, "previous-token"))
|
||||
previousToken := "previous-token"
|
||||
for _, method := range []string{"GET", "POST"} {
|
||||
require.NoError(t, model.DB.Model(user).Update("access_token", "previous-token").Error)
|
||||
createBody := `{"name":"ci","scopes":["profile:read"],"expires_at":0}`
|
||||
var created []string
|
||||
for range 2 {
|
||||
proof, err := service.VerifySecurityInput(identity, service.VerificationInput{
|
||||
Scope: service.VerificationScopeAccessTokenGenerate, Method: "password", Password: "enrollment-password",
|
||||
Scope: service.VerificationScopeAccessTokenGenerate, Context: []byte(`{"scopes":["profile:read"],"expires_at":0}`), Method: "password", Password: "enrollment-password",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
wrongScope := securityEnrollmentRequest("DELETE", "/api/user/token", "", proof.ProofToken, identity, RevokeAccessToken)
|
||||
wrongScope := securityEnrollmentRequest("DELETE", "/api/user/access_tokens/legacy", "", proof.ProofToken, identity, RevokeLegacyAccessToken)
|
||||
assert.Contains(t, wrongScope.Body.String(), `"code":"SECURITY_PROOF_SCOPE_MISMATCH"`)
|
||||
response := securityEnrollmentRequest(method, "/api/user/token", "", proof.ProofToken, identity, GenerateAccessToken)
|
||||
response := securityEnrollmentRequest("POST", "/api/user/access_tokens", createBody, proof.ProofToken, identity, CreateAccessToken)
|
||||
var body securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
||||
require.True(t, body.Success, body.Message)
|
||||
var token string
|
||||
require.NoError(t, common.Unmarshal(body.Data, &token))
|
||||
assert.GreaterOrEqual(t, len(token), 28)
|
||||
assert.LessOrEqual(t, len(token), 32)
|
||||
assert.NotEqual(t, previousToken, token)
|
||||
stored, err := model.ValidateAccessToken(token)
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(body.Data, &result))
|
||||
require.True(t, strings.HasPrefix(result.Token, model.AccessTokenPrefix))
|
||||
stored, err := model.FindUserAccessTokenByHash(model.AccessTokenFingerprint(result.Token))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, stored)
|
||||
assert.Equal(t, user.Id, stored.Id)
|
||||
assert.Equal(t, model.AccessTokenFingerprint(token), model.AccessTokenFingerprint(stored.GetAccessToken()))
|
||||
oldUser, err := model.ValidateAccessToken(previousToken)
|
||||
assert.Nil(t, oldUser)
|
||||
require.NoError(t, err)
|
||||
response = securityEnrollmentRequest(method, "/api/user/token", "", proof.ProofToken, identity, GenerateAccessToken)
|
||||
response = securityEnrollmentRequest("POST", "/api/user/access_tokens", createBody, proof.ProofToken, identity, CreateAccessToken)
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
|
||||
previousToken = token
|
||||
created = append(created, result.Token)
|
||||
}
|
||||
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke}, "password")
|
||||
response := securityEnrollmentRequest("DELETE", "/api/user/token", "", proof, identity, RevokeAccessToken)
|
||||
legacyUser, err := model.ValidateAccessToken("previous-token")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, legacyUser, "creating scoped tokens leaves the legacy token alone")
|
||||
tokens, err := model.ListUserAccessTokens(user.Id)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tokens, 2)
|
||||
first, second := tokens[0], tokens[1]
|
||||
|
||||
// A revoke proof is bound to one token ID.
|
||||
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: []byte(fmt.Sprintf(`{"token_id":%d}`, first.Id))}, "password")
|
||||
response := securityEnrollmentRequest("DELETE", "/api/user/access_tokens/x", "", proof, identity, withAccessTokenID(DeleteAccessToken, second.Id))
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONTEXT_MISMATCH"`)
|
||||
response = securityEnrollmentRequest("DELETE", "/api/user/access_tokens/x", "", proof, identity, withAccessTokenID(DeleteAccessToken, first.Id))
|
||||
var body securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
||||
require.True(t, body.Success, body.Message)
|
||||
stored, err := model.GetUserById(user.Id, true)
|
||||
deleted, err := model.FindUserAccessTokenByHash(first.TokenHash)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, stored.GetAccessToken())
|
||||
revokedUser, err := model.ValidateAccessToken(previousToken)
|
||||
assert.Nil(t, deleted)
|
||||
kept, err := model.FindUserAccessTokenByHash(second.TokenHash)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, revokedUser)
|
||||
response = securityEnrollmentRequest("DELETE", "/api/user/token", "", proof, identity, RevokeAccessToken)
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
|
||||
response = securityEnrollmentRequest("GET", "/api/user/token/status", "", "", identity, GetAccessTokenStatus)
|
||||
assert.Contains(t, response.Body.String(), `"exists":false`)
|
||||
assert.NotNil(t, kept)
|
||||
|
||||
legacyProof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: []byte(`{"legacy":true}`)}, "password")
|
||||
response = securityEnrollmentRequest("DELETE", "/api/user/access_tokens/legacy", "", legacyProof, identity, RevokeLegacyAccessToken)
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
||||
require.True(t, body.Success, body.Message)
|
||||
legacyUser, err = model.ValidateAccessToken("previous-token")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, legacyUser)
|
||||
response = securityEnrollmentRequest("DELETE", "/api/user/access_tokens/legacy", "", legacyProof, identity, RevokeLegacyAccessToken)
|
||||
assert.Equal(t, http.StatusNotFound, response.Code, "there is nothing left to revoke")
|
||||
|
||||
var audits []model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Find(&audits).Error)
|
||||
require.Len(t, audits, 3)
|
||||
require.NoError(t, model.LOG_DB.Order("id").Find(&audits).Error)
|
||||
actions := make([]string, 0, len(audits))
|
||||
for _, audit := range audits {
|
||||
actions = append(actions, audit.Action)
|
||||
}
|
||||
assert.Equal(t, []string{"access_token.generate", "access_token.generate", "access_token.revoke", "access_token.revoke"}, actions)
|
||||
encoded, err := common.Marshal(audits)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(encoded), "access_token.generate")
|
||||
assert.Contains(t, string(encoded), "access_token.revoke")
|
||||
assert.NotContains(t, string(encoded), previousToken)
|
||||
assert.NotContains(t, string(encoded), proof)
|
||||
for _, secret := range append(created, "previous-token", proof, legacyProof) {
|
||||
assert.NotContains(t, string(encoded), secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityEnrollmentAccessTokenRejectsInvalidProofs(t *testing.T) {
|
||||
user, identity := setupSecurityEnrollmentTest(t)
|
||||
require.NoError(t, model.UpdateUserAccessToken(user.Id, "unchanged-token"))
|
||||
for _, endpoint := range []struct {
|
||||
method, scope string
|
||||
handler gin.HandlerFunc
|
||||
}{
|
||||
{"GET", service.VerificationScopeAccessTokenGenerate, GenerateAccessToken},
|
||||
{"POST", service.VerificationScopeAccessTokenGenerate, GenerateAccessToken},
|
||||
{"DELETE", service.VerificationScopeAccessTokenRevoke, RevokeAccessToken},
|
||||
} {
|
||||
for _, failure := range []string{"session", "user", "expired"} {
|
||||
t.Run(endpoint.method+"/"+failure, func(t *testing.T) {
|
||||
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope}, "password")
|
||||
require.NoError(t, model.DB.Model(user).Update("access_token", "unchanged-token").Error)
|
||||
_, scoped := createScopedAccessToken(t, user.Id, 0, "profile:read")
|
||||
for _, endpoint := range accessTokenMutationEndpoints(scoped.Id) {
|
||||
for _, failure := range []string{"session", "user version", "expired"} {
|
||||
t.Run(endpoint.name+"/"+failure, func(t *testing.T) {
|
||||
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope, Context: endpoint.context}, "password")
|
||||
requestIdentity := identity
|
||||
code := "SECURITY_PROOF_INVALID"
|
||||
switch failure {
|
||||
case "session":
|
||||
requestIdentity.SessionID = "other-session"
|
||||
case "user":
|
||||
requestIdentity.UserID++
|
||||
case "user version":
|
||||
requestIdentity.UserAuthVersion++
|
||||
case "expired":
|
||||
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Update("expires_at", time.Now().Add(-time.Minute)).Error)
|
||||
code = "SECURITY_PROOF_EXPIRED"
|
||||
}
|
||||
response := securityEnrollmentRequest(endpoint.method, "/api/user/token", "", proof, requestIdentity, endpoint.handler)
|
||||
response := securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, proof, requestIdentity, endpoint.handler)
|
||||
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||
assert.Contains(t, response.Body.String(), code)
|
||||
stored, err := model.ValidateAccessToken("unchanged-token")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, stored)
|
||||
assert.Equal(t, user.Id, stored.Id)
|
||||
assertAccessTokensUnchanged(t, user.Id, scoped.Id, "unchanged-token")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -324,30 +384,183 @@ func TestSecurityEnrollmentAccessTokenRejectsInvalidProofs(t *testing.T) {
|
||||
|
||||
func TestSecurityEnrollmentAccessTokenFailureDoesNotRestoreProof(t *testing.T) {
|
||||
user, identity := setupSecurityEnrollmentTest(t)
|
||||
require.NoError(t, model.UpdateUserAccessToken(user.Id, "unchanged-token"))
|
||||
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("access_token_write_failure", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "users" {
|
||||
tx.AddError(errors.New("private database failure"))
|
||||
require.NoError(t, model.DB.Model(user).Update("access_token", "unchanged-token").Error)
|
||||
_, scoped := createScopedAccessToken(t, user.Id, 0, "profile:read")
|
||||
failWrite := func(table string) func(tx *gorm.DB) {
|
||||
return func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == table {
|
||||
tx.AddError(errors.New("private database failure"))
|
||||
}
|
||||
}
|
||||
}))
|
||||
for _, endpoint := range []struct {
|
||||
method, scope string
|
||||
handler gin.HandlerFunc
|
||||
}{
|
||||
{"POST", service.VerificationScopeAccessTokenGenerate, GenerateAccessToken},
|
||||
{"DELETE", service.VerificationScopeAccessTokenRevoke, RevokeAccessToken},
|
||||
} {
|
||||
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope}, "password")
|
||||
response := securityEnrollmentRequest(endpoint.method, "/api/user/token", "", proof, identity, endpoint.handler)
|
||||
assert.Equal(t, http.StatusInternalServerError, response.Code)
|
||||
assert.NotContains(t, response.Body.String(), "private database")
|
||||
response = securityEnrollmentRequest(endpoint.method, "/api/user/token", "", proof, identity, endpoint.handler)
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
|
||||
}
|
||||
stored, err := model.ValidateAccessToken("unchanged-token")
|
||||
require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("access_token_create_failure", failWrite("user_access_tokens")))
|
||||
require.NoError(t, model.DB.Callback().Delete().Before("gorm:delete").Register("access_token_delete_failure", failWrite("user_access_tokens")))
|
||||
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("legacy_token_update_failure", failWrite("users")))
|
||||
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("access_token_update_failure", failWrite("user_access_tokens")))
|
||||
for _, endpoint := range accessTokenMutationEndpoints(scoped.Id) {
|
||||
t.Run(endpoint.name, func(t *testing.T) {
|
||||
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope, Context: endpoint.context}, "password")
|
||||
response := securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, proof, identity, endpoint.handler)
|
||||
assert.Equal(t, http.StatusInternalServerError, response.Code)
|
||||
assert.NotContains(t, response.Body.String(), "private database")
|
||||
response = securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, proof, identity, endpoint.handler)
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
|
||||
})
|
||||
}
|
||||
assertAccessTokensUnchanged(t, user.Id, scoped.Id, "unchanged-token")
|
||||
}
|
||||
|
||||
func decodeSecurityEnrollmentResponse(t *testing.T, response *httptest.ResponseRecorder) securityEnrollmentResponse {
|
||||
t.Helper()
|
||||
var body securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body), response.Body.String())
|
||||
return body
|
||||
}
|
||||
|
||||
// A scoped token completes step-up on its own pat:<id> identity. The change it
|
||||
// makes logs out every browser session and leaves the token usable.
|
||||
func TestSecurityEnrollmentAccessTokenStepUpDisablesTwoFA(t *testing.T) {
|
||||
user, _ := setupSecurityEnrollmentTest(t)
|
||||
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
|
||||
browser, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "browser")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, stored)
|
||||
assert.Equal(t, user.Id, stored.Id)
|
||||
raw, token := createScopedAccessToken(t, user.Id, 0, "account_security:write", "profile:read")
|
||||
router := newAccessTokenTestRouter()
|
||||
|
||||
response := accessTokenRequest(router, http.MethodGet, "/api/verify/methods?scope=2fa.disable", raw, "", "")
|
||||
body := decodeSecurityEnrollmentResponse(t, response)
|
||||
require.True(t, body.Success, body.Message)
|
||||
var requirements service.VerificationRequirements
|
||||
require.NoError(t, common.Unmarshal(body.Data, &requirements))
|
||||
assert.Equal(t, []service.VerificationMethodOption{{Method: service.VerificationMethodTwoFA, Available: true}}, requirements.Methods)
|
||||
|
||||
code, err := totp.GenerateCode("JBSWY3DPEHPK3PXP", time.Now())
|
||||
require.NoError(t, err)
|
||||
response = accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", `{"scope":"2fa.disable","method":"2fa","code":"`+code+`"}`)
|
||||
body = decodeSecurityEnrollmentResponse(t, response)
|
||||
require.True(t, body.Success, body.Message)
|
||||
var proof service.SecurityProof
|
||||
require.NoError(t, common.Unmarshal(body.Data, &proof))
|
||||
response = accessTokenRequest(router, http.MethodPost, "/api/user/2fa/disable", raw, proof.ProofToken, `{}`)
|
||||
body = decodeSecurityEnrollmentResponse(t, response)
|
||||
require.True(t, body.Success, body.Message)
|
||||
assert.JSONEq(t, `{}`, string(body.Data), "a token-initiated change issues no browser session")
|
||||
|
||||
twoFA, err := model.GetTwoFAByUserId(user.Id)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, twoFA != nil && twoFA.IsEnabled)
|
||||
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, http.MethodGet, "/api/user/self", browser.AccessToken, "", "").Code)
|
||||
session, err := model.GetUserSessionBySID(browser.Session.SID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.UserSessionStatusRevoked, session.Status)
|
||||
assert.Equal(t, http.StatusOK, accessTokenRequest(router, http.MethodGet, "/api/user/self", raw, "", "").Code)
|
||||
|
||||
var audits []model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Where("action IN ?", []string{"user.security_verify", "user.2fa_disable_self"}).Order("id").Find(&audits).Error)
|
||||
require.Len(t, audits, 2)
|
||||
encoded, err := common.Marshal(audits)
|
||||
require.NoError(t, err)
|
||||
for _, secret := range []string{raw, proof.ProofToken, code} {
|
||||
assert.NotContains(t, string(encoded), secret)
|
||||
}
|
||||
for _, audit := range audits {
|
||||
assert.Equal(t, "access_token", audit.AuthMethod)
|
||||
require.NotNil(t, audit.Other.Op)
|
||||
params, err := common.Marshal(audit.Other.Op.Params)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(params), `"token_ref":"`+token.TokenHash+`"`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityEnrollmentAccessTokenStepUpBoundaries(t *testing.T) {
|
||||
t.Run("token scope gates verification", func(t *testing.T) {
|
||||
user, _ := setupSecurityEnrollmentTest(t)
|
||||
readOnly, _ := createScopedAccessToken(t, user.Id, 0, "profile:read")
|
||||
full, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write", "profile:read", "user:write")
|
||||
router := newAccessTokenTestRouter()
|
||||
for _, request := range []struct {
|
||||
name, credential, path, body string
|
||||
}{
|
||||
{"methods without account security", readOnly, "/api/verify/methods?scope=2fa.setup", ""},
|
||||
{"verify without account security", readOnly, "/api/verify", `{"scope":"2fa.setup","method":"password","password":"enrollment-password"}`},
|
||||
{"token creation", full, "/api/verify", `{"scope":"access_token.generate","method":"password","password":"enrollment-password","context":{"scopes":["profile:read"],"expires_at":0}}`},
|
||||
{"token grant change", full, "/api/verify", `{"scope":"access_token.update","method":"password","password":"enrollment-password","context":{"token_id":1,"scopes":["profile:read"]}}`},
|
||||
{"token revocation", full, "/api/verify", `{"scope":"access_token.revoke","method":"password","password":"enrollment-password","context":{"legacy":true}}`},
|
||||
} {
|
||||
method := http.MethodPost
|
||||
if request.body == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
response := accessTokenRequest(router, method, request.path, request.credential, "", request.body)
|
||||
assert.Equal(t, http.StatusForbidden, response.Code, request.name)
|
||||
assert.Equal(t, "SECURITY_ACTION_FORBIDDEN", decodeSecurityEnrollmentResponse(t, response).Code, request.name)
|
||||
}
|
||||
var proofs int64
|
||||
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Count(&proofs).Error)
|
||||
assert.Zero(t, proofs)
|
||||
})
|
||||
|
||||
t.Run("OAuth-only accounts have no method for a token", func(t *testing.T) {
|
||||
user, identity := setupSecurityEnrollmentTest(t)
|
||||
require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "github_id": "linked-user"}).Error)
|
||||
oauth.Register("enrollment-oauth", &enrollmentOAuthProvider{externalID: "linked-user"})
|
||||
t.Cleanup(func() { oauth.Unregister("enrollment-oauth") })
|
||||
raw, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write")
|
||||
router := newAccessTokenTestRouter()
|
||||
body := decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodGet, "/api/verify/methods?scope=2fa.setup", raw, "", ""))
|
||||
require.True(t, body.Success, body.Message)
|
||||
var requirements service.VerificationRequirements
|
||||
require.NoError(t, common.Unmarshal(body.Data, &requirements))
|
||||
require.Len(t, requirements.Methods, 1)
|
||||
assert.Equal(t, service.VerificationMethodOAuth, requirements.Methods[0].Method)
|
||||
assert.False(t, requirements.Methods[0].Available)
|
||||
assert.Empty(t, requirements.OAuthProviders)
|
||||
body = decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", `{"scope":"2fa.setup","method":"oauth"}`))
|
||||
assert.Equal(t, "SECURITY_METHOD_UNAVAILABLE", body.Code)
|
||||
// The same account still verifies through OAuth in a browser.
|
||||
browser, err := service.GetVerificationRequirements(identity, service.VerificationScopeTwoFASetup)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, browser.Methods[0].Available)
|
||||
})
|
||||
|
||||
t.Run("a proof stays with the token that obtained it", func(t *testing.T) {
|
||||
user, _ := setupSecurityEnrollmentTest(t)
|
||||
browser, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "browser")
|
||||
require.NoError(t, err)
|
||||
first, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write")
|
||||
second, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write")
|
||||
router := newAccessTokenTestRouter()
|
||||
body := decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/verify", first, "", `{"scope":"2fa.setup","method":"password","password":"enrollment-password"}`))
|
||||
require.True(t, body.Success, body.Message)
|
||||
var proof service.SecurityProof
|
||||
require.NoError(t, common.Unmarshal(body.Data, &proof))
|
||||
for _, credential := range []string{second, browser.AccessToken} {
|
||||
response := accessTokenRequest(router, http.MethodPost, "/api/user/2fa/setup", credential, proof.ProofToken, `{}`)
|
||||
assert.Equal(t, "SECURITY_PROOF_INVALID", decodeSecurityEnrollmentResponse(t, response).Code)
|
||||
}
|
||||
body = decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/user/2fa/setup", first, proof.ProofToken, `{}`))
|
||||
assert.True(t, body.Success, "rejected holders do not burn the proof: %s", body.Message)
|
||||
})
|
||||
|
||||
t.Run("revoking the token voids its proof", func(t *testing.T) {
|
||||
user, _ := setupSecurityEnrollmentTest(t)
|
||||
raw, token := createScopedAccessToken(t, user.Id, 0, "account_security:write")
|
||||
router := newAccessTokenTestRouter()
|
||||
body := decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", `{"scope":"2fa.setup","method":"password","password":"enrollment-password"}`))
|
||||
require.True(t, body.Success, body.Message)
|
||||
var proof service.SecurityProof
|
||||
require.NoError(t, common.Unmarshal(body.Data, &proof))
|
||||
_, err := model.DeleteUserAccessToken(user.Id, token.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, http.MethodPost, "/api/user/2fa/setup", raw, proof.ProofToken, `{}`).Code)
|
||||
// A request that authenticated just before the revocation cannot spend it either.
|
||||
identity := service.AuthIdentity{UserID: user.Id, SessionID: model.AccessTokenSessionID(token.Id), UserAuthVersion: user.AuthVersion, SessionVersion: model.AccessTokenSessionVersion}
|
||||
_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: service.VerificationScopeTwoFASetup})
|
||||
assert.Error(t, err)
|
||||
var consumed int64
|
||||
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ? AND consumed_at IS NOT NULL", model.AuthFlowPurposeSecurityProof).Count(&consumed).Error)
|
||||
assert.Zero(t, consumed)
|
||||
})
|
||||
}
|
||||
|
||||
func authorizeSecurityEnrollment(t *testing.T, identity service.AuthIdentity) *model.AuthFlowAuthorization {
|
||||
@@ -433,10 +646,11 @@ func TestSecurityEnrollmentRejectsMissingProofBeforeCreatingCredentials(t *testi
|
||||
}
|
||||
|
||||
type securityEnrollmentResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
MessageKey string `json:"message_key"`
|
||||
Code string `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func TestSecurityEnrollmentMethodPolicy(t *testing.T) {
|
||||
@@ -553,10 +767,34 @@ func TestSecurityEnrollmentOperationContext(t *testing.T) {
|
||||
{"array context", "passkey.register", `[]`, service.ErrVerificationContextInvalid},
|
||||
{"empty enrollment", "passkey.register", `{}`, nil},
|
||||
{"implicit enrollment", "passkey.register", ``, nil},
|
||||
{"generate access token", "access_token.generate", `{}`, nil},
|
||||
{"revoke access token", "access_token.revoke", ``, nil},
|
||||
{"generate access token", "access_token.generate", `{"scopes":["profile:read"],"expires_at":0}`, nil},
|
||||
{"generate access token without grant", "access_token.generate", `{}`, service.ErrVerificationContextInvalid},
|
||||
{"generate access token empty grant", "access_token.generate", `{"scopes":[],"expires_at":0}`, service.ErrVerificationContextInvalid},
|
||||
{"generate access token negative expiry", "access_token.generate", `{"scopes":["profile:read"],"expires_at":-1}`, service.ErrVerificationContextInvalid},
|
||||
{"update access token", "access_token.update", `{"token_id":3,"scopes":["profile:read"]}`, nil},
|
||||
{"update access token without target", "access_token.update", `{"scopes":["profile:read"]}`, service.ErrVerificationContextInvalid},
|
||||
{"update access token without grant", "access_token.update", `{"token_id":3}`, service.ErrVerificationContextInvalid},
|
||||
{"update access token empty grant", "access_token.update", `{"token_id":3,"scopes":[]}`, service.ErrVerificationContextInvalid},
|
||||
{"update access token target injection", "access_token.update", `{"token_id":3,"scopes":["profile:read"],"user_id":42}`, service.ErrVerificationContextInvalid},
|
||||
{"revoke access token", "access_token.revoke", `{"token_id":3}`, nil},
|
||||
{"revoke legacy access token", "access_token.revoke", `{"legacy":true}`, nil},
|
||||
{"revoke access token without target", "access_token.revoke", ``, service.ErrVerificationContextInvalid},
|
||||
{"revoke access token two targets", "access_token.revoke", `{"token_id":3,"legacy":true}`, service.ErrVerificationContextInvalid},
|
||||
{"access token target injection", "access_token.revoke", `{"user_id":42}`, service.ErrVerificationContextInvalid},
|
||||
{"enrollment target injection", "passkey.register", `{"user_id":42}`, service.ErrVerificationContextInvalid},
|
||||
{"admin user", "admin.user.delete", `{"user_id":7}`, nil},
|
||||
{"admin user zero", "admin.user.delete", `{"user_id":0}`, service.ErrVerificationContextInvalid},
|
||||
{"admin user extra field", "admin.user.update", `{"user_id":7,"password":"x"}`, service.ErrVerificationContextInvalid},
|
||||
{"admin manage", "admin.user.manage", `{"user_id":7,"action":"promote"}`, nil},
|
||||
{"admin manage quota", "admin.user.manage", `{"user_id":7,"action":"add_quota"}`, service.ErrVerificationContextInvalid},
|
||||
{"admin manage delete", "admin.user.manage", `{"user_id":7,"action":"delete"}`, service.ErrVerificationContextInvalid},
|
||||
{"admin binding type", "admin.user.binding.clear", `{"user_id":7,"binding_type":"github"}`, nil},
|
||||
{"admin binding provider", "admin.user.binding.clear", `{"user_id":7,"provider_id":3}`, nil},
|
||||
{"admin binding both", "admin.user.binding.clear", `{"user_id":7,"binding_type":"github","provider_id":3}`, service.ErrVerificationContextInvalid},
|
||||
{"admin binding neither", "admin.user.binding.clear", `{"user_id":7}`, service.ErrVerificationContextInvalid},
|
||||
{"admin create administrator", "admin.user.create", `{"role":10}`, nil},
|
||||
{"admin create common user", "admin.user.create", `{"role":1}`, service.ErrVerificationContextInvalid},
|
||||
{"admin create unknown role", "admin.user.create", `{"role":50}`, service.ErrVerificationContextInvalid},
|
||||
{"unknown scope", "user.email.change", `{}`, service.ErrProofScope},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -572,6 +810,11 @@ func TestSecurityEnrollmentOperationContext(t *testing.T) {
|
||||
secondBinding, err := service.BindVerificationOperation(reordered)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, firstBinding, secondBinding)
|
||||
sortedGrant, err := service.BindVerificationOperation(service.VerificationOperation{Scope: "access_token.generate", Context: []byte(`{"scopes":["profile:read","usage:read"],"expires_at":0}`)})
|
||||
require.NoError(t, err)
|
||||
unsortedGrant, err := service.BindVerificationOperation(service.VerificationOperation{Scope: "access_token.generate", Context: []byte(`{"expires_at":0,"scopes":["usage:read"," profile:read","usage:read"]}`)})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, sortedGrant, unsortedGrant, "a grant binds as a set")
|
||||
}
|
||||
|
||||
func TestSecurityEnrollmentChannelProofRejectsMismatchesBeforeConsumption(t *testing.T) {
|
||||
@@ -1168,7 +1411,7 @@ func TestSecurityEnrollmentTwoFAFailureAccountingAndStorageErrors(t *testing.T)
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
||||
assert.False(t, body.Success, endpoint.path)
|
||||
if endpoint.path == "/api/user/login/2fa" {
|
||||
assert.Equal(t, "参数错误", body.Message, endpoint.path)
|
||||
assert.Equal(t, "Invalid parameters", body.Message, endpoint.path)
|
||||
} else {
|
||||
assert.Equal(t, "SECURITY_PROOF_REQUIRED", body.Code, endpoint.path)
|
||||
}
|
||||
@@ -1535,23 +1778,21 @@ func TestSecurityEnrollmentMissingTargetsAreBusinessErrors(t *testing.T) {
|
||||
_, identity := setupSecurityEnrollmentTest(t)
|
||||
require.NoError(t, model.DB.AutoMigrate(&model.Channel{}))
|
||||
for _, target := range []struct {
|
||||
path, key string
|
||||
handler gin.HandlerFunc
|
||||
path, message string
|
||||
handler gin.HandlerFunc
|
||||
}{
|
||||
{"/api/channel/999/key", i18n.MsgChannelNotExists, GetChannelKey},
|
||||
{"/api/user/999/2fa", i18n.MsgUserNotExists, AdminDisable2FA},
|
||||
{"/api/channel/999/key", "Channel does not exist", GetChannelKey},
|
||||
{"/api/user/999/2fa", "User does not exist", AdminDisable2FA},
|
||||
} {
|
||||
var expectedMessage string
|
||||
response := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) {
|
||||
c.Params = gin.Params{{Key: "id", Value: "999"}}
|
||||
expectedMessage = i18n.T(c, target.key)
|
||||
target.handler(c)
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, response.Code)
|
||||
var body securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
||||
assert.False(t, body.Success)
|
||||
assert.Equal(t, expectedMessage, body.Message)
|
||||
assert.Equal(t, target.message, body.Message)
|
||||
assert.NotEqual(t, "AUTH_UNAUTHORIZED", body.Code)
|
||||
for _, id := range []string{"invalid", "0", "-1"} {
|
||||
invalid := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) {
|
||||
|
||||
Reference in new issue
Block a user