feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -17,6 +17,7 @@ import (
|
||||
"gorm.io/driver/clickhouse"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -37,9 +38,12 @@ func setupAccessTokenAudit(t *testing.T) (*model.User, string) {
|
||||
previousRedis := common.RedisEnabled
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.Log{}, &model.AuditLog{}, &model.CasbinRule{}, &model.AuthzRole{}))
|
||||
require.NoError(t, i18n.Init())
|
||||
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.Log{}, &model.AuditLog{}, &model.CasbinRule{}, &model.AuthzRole{}, &model.UserAccessToken{}, &model.Option{},
|
||||
&model.AuthFlow{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.UserOAuthBinding{}))
|
||||
model.DB, model.LOG_DB = db, db
|
||||
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
|
||||
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
|
||||
common.RedisEnabled = false
|
||||
previousMaster := common.IsMasterNode
|
||||
common.IsMasterNode = true
|
||||
@@ -66,56 +70,447 @@ func auditRequest(router http.Handler, method, path, token string) *httptest.Res
|
||||
return response
|
||||
}
|
||||
|
||||
func TestAccessTokenLifecycleAndLateRequests(t *testing.T) {
|
||||
user, old := setupAccessTokenAudit(t)
|
||||
// newAccessTokenTestRouter mirrors the production paths, so the access token
|
||||
// route rules keyed by gin full path apply unchanged.
|
||||
func newAccessTokenTestRouter() *gin.Engine {
|
||||
router := gin.New()
|
||||
router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
|
||||
router.GET("/api/user/token/status", middleware.UserAuth(), GetAccessTokenStatus)
|
||||
router.GET("/api/user/token", middleware.UserAuth(), GenerateAccessToken)
|
||||
router.POST("/api/user/token", middleware.UserAuth(), GenerateAccessToken)
|
||||
router.DELETE("/api/user/token", middleware.UserAuth(), RevokeAccessToken)
|
||||
// Rotate during the handler, after authentication has captured the old PAT.
|
||||
router.POST("/rotate-in-flight", middleware.UserAuth(), func(c *gin.Context) {
|
||||
require.NoError(t, model.UpdateUserAccessToken(user.Id, "new-token"))
|
||||
c.JSON(200, gin.H{"success": true})
|
||||
})
|
||||
status, err := model.GetUserAccessTokenStatus(user.Id)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, status.Exists)
|
||||
assert.Nil(t, status.CreatedAt)
|
||||
assert.Nil(t, status.LastUsedAt)
|
||||
assert.NotContains(t, auditRequest(router, "GET", "/api/user/token/status", old).Body.String(), old)
|
||||
require.Equal(t, 200, auditRequest(router, "POST", "/rotate-in-flight", old).Code)
|
||||
status, err = model.GetUserAccessTokenStatus(user.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.AccessTokenFingerprint("new-token"), status.TokenRef)
|
||||
assert.NotNil(t, status.CreatedAt)
|
||||
assert.Nil(t, status.LastUsedAt, "in-flight old requests must not mark the new generation as used")
|
||||
assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", old).Code)
|
||||
for _, method := range []string{"POST", "GET", "DELETE"} {
|
||||
response := auditRequest(router, method, "/api/user/token", "new-token")
|
||||
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_INVALID"`)
|
||||
stored, err := model.GetUserById(user.Id, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new-token", stored.GetAccessToken(), "a PAT cannot manage itself without a dashboard verification")
|
||||
api := router.Group("/api")
|
||||
api.GET("/verify/methods", middleware.UserAuth(), GetVerificationMethods)
|
||||
api.POST("/verify", middleware.UserAuth(), UniversalVerify)
|
||||
userRoute := api.Group("/user")
|
||||
selfRoute := userRoute.Group("/", middleware.UserAuth())
|
||||
selfRoute.GET("/self", func(c *gin.Context) { common.ApiSuccess(c, gin.H{"id": c.GetInt("id")}) })
|
||||
selfRoute.POST("/2fa/setup", Setup2FA)
|
||||
selfRoute.POST("/2fa/disable", Disable2FA)
|
||||
tokenRoute := selfRoute.Group("/access_tokens")
|
||||
tokenRoute.GET("", ListAccessTokens)
|
||||
tokenRoute.GET("/catalog", GetAccessTokenCatalog)
|
||||
tokenRoute.GET("/scopes", GetAccessTokenScopes)
|
||||
tokenRoute.POST("", CreateAccessToken)
|
||||
tokenRoute.PATCH("/:id", UpdateAccessToken)
|
||||
tokenRoute.DELETE("/:id", DeleteAccessToken)
|
||||
tokenRoute.DELETE("/legacy", RevokeLegacyAccessToken)
|
||||
adminRoute := userRoute.Group("/", middleware.AdminAuth())
|
||||
adminRoute.DELETE("/:id", DeleteUser)
|
||||
return router
|
||||
}
|
||||
|
||||
func accessTokenRequest(router http.Handler, method, path, credential, proof, body string) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+credential)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if proof != "" {
|
||||
request.Header.Set("X-Security-Proof", proof)
|
||||
}
|
||||
_, err = model.RevokeUserAccessToken(user.Id)
|
||||
request.RemoteAddr = "192.0.2.8:4567"
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func createScopedAccessToken(t *testing.T, userID int, expiresAt int64, scopes ...string) (string, *model.UserAccessToken) {
|
||||
t.Helper()
|
||||
suffix, err := common.GenerateRandomCharsKey(43)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", "new-token").Code)
|
||||
ref, err := model.RevokeUserAccessToken(user.Id)
|
||||
raw := model.AccessTokenPrefix + suffix
|
||||
token := &model.UserAccessToken{Name: "test token", TokenHash: model.AccessTokenFingerprint(raw), TokenHint: model.AccessTokenHint(raw), ExpiresAt: expiresAt}
|
||||
require.NoError(t, token.SetScopes(scopes))
|
||||
require.NoError(t, model.CreateUserAccessToken(userID, token, service.AccessTokenMaxPerUser))
|
||||
return raw, token
|
||||
}
|
||||
|
||||
// createAccessTokenTestSession returns a browser session identity and the
|
||||
// dashboard JWT that carries it.
|
||||
func createAccessTokenTestSession(t *testing.T, userID int, sid string) (service.AuthIdentity, string) {
|
||||
t.Helper()
|
||||
now := time.Now().Unix()
|
||||
require.NoError(t, model.CreateUserSession(&model.UserSession{SID: sid, UserID: userID, Version: 1, UserAuthVersion: 1, Status: model.UserSessionStatusActive, RefreshHash: "refresh-" + sid, LoginMethod: "password", LastActiveAt: now, ExpiresAt: now + 3600}))
|
||||
identity := service.AuthIdentity{UserID: userID, SessionID: sid, UserAuthVersion: 1, SessionVersion: 1}
|
||||
jwt, _, err := service.IssueAccessToken(identity)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, ref, "repeated revocation is idempotent")
|
||||
status, err = model.GetUserAccessTokenStatus(user.Id)
|
||||
return identity, jwt
|
||||
}
|
||||
|
||||
func issueAccessTokenGenerateProof(t *testing.T, identity service.AuthIdentity, expiresAt int64, scopes ...string) string {
|
||||
t.Helper()
|
||||
context, err := common.Marshal(service.AccessTokenGenerateContext{Scopes: scopes, ExpiresAt: expiresAt})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, status.Exists)
|
||||
assert.Nil(t, status.CreatedAt)
|
||||
return issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenGenerate, Context: context}, "password")
|
||||
}
|
||||
|
||||
func issueAccessTokenRevokeProof(t *testing.T, identity service.AuthIdentity, target service.AccessTokenRevokeContext) string {
|
||||
t.Helper()
|
||||
context, err := common.Marshal(target)
|
||||
require.NoError(t, err)
|
||||
return issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: context}, "password")
|
||||
}
|
||||
|
||||
func issueAccessTokenUpdateProof(t *testing.T, identity service.AuthIdentity, tokenID int, scopes ...string) string {
|
||||
t.Helper()
|
||||
context, err := common.Marshal(service.AccessTokenUpdateContext{TokenID: tokenID, Scopes: scopes})
|
||||
require.NoError(t, err)
|
||||
return issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenUpdate, Context: context}, "password")
|
||||
}
|
||||
|
||||
// setLegacyAccessTokenRetireAt moves the stored transition deadline the way an
|
||||
// operator restoring the option row would, then reloads it.
|
||||
func setLegacyAccessTokenRetireAt(t *testing.T, retireAt int64) {
|
||||
t.Helper()
|
||||
require.NoError(t, model.DB.Model(&model.Option{}).Where(&model.Option{Key: "LegacyAccessTokenRetireAt"}).Update("value", fmt.Sprint(retireAt)).Error)
|
||||
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
|
||||
require.Equal(t, retireAt, model.LegacyAccessTokenRetireAt())
|
||||
}
|
||||
|
||||
type accessTokenListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TokenRef string `json:"token_ref"`
|
||||
Scopes []string `json:"scopes"`
|
||||
} `json:"items"`
|
||||
Legacy *struct {
|
||||
TokenRef string `json:"token_ref"`
|
||||
RetireAt int64 `json:"retire_at"`
|
||||
} `json:"legacy"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func TestAccessTokenLifecycleAndLateRequests(t *testing.T) {
|
||||
user, legacy := setupAccessTokenAudit(t)
|
||||
router := newAccessTokenTestRouter()
|
||||
identity, browser := createAccessTokenTestSession(t, user.Id, "lifecycle-session")
|
||||
scoped, scopedToken := createScopedAccessToken(t, user.Id, 0, "profile:read")
|
||||
tokenPath := fmt.Sprintf("/api/user/access_tokens/%d", scopedToken.Id)
|
||||
for _, credential := range []string{scoped, legacy} {
|
||||
for _, endpoint := range []struct{ method, path string }{
|
||||
{"GET", "/api/user/access_tokens"}, {"GET", "/api/user/access_tokens/catalog"}, {"GET", "/api/user/access_tokens/scopes"}, {"POST", "/api/user/access_tokens"},
|
||||
{"PATCH", tokenPath}, {"DELETE", tokenPath}, {"DELETE", "/api/user/access_tokens/legacy"},
|
||||
} {
|
||||
response := accessTokenRequest(router, endpoint.method, endpoint.path, credential, "", `{"name":"renamed","scopes":["profile:read"],"expires_at":0}`)
|
||||
assert.Equal(t, http.StatusForbidden, response.Code, endpoint.method+" "+endpoint.path)
|
||||
assert.Contains(t, response.Body.String(), `"code":"AUTH_SESSION_REQUIRED"`)
|
||||
}
|
||||
}
|
||||
tokens, err := model.ListUserAccessTokens(user.Id)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tokens, 1)
|
||||
assert.Equal(t, "test token", tokens[0].Name, "an access token cannot manage access tokens")
|
||||
|
||||
response := accessTokenRequest(router, "GET", "/api/user/access_tokens", browser, "", "")
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
for _, secret := range []string{scoped, legacy} {
|
||||
assert.NotContains(t, response.Body.String(), secret)
|
||||
}
|
||||
var listed accessTokenListResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &listed))
|
||||
require.Len(t, listed.Data.Items, 1)
|
||||
assert.Equal(t, scopedToken.TokenHash, listed.Data.Items[0].TokenRef)
|
||||
assert.Equal(t, []string{"profile:read"}, listed.Data.Items[0].Scopes)
|
||||
require.NotNil(t, listed.Data.Legacy)
|
||||
assert.Equal(t, model.AccessTokenFingerprint(legacy), listed.Data.Legacy.TokenRef)
|
||||
assert.Equal(t, model.LegacyAccessTokenRetireAt(), listed.Data.Legacy.RetireAt)
|
||||
|
||||
// A token id only resolves among the caller's own tokens.
|
||||
other := &model.User{Username: "audit-other", Password: "placeholder", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "audit-other"}
|
||||
require.NoError(t, model.DB.Create(other).Error)
|
||||
otherRaw, otherToken := createScopedAccessToken(t, other.Id, 0, "profile:read")
|
||||
otherProof := issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{TokenID: otherToken.Id})
|
||||
response = accessTokenRequest(router, "DELETE", fmt.Sprintf("/api/user/access_tokens/%d", otherToken.Id), browser, otherProof, "")
|
||||
assert.Equal(t, http.StatusNotFound, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_NOT_FOUND"`)
|
||||
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", otherRaw, "", "").Code)
|
||||
|
||||
// A revoke proof names one target and is not spent on another one.
|
||||
proof := issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{TokenID: scopedToken.Id})
|
||||
response = accessTokenRequest(router, "DELETE", "/api/user/access_tokens/legacy", browser, proof, "")
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONTEXT_MISMATCH"`)
|
||||
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "").Code)
|
||||
response = accessTokenRequest(router, "DELETE", tokenPath, browser, proof, "")
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, "GET", "/api/user/self", scoped, "", "").Code)
|
||||
response = accessTokenRequest(router, "DELETE", tokenPath, browser, proof, "")
|
||||
assert.Equal(t, http.StatusNotFound, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_NOT_FOUND"`)
|
||||
|
||||
legacyProof := issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{Legacy: true})
|
||||
response = accessTokenRequest(router, "DELETE", "/api/user/access_tokens/legacy", browser, legacyProof, "")
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "").Code)
|
||||
response = accessTokenRequest(router, "GET", "/api/user/access_tokens", browser, "", "")
|
||||
listed = accessTokenListResponse{}
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &listed))
|
||||
assert.Empty(t, listed.Data.Items)
|
||||
assert.Nil(t, listed.Data.Legacy)
|
||||
|
||||
// After the deadline a stored legacy value is inert: it is neither listed,
|
||||
// revocable nor accepted, and the column itself is left untouched.
|
||||
require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("access_token", legacy).Error)
|
||||
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "").Code)
|
||||
retireAt := model.LegacyAccessTokenRetireAt()
|
||||
setLegacyAccessTokenRetireAt(t, time.Now().Unix()-1)
|
||||
t.Cleanup(func() { setLegacyAccessTokenRetireAt(t, retireAt) })
|
||||
response = accessTokenRequest(router, "GET", "/api/user/access_tokens", browser, "", "")
|
||||
listed = accessTokenListResponse{}
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &listed))
|
||||
assert.Nil(t, listed.Data.Legacy)
|
||||
legacyProof = issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{Legacy: true})
|
||||
response = accessTokenRequest(router, "DELETE", "/api/user/access_tokens/legacy", browser, legacyProof, "")
|
||||
assert.Equal(t, http.StatusNotFound, response.Code)
|
||||
response = accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "")
|
||||
assert.Equal(t, http.StatusUnauthorized, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_LEGACY_RETIRED"`)
|
||||
stored, err := model.GetUserById(user.Id, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, legacy, stored.GetAccessToken())
|
||||
|
||||
var revoked []model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Where("action = ?", "access_token.revoke").Order("id").Find(&revoked).Error)
|
||||
require.Len(t, revoked, 2)
|
||||
for i, want := range []string{
|
||||
fmt.Sprintf(`"token_ref":"%s"`, scopedToken.TokenHash),
|
||||
fmt.Sprintf(`"legacy":true,"token_ref":"%s"`, model.AccessTokenFingerprint(legacy)),
|
||||
} {
|
||||
require.NotNil(t, revoked[i].Other.Op)
|
||||
params, err := common.Marshal(revoked[i].Other.Op.Params)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(params), want)
|
||||
}
|
||||
var history []model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Find(&history).Error)
|
||||
require.NotEmpty(t, history)
|
||||
encoded, err := common.Marshal(history)
|
||||
require.NoError(t, err)
|
||||
for _, secret := range []string{old, "new-token", "body-must-not-be-logged", "Authorization"} {
|
||||
for _, secret := range []string{scoped, legacy, browser, proof, legacyProof, otherRaw, otherProof, "Authorization"} {
|
||||
assert.NotContains(t, string(encoded), secret)
|
||||
}
|
||||
}
|
||||
|
||||
// The scope dictionary labels stored grants, so it must not shrink to what the
|
||||
// viewer can grant today.
|
||||
func TestAccessTokenScopeDictionaryIgnoresTheViewersGrants(t *testing.T) {
|
||||
user, _ := setupAccessTokenAudit(t)
|
||||
require.NoError(t, model.DB.Model(user).Update("role", common.RoleCommonUser).Error)
|
||||
router := newAccessTokenTestRouter()
|
||||
_, browser := createAccessTokenTestSession(t, user.Id, "dictionary-session")
|
||||
response := accessTokenRequest(router, "GET", "/api/user/access_tokens/scopes", browser, "", "")
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
var dictionary struct {
|
||||
Data struct {
|
||||
Resources []struct {
|
||||
Resource string `json:"resource"`
|
||||
LabelKey string `json:"label_key"`
|
||||
Actions []struct {
|
||||
Action string `json:"action"`
|
||||
LabelKey string `json:"label_key"`
|
||||
} `json:"actions"`
|
||||
} `json:"resources"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &dictionary))
|
||||
labels := map[string]string{}
|
||||
for _, resource := range dictionary.Data.Resources {
|
||||
for _, action := range resource.Actions {
|
||||
labels[resource.Resource+":"+action.Action] = resource.LabelKey + " / " + action.LabelKey
|
||||
}
|
||||
}
|
||||
assert.Equal(t, "Profile / View", labels["profile:read"])
|
||||
assert.Equal(t, "API keys / Reveal full keys", labels["api_key:reveal"])
|
||||
assert.Equal(t, "Users / Edit", labels["user:write"])
|
||||
assert.Equal(t, "System settings / Edit", labels["option:write"])
|
||||
for _, permission := range authz.AllPermissions() {
|
||||
assert.Contains(t, labels, service.AccessTokenScopeOf(permission))
|
||||
}
|
||||
|
||||
response = accessTokenRequest(router, "GET", "/api/user/access_tokens/catalog", browser, "", "")
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
assert.NotContains(t, response.Body.String(), `"resource":"user"`, "the grant catalog stays filtered")
|
||||
}
|
||||
|
||||
func TestAccessTokenCreationChecksGrantBeforeConsumingProof(t *testing.T) {
|
||||
user, legacy := setupAccessTokenAudit(t)
|
||||
router := newAccessTokenTestRouter()
|
||||
identity, browser := createAccessTokenTestSession(t, user.Id, "create-session")
|
||||
response := accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, "", `{"name":"ci","scopes":["profile:read"],"expires_at":0}`)
|
||||
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_REQUIRED"`)
|
||||
expiresAt := time.Now().Unix() + 7*24*60*60
|
||||
for _, test := range []struct {
|
||||
name, body, code string
|
||||
role, status int
|
||||
proofScopes []string
|
||||
proofExpiry int64
|
||||
}{
|
||||
{"common user cannot grant user management", `{"name":"ci","scopes":["user:write"],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_FORBIDDEN", common.RoleCommonUser, http.StatusBadRequest, []string{"user:write"}, 0},
|
||||
{"administrator cannot grant a permission they lack", `{"name":"ci","scopes":["channel:sensitive_write"],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_FORBIDDEN", common.RoleAdminUser, http.StatusBadRequest, []string{"channel:sensitive_write"}, 0},
|
||||
{"unknown scope", `{"name":"ci","scopes":["profile:admin"],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:admin"}, 0},
|
||||
{"empty grant", `{"name":"ci","scopes":[],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:read"}, 0},
|
||||
{"expiry under one hour", fmt.Sprintf(`{"name":"ci","scopes":["profile:read"],"expires_at":%d}`, time.Now().Unix()+60), "ACCESS_TOKEN_EXPIRY_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:read"}, time.Now().Unix() + 60},
|
||||
{"blank name", `{"name":" ","scopes":["profile:read"],"expires_at":0}`, "ACCESS_TOKEN_NAME_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:read"}, 0},
|
||||
{"proof for other scopes", `{"name":"ci","scopes":["profile:read","usage:read"],"expires_at":0}`, "SECURITY_PROOF_CONTEXT_MISMATCH", common.RoleAdminUser, http.StatusForbidden, []string{"profile:read"}, 0},
|
||||
{"proof for other expiry", fmt.Sprintf(`{"name":"ci","scopes":["profile:read"],"expires_at":%d}`, expiresAt), "SECURITY_PROOF_CONTEXT_MISMATCH", common.RoleAdminUser, http.StatusForbidden, []string{"profile:read"}, 0},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
require.NoError(t, model.DB.Model(user).Update("role", test.role).Error)
|
||||
proof := issueAccessTokenGenerateProof(t, identity, test.proofExpiry, test.proofScopes...)
|
||||
response := accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, proof, test.body)
|
||||
assert.Equal(t, test.status, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"`+test.code+`"`)
|
||||
var consumed int64
|
||||
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("consumed_at IS NOT NULL").Count(&consumed).Error)
|
||||
assert.Zero(t, consumed, "a rejected grant must not spend the verification")
|
||||
})
|
||||
}
|
||||
tokens, err := model.ListUserAccessTokens(user.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, tokens)
|
||||
|
||||
require.NoError(t, model.DB.Model(user).Update("role", common.RoleAdminUser).Error)
|
||||
proof := issueAccessTokenGenerateProof(t, identity, 0, "usage:read", "profile:read")
|
||||
response = accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, proof, `{"name":" ci ","scopes":["profile:read","usage:read","profile:read"],"expires_at":0}`)
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
var created struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
Item struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TokenRef string `json:"token_ref"`
|
||||
TokenHint string `json:"token_hint"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Scopes []string `json:"scopes"`
|
||||
} `json:"item"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &created))
|
||||
raw := created.Data.Token
|
||||
require.True(t, strings.HasPrefix(raw, model.AccessTokenPrefix))
|
||||
assert.Len(t, raw, len(model.AccessTokenPrefix)+43)
|
||||
assert.Equal(t, "ci", created.Data.Item.Name)
|
||||
assert.Equal(t, []string{"profile:read", "usage:read"}, created.Data.Item.Scopes)
|
||||
assert.Zero(t, created.Data.Item.ExpiresAt, "expires_at 0 creates a token that never expires")
|
||||
assert.Equal(t, model.AccessTokenFingerprint(raw), created.Data.Item.TokenRef)
|
||||
assert.Equal(t, raw[len(raw)-4:], created.Data.Item.TokenHint)
|
||||
var columns []map[string]any
|
||||
require.NoError(t, model.DB.Table("user_access_tokens").Find(&columns).Error)
|
||||
require.Len(t, columns, 1)
|
||||
encodedRow, err := common.Marshal(columns[0])
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(encodedRow), raw, "the database stores only the token hash")
|
||||
response = accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, proof, `{"name":"ci","scopes":["profile:read","usage:read"],"expires_at":0}`)
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
|
||||
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", raw, "", "").Code)
|
||||
stored, err := model.GetUserById(user.Id, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, legacy, stored.GetAccessToken(), "creating a token never replaces the legacy token")
|
||||
|
||||
var generated model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Where("action = ?", "access_token.generate").First(&generated).Error)
|
||||
require.NotNil(t, generated.Other.Op)
|
||||
params, err := common.Marshal(generated.Other.Op.Params)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(params), fmt.Sprintf(`"token_ref":"%s"`, created.Data.Item.TokenRef))
|
||||
assert.Contains(t, string(params), `"scopes":["profile:read","usage:read"]`)
|
||||
var history []model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Find(&history).Error)
|
||||
encoded, err := common.Marshal(history)
|
||||
require.NoError(t, err)
|
||||
for _, secret := range []string{raw, browser, proof} {
|
||||
assert.NotContains(t, string(encoded), secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessTokenUpdateRenamesFreelyAndChangesGrantOnlyWithProof(t *testing.T) {
|
||||
user, _ := setupAccessTokenAudit(t)
|
||||
router := newAccessTokenTestRouter()
|
||||
identity, browser := createAccessTokenTestSession(t, user.Id, "update-session")
|
||||
raw, token := createScopedAccessToken(t, user.Id, 0, "profile:read")
|
||||
tokenPath := fmt.Sprintf("/api/user/access_tokens/%d", token.Id)
|
||||
other := &model.User{Username: "audit-other", Password: "placeholder", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "audit-other"}
|
||||
require.NoError(t, model.DB.Create(other).Error)
|
||||
_, otherToken := createScopedAccessToken(t, other.Id, 0, "profile:read")
|
||||
|
||||
response := accessTokenRequest(router, "PATCH", tokenPath, browser, "", `{"name":" renamed "}`)
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
stored, err := model.GetUserAccessToken(user.Id, token.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", stored.Name)
|
||||
assert.Equal(t, []string{"profile:read"}, stored.GetScopes(), "a rename keeps the grant")
|
||||
|
||||
response = accessTokenRequest(router, "PATCH", tokenPath, browser, "", `{"name":"ci","scopes":["usage:read"]}`)
|
||||
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_REQUIRED"`)
|
||||
|
||||
generateProof := issueAccessTokenGenerateProof(t, identity, 0, "usage:read")
|
||||
for _, test := range []struct {
|
||||
name, path, body, proof, code string
|
||||
status int
|
||||
}{
|
||||
{"permission the owner lacks", tokenPath, `{"name":"ci","scopes":["channel:sensitive_write"]}`, issueAccessTokenUpdateProof(t, identity, token.Id, "channel:sensitive_write"), "ACCESS_TOKEN_SCOPE_FORBIDDEN", http.StatusBadRequest},
|
||||
{"empty grant", tokenPath, `{"name":"ci","scopes":[]}`, issueAccessTokenUpdateProof(t, identity, token.Id, "profile:read"), "ACCESS_TOKEN_SCOPE_INVALID", http.StatusBadRequest},
|
||||
{"another user's token", fmt.Sprintf("/api/user/access_tokens/%d", otherToken.Id), `{"name":"ci","scopes":["usage:read"]}`, issueAccessTokenUpdateProof(t, identity, otherToken.Id, "usage:read"), "ACCESS_TOKEN_NOT_FOUND", http.StatusNotFound},
|
||||
{"proof for another token", tokenPath, `{"name":"ci","scopes":["usage:read"]}`, issueAccessTokenUpdateProof(t, identity, otherToken.Id, "usage:read"), "SECURITY_PROOF_CONTEXT_MISMATCH", http.StatusForbidden},
|
||||
{"proof for another grant", tokenPath, `{"name":"ci","scopes":["usage:read"]}`, issueAccessTokenUpdateProof(t, identity, token.Id, "profile:read"), "SECURITY_PROOF_CONTEXT_MISMATCH", http.StatusForbidden},
|
||||
{"creation proof", tokenPath, `{"name":"ci","scopes":["usage:read"]}`, generateProof, "SECURITY_PROOF_SCOPE_MISMATCH", http.StatusForbidden},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
response := accessTokenRequest(router, "PATCH", test.path, browser, test.proof, test.body)
|
||||
assert.Equal(t, test.status, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"`+test.code+`"`)
|
||||
var consumed int64
|
||||
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("consumed_at IS NOT NULL").Count(&consumed).Error)
|
||||
assert.Zero(t, consumed, "a rejected change must not spend the verification")
|
||||
})
|
||||
}
|
||||
stored, err = model.GetUserAccessToken(user.Id, token.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", stored.Name, "a rejected grant change does not apply the rename either")
|
||||
assert.Equal(t, []string{"profile:read"}, stored.GetScopes())
|
||||
|
||||
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", raw, "", "").Code)
|
||||
// The proof binds the grant as a set, so its order and repeats do not matter.
|
||||
widenProof := issueAccessTokenUpdateProof(t, identity, token.Id, "usage:read", "profile:read", "profile:read")
|
||||
response = accessTokenRequest(router, "PATCH", tokenPath, browser, widenProof, `{"name":"ci","scopes":["profile:read","usage:read"]}`)
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
stored, err = model.GetUserAccessToken(user.Id, token.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"profile:read", "usage:read"}, stored.GetScopes())
|
||||
|
||||
proof := issueAccessTokenUpdateProof(t, identity, token.Id, "usage:read")
|
||||
response = accessTokenRequest(router, "PATCH", tokenPath, browser, proof, `{"name":"ci","scopes":["usage:read"," usage:read"]}`)
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
var updated struct {
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &updated))
|
||||
assert.Equal(t, "ci", updated.Data.Name)
|
||||
assert.Equal(t, []string{"usage:read"}, updated.Data.Scopes)
|
||||
response = accessTokenRequest(router, "GET", "/api/user/self", raw, "", "")
|
||||
assert.Equal(t, http.StatusForbidden, response.Code, "a narrowed grant applies to the next request")
|
||||
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_SCOPE_DENIED"`)
|
||||
response = accessTokenRequest(router, "PATCH", tokenPath, browser, proof, `{"name":"ci","scopes":["usage:read"]}`)
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
|
||||
|
||||
var audits []model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Where("action IN ?", []string{"access_token.rename", "access_token.update"}).Order("id").Find(&audits).Error)
|
||||
require.Len(t, audits, 3)
|
||||
assert.Equal(t, "access_token.rename", audits[0].Action)
|
||||
assert.Equal(t, "access_token.update", audits[1].Action)
|
||||
assert.Equal(t, "access_token.update", audits[2].Action)
|
||||
require.NotNil(t, audits[2].Other.Op)
|
||||
params, err := common.Marshal(audits[2].Other.Op.Params)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(params), `"previous_scopes":["profile:read","usage:read"]`)
|
||||
assert.Contains(t, string(params), `"scopes":["usage:read"]`)
|
||||
assert.Contains(t, string(params), fmt.Sprintf(`"token_ref":"%s"`, token.TokenHash))
|
||||
encoded, err := common.Marshal(audits)
|
||||
require.NoError(t, err)
|
||||
for _, secret := range []string{raw, browser, widenProof, proof, generateProof} {
|
||||
assert.NotContains(t, string(encoded), secret)
|
||||
}
|
||||
}
|
||||
@@ -226,7 +621,6 @@ func TestAuditIsolationVisibilityAndFailureContracts(t *testing.T) {
|
||||
require.Error(t, err, "audit query failure must not look like never used")
|
||||
model.LOG_DB.Callback().Query().Remove("audit:fail")
|
||||
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("audit:write-fail", func(tx *gorm.DB) { tx.AddError(errors.New("write unavailable")) }))
|
||||
require.Error(t, model.UpdateUserAccessToken(user.Id, "replacement"))
|
||||
_, err = model.RevokeUserAccessToken(user.Id)
|
||||
require.Error(t, err)
|
||||
model.DB.Callback().Update().Remove("audit:write-fail")
|
||||
@@ -347,7 +741,7 @@ func TestSecurityAndOperationEventsUseAuditTable(t *testing.T) {
|
||||
recordManageAudit(c, "option.update", map[string]any{"key": "safe"})
|
||||
recordSubscriptionResetUserLogs(c, &model.SubscriptionResetResult{ResetCount: 1, PlanId: 1, PlanTitle: "Plan", AffectedUserIds: []int{user.Id}}, &model.AuditAdminInfo{AdminID: user.Id})
|
||||
for _, typ := range []int{model.LogTypeTopup, model.LogTypeConsume, model.LogTypeRefund, model.LogTypeSystem} {
|
||||
model.RecordLog(user.Id, typ, "business entry")
|
||||
model.RecordLog(user.Id, typ, common.NewMessage("business entry"))
|
||||
}
|
||||
var audits []model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Find(&audits).Error)
|
||||
@@ -703,8 +1097,21 @@ func TestAuditDatabaseMatrix(t *testing.T) {
|
||||
require.NoError(t, db.First(&old).Error)
|
||||
assert.Equal(t, "historical login", old.Content)
|
||||
}
|
||||
require.NoError(t, model.UpdateUserAccessToken(1, "matrix-token"))
|
||||
require.NoError(t, db.Model(&model.User{}).Where("id = ?", 1).Update("access_token", "matrix-token").Error)
|
||||
require.Error(t, db.Create(&model.User{Username: "duplicate", Password: "placeholder", AffCode: "duplicate-aff", AccessToken: common.GetPointer("matrix-token")}).Error, "PAT uniqueness must survive upgrade")
|
||||
scoped, scopedToken := createScopedAccessToken(t, 1, 0, "profile:read")
|
||||
found, err := model.FindUserAccessTokenByHash(model.AccessTokenFingerprint(scoped))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, scopedToken.Id, found.Id)
|
||||
assert.Equal(t, []string{"profile:read"}, found.GetScopes())
|
||||
require.Error(t, db.Create(&model.UserAccessToken{UserId: 1, Name: "duplicate", TokenHash: scopedToken.TokenHash, TokenHint: "dupe", Scopes: found.Scopes}).Error, "token hashes stay unique")
|
||||
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "owner", CreatedAt: 99, Category: model.AuditCategoryAccessToken, TokenRef: scopedToken.TokenHash, Ip: "192.0.2.1", Success: true})
|
||||
byRef, _, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1, TokenRef: scopedToken.TokenHash}, 0, 10, common.RoleCommonUser)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byRef, 1, "audit rows of a scoped token are found by its token_ref")
|
||||
_, err = model.DeleteUserAccessToken(1, scopedToken.Id)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, model.LOG_DB.Where("token_ref = ?", scopedToken.TokenHash).Delete(&model.AuditLog{}).Error)
|
||||
for _, timestamp := range []int64{101, 102, 103} {
|
||||
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "owner", CreatedAt: timestamp, Category: model.AuditCategoryAccessToken, TokenRef: model.AccessTokenFingerprint("matrix-token"), Ip: "192.0.2.1", Success: timestamp != 102})
|
||||
}
|
||||
@@ -788,7 +1195,7 @@ func TestIndependentAuditLogStores(t *testing.T) {
|
||||
require.NoError(t, model.LOG_DB.Where("request_id = ?", "legacy-split-request").Take(&old).Error)
|
||||
assert.Equal(t, "retained historical login", old.Content)
|
||||
}
|
||||
require.NoError(t, model.UpdateUserAccessToken(1, "independent-pat"))
|
||||
require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", 1).Update("access_token", "independent-pat").Error)
|
||||
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "independent", Category: model.AuditCategoryAccessToken, TokenRef: model.AccessTokenFingerprint("independent-pat"), Ip: "192.0.2.8", Success: false, Status: 403})
|
||||
entries, total, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 20, common.RoleAdminUser)
|
||||
require.NoError(t, err)
|
||||
@@ -799,7 +1206,7 @@ func TestIndependentAuditLogStores(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, status.LastUsedAt)
|
||||
assert.Equal(t, "192.0.2.8", status.LastUsedIp)
|
||||
model.RecordLog(1, model.LogTypeTopup, "independent business")
|
||||
model.RecordLog(1, model.LogTypeTopup, common.NewMessage("independent business"))
|
||||
_, err = model.DeleteOldLogBatch(context.Background(), time.Now().Unix()+1, 100)
|
||||
require.NoError(t, err)
|
||||
_, total, err = model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 20, 1)
|
||||
|
||||
Reference in new issue
Block a user