aboutsummaryrefslogtreecommitdiffstats
path: root/models/auth
diff options
context:
space:
mode:
authorChongyi Zheng <harryzheng25@gmail.com>2023-01-17 16:46:03 -0500
committerGitHub <noreply@github.com>2023-01-17 15:46:03 -0600
commitde484e86bc495a67d2f122ed438178d587a92526 (patch)
tree82ebe623a517a31006699a21613c0307020417b0 /models/auth
parentdb2286bbb69f5453f5b184a16a9dca999f3f3eb8 (diff)
downloadgitea-de484e86bc495a67d2f122ed438178d587a92526.tar.gz
gitea-de484e86bc495a67d2f122ed438178d587a92526.zip
Support scoped access tokens (#20908)
This PR adds the support for scopes of access tokens, mimicking the design of GitHub OAuth scopes. The changes of the core logic are in `models/auth` that `AccessToken` struct will have a `Scope` field. The normalized (no duplication of scope), comma-separated scope string will be stored in `access_token` table in the database. In `services/auth`, the scope will be stored in context, which will be used by `reqToken` middleware in API calls. Only OAuth2 tokens will have granular token scopes, while others like BasicAuth will default to scope `all`. A large amount of work happens in `routers/api/v1/api.go` and the corresponding `tests/integration` tests, that is adding necessary scopes to each of the API calls as they fit. - [x] Add `Scope` field to `AccessToken` - [x] Add access control to all API endpoints - [x] Update frontend & backend for when creating tokens - [x] Add a database migration for `scope` column (enable 'all' access to past tokens) I'm aiming to complete it before Gitea 1.19 release. Fixes #4300
Diffstat (limited to 'models/auth')
-rw-r--r--models/auth/token.go1
-rw-r--r--models/auth/token_scope.go251
-rw-r--r--models/auth/token_scope_test.go84
3 files changed, 336 insertions, 0 deletions
diff --git a/models/auth/token.go b/models/auth/token.go
index 0dfcb7629b..3f9f117f73 100644
--- a/models/auth/token.go
+++ b/models/auth/token.go
@@ -65,6 +65,7 @@ type AccessToken struct {
TokenHash string `xorm:"UNIQUE"` // sha256 of token
TokenSalt string
TokenLastEight string `xorm:"INDEX token_last_eight"`
+ Scope AccessTokenScope
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
diff --git a/models/auth/token_scope.go b/models/auth/token_scope.go
new file mode 100644
index 0000000000..c61c306496
--- /dev/null
+++ b/models/auth/token_scope.go
@@ -0,0 +1,251 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "fmt"
+ "strings"
+)
+
+// AccessTokenScope represents the scope for an access token.
+type AccessTokenScope string
+
+const (
+ AccessTokenScopeAll AccessTokenScope = "all"
+
+ AccessTokenScopeRepo AccessTokenScope = "repo"
+ AccessTokenScopeRepoStatus AccessTokenScope = "repo:status"
+ AccessTokenScopePublicRepo AccessTokenScope = "public_repo"
+
+ AccessTokenScopeAdminOrg AccessTokenScope = "admin:org"
+ AccessTokenScopeWriteOrg AccessTokenScope = "write:org"
+ AccessTokenScopeReadOrg AccessTokenScope = "read:org"
+
+ AccessTokenScopeAdminPublicKey AccessTokenScope = "admin:public_key"
+ AccessTokenScopeWritePublicKey AccessTokenScope = "write:public_key"
+ AccessTokenScopeReadPublicKey AccessTokenScope = "read:public_key"
+
+ AccessTokenScopeAdminRepoHook AccessTokenScope = "admin:repo_hook"
+ AccessTokenScopeWriteRepoHook AccessTokenScope = "write:repo_hook"
+ AccessTokenScopeReadRepoHook AccessTokenScope = "read:repo_hook"
+
+ AccessTokenScopeAdminOrgHook AccessTokenScope = "admin:org_hook"
+
+ AccessTokenScopeNotification AccessTokenScope = "notification"
+
+ AccessTokenScopeUser AccessTokenScope = "user"
+ AccessTokenScopeReadUser AccessTokenScope = "read:user"
+ AccessTokenScopeUserEmail AccessTokenScope = "user:email"
+ AccessTokenScopeUserFollow AccessTokenScope = "user:follow"
+
+ AccessTokenScopeDeleteRepo AccessTokenScope = "delete_repo"
+
+ AccessTokenScopePackage AccessTokenScope = "package"
+ AccessTokenScopeWritePackage AccessTokenScope = "write:package"
+ AccessTokenScopeReadPackage AccessTokenScope = "read:package"
+ AccessTokenScopeDeletePackage AccessTokenScope = "delete:package"
+
+ AccessTokenScopeAdminGPGKey AccessTokenScope = "admin:gpg_key"
+ AccessTokenScopeWriteGPGKey AccessTokenScope = "write:gpg_key"
+ AccessTokenScopeReadGPGKey AccessTokenScope = "read:gpg_key"
+
+ AccessTokenScopeAdminApplication AccessTokenScope = "admin:application"
+ AccessTokenScopeWriteApplication AccessTokenScope = "write:application"
+ AccessTokenScopeReadApplication AccessTokenScope = "read:application"
+
+ AccessTokenScopeSudo AccessTokenScope = "sudo"
+)
+
+// AccessTokenScopeBitmap represents a bitmap of access token scopes.
+type AccessTokenScopeBitmap uint64
+
+// Bitmap of each scope, including the child scopes.
+const (
+ // AccessTokenScopeAllBits is the bitmap of all access token scopes, except `sudo`.
+ AccessTokenScopeAllBits AccessTokenScopeBitmap = AccessTokenScopeRepoBits |
+ AccessTokenScopeAdminOrgBits | AccessTokenScopeAdminPublicKeyBits | AccessTokenScopeAdminOrgHookBits |
+ AccessTokenScopeNotificationBits | AccessTokenScopeUserBits | AccessTokenScopeDeleteRepoBits |
+ AccessTokenScopePackageBits | AccessTokenScopeAdminGPGKeyBits | AccessTokenScopeAdminApplicationBits
+
+ AccessTokenScopeRepoBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeRepoStatusBits | AccessTokenScopePublicRepoBits | AccessTokenScopeAdminRepoHookBits
+ AccessTokenScopeRepoStatusBits AccessTokenScopeBitmap = 1 << iota
+ AccessTokenScopePublicRepoBits AccessTokenScopeBitmap = 1 << iota
+
+ AccessTokenScopeAdminOrgBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWriteOrgBits
+ AccessTokenScopeWriteOrgBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadOrgBits
+ AccessTokenScopeReadOrgBits AccessTokenScopeBitmap = 1 << iota
+
+ AccessTokenScopeAdminPublicKeyBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWritePublicKeyBits
+ AccessTokenScopeWritePublicKeyBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadPublicKeyBits
+ AccessTokenScopeReadPublicKeyBits AccessTokenScopeBitmap = 1 << iota
+
+ AccessTokenScopeAdminRepoHookBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWriteRepoHookBits
+ AccessTokenScopeWriteRepoHookBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadRepoHookBits
+ AccessTokenScopeReadRepoHookBits AccessTokenScopeBitmap = 1 << iota
+
+ AccessTokenScopeAdminOrgHookBits AccessTokenScopeBitmap = 1 << iota
+
+ AccessTokenScopeNotificationBits AccessTokenScopeBitmap = 1 << iota
+
+ AccessTokenScopeUserBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadUserBits | AccessTokenScopeUserEmailBits | AccessTokenScopeUserFollowBits
+ AccessTokenScopeReadUserBits AccessTokenScopeBitmap = 1 << iota
+ AccessTokenScopeUserEmailBits AccessTokenScopeBitmap = 1 << iota
+ AccessTokenScopeUserFollowBits AccessTokenScopeBitmap = 1 << iota
+
+ AccessTokenScopeDeleteRepoBits AccessTokenScopeBitmap = 1 << iota
+
+ AccessTokenScopePackageBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWritePackageBits | AccessTokenScopeDeletePackageBits
+ AccessTokenScopeWritePackageBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadPackageBits
+ AccessTokenScopeReadPackageBits AccessTokenScopeBitmap = 1 << iota
+ AccessTokenScopeDeletePackageBits AccessTokenScopeBitmap = 1 << iota
+
+ AccessTokenScopeAdminGPGKeyBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWriteGPGKeyBits
+ AccessTokenScopeWriteGPGKeyBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadGPGKeyBits
+ AccessTokenScopeReadGPGKeyBits AccessTokenScopeBitmap = 1 << iota
+
+ AccessTokenScopeAdminApplicationBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWriteApplicationBits
+ AccessTokenScopeWriteApplicationBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadApplicationBits
+ AccessTokenScopeReadApplicationBits AccessTokenScopeBitmap = 1 << iota
+
+ AccessTokenScopeSudoBits AccessTokenScopeBitmap = 1 << iota
+
+ // The current implementation only supports up to 64 token scopes.
+ // If we need to support > 64 scopes,
+ // refactoring the whole implementation in this file (and only this file) is needed.
+)
+
+// allAccessTokenScopes contains all access token scopes.
+// The order is important: parent scope must precedes child scopes.
+var allAccessTokenScopes = []AccessTokenScope{
+ AccessTokenScopeRepo, AccessTokenScopeRepoStatus, AccessTokenScopePublicRepo,
+ AccessTokenScopeAdminOrg, AccessTokenScopeWriteOrg, AccessTokenScopeReadOrg,
+ AccessTokenScopeAdminPublicKey, AccessTokenScopeWritePublicKey, AccessTokenScopeReadPublicKey,
+ AccessTokenScopeAdminRepoHook, AccessTokenScopeWriteRepoHook, AccessTokenScopeReadRepoHook,
+ AccessTokenScopeAdminOrgHook,
+ AccessTokenScopeNotification,
+ AccessTokenScopeUser, AccessTokenScopeReadUser, AccessTokenScopeUserEmail, AccessTokenScopeUserFollow,
+ AccessTokenScopeDeleteRepo,
+ AccessTokenScopePackage, AccessTokenScopeWritePackage, AccessTokenScopeReadPackage, AccessTokenScopeDeletePackage,
+ AccessTokenScopeAdminGPGKey, AccessTokenScopeWriteGPGKey, AccessTokenScopeReadGPGKey,
+ AccessTokenScopeAdminApplication, AccessTokenScopeWriteApplication, AccessTokenScopeReadApplication,
+ AccessTokenScopeSudo,
+}
+
+// allAccessTokenScopeBits contains all access token scopes.
+var allAccessTokenScopeBits = map[AccessTokenScope]AccessTokenScopeBitmap{
+ AccessTokenScopeRepo: AccessTokenScopeRepoBits,
+ AccessTokenScopeRepoStatus: AccessTokenScopeRepoStatusBits,
+ AccessTokenScopePublicRepo: AccessTokenScopePublicRepoBits,
+ AccessTokenScopeAdminOrg: AccessTokenScopeAdminOrgBits,
+ AccessTokenScopeWriteOrg: AccessTokenScopeWriteOrgBits,
+ AccessTokenScopeReadOrg: AccessTokenScopeReadOrgBits,
+ AccessTokenScopeAdminPublicKey: AccessTokenScopeAdminPublicKeyBits,
+ AccessTokenScopeWritePublicKey: AccessTokenScopeWritePublicKeyBits,
+ AccessTokenScopeReadPublicKey: AccessTokenScopeReadPublicKeyBits,
+ AccessTokenScopeAdminRepoHook: AccessTokenScopeAdminRepoHookBits,
+ AccessTokenScopeWriteRepoHook: AccessTokenScopeWriteRepoHookBits,
+ AccessTokenScopeReadRepoHook: AccessTokenScopeReadRepoHookBits,
+ AccessTokenScopeAdminOrgHook: AccessTokenScopeAdminOrgHookBits,
+ AccessTokenScopeNotification: AccessTokenScopeNotificationBits,
+ AccessTokenScopeUser: AccessTokenScopeUserBits,
+ AccessTokenScopeReadUser: AccessTokenScopeReadUserBits,
+ AccessTokenScopeUserEmail: AccessTokenScopeUserEmailBits,
+ AccessTokenScopeUserFollow: AccessTokenScopeUserFollowBits,
+ AccessTokenScopeDeleteRepo: AccessTokenScopeDeleteRepoBits,
+ AccessTokenScopePackage: AccessTokenScopePackageBits,
+ AccessTokenScopeWritePackage: AccessTokenScopeWritePackageBits,
+ AccessTokenScopeReadPackage: AccessTokenScopeReadPackageBits,
+ AccessTokenScopeDeletePackage: AccessTokenScopeDeletePackageBits,
+ AccessTokenScopeAdminGPGKey: AccessTokenScopeAdminGPGKeyBits,
+ AccessTokenScopeWriteGPGKey: AccessTokenScopeWriteGPGKeyBits,
+ AccessTokenScopeReadGPGKey: AccessTokenScopeReadGPGKeyBits,
+ AccessTokenScopeAdminApplication: AccessTokenScopeAdminApplicationBits,
+ AccessTokenScopeWriteApplication: AccessTokenScopeWriteApplicationBits,
+ AccessTokenScopeReadApplication: AccessTokenScopeReadApplicationBits,
+ AccessTokenScopeSudo: AccessTokenScopeSudoBits,
+}
+
+// Parse parses the scope string into a bitmap, thus removing possible duplicates.
+func (s AccessTokenScope) Parse() (AccessTokenScopeBitmap, error) {
+ list := strings.Split(string(s), ",")
+
+ var bitmap AccessTokenScopeBitmap
+ for _, v := range list {
+ singleScope := AccessTokenScope(v)
+ if singleScope == "" {
+ continue
+ }
+ if singleScope == AccessTokenScopeAll {
+ bitmap |= AccessTokenScopeAllBits
+ continue
+ }
+
+ bits, ok := allAccessTokenScopeBits[singleScope]
+ if !ok {
+ return 0, fmt.Errorf("invalid access token scope: %s", singleScope)
+ }
+ bitmap |= bits
+ }
+ return bitmap, nil
+}
+
+// Normalize returns a normalized scope string without any duplicates.
+func (s AccessTokenScope) Normalize() (AccessTokenScope, error) {
+ bitmap, err := s.Parse()
+ if err != nil {
+ return "", err
+ }
+
+ return bitmap.ToScope(), nil
+}
+
+// HasScope returns true if the string has the given scope
+func (s AccessTokenScope) HasScope(scope AccessTokenScope) (bool, error) {
+ bitmap, err := s.Parse()
+ if err != nil {
+ return false, err
+ }
+
+ return bitmap.HasScope(scope)
+}
+
+// HasScope returns true if the string has the given scope
+func (bitmap AccessTokenScopeBitmap) HasScope(scope AccessTokenScope) (bool, error) {
+ expectedBits, ok := allAccessTokenScopeBits[scope]
+ if !ok {
+ return false, fmt.Errorf("invalid access token scope: %s", scope)
+ }
+
+ return bitmap&expectedBits == expectedBits, nil
+}
+
+// ToScope returns a normalized scope string without any duplicates.
+func (bitmap AccessTokenScopeBitmap) ToScope() AccessTokenScope {
+ var scopes []string
+
+ // iterate over all scopes, and reconstruct the bitmap
+ // if the reconstructed bitmap doesn't change, then the scope is already included
+ var reconstruct AccessTokenScopeBitmap
+
+ for _, singleScope := range allAccessTokenScopes {
+ // no need for error checking here, since we know the scope is valid
+ if ok, _ := bitmap.HasScope(singleScope); ok {
+ current := reconstruct | allAccessTokenScopeBits[singleScope]
+ if current == reconstruct {
+ continue
+ }
+
+ reconstruct = current
+ scopes = append(scopes, string(singleScope))
+ }
+ }
+
+ scope := AccessTokenScope(strings.Join(scopes, ","))
+ scope = AccessTokenScope(strings.ReplaceAll(
+ string(scope),
+ "repo,admin:org,admin:public_key,admin:org_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application",
+ "all",
+ ))
+ return scope
+}
diff --git a/models/auth/token_scope_test.go b/models/auth/token_scope_test.go
new file mode 100644
index 0000000000..1d7f4794a4
--- /dev/null
+++ b/models/auth/token_scope_test.go
@@ -0,0 +1,84 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAccessTokenScope_Normalize(t *testing.T) {
+ tests := []struct {
+ in AccessTokenScope
+ out AccessTokenScope
+ err error
+ }{
+ {"", "", nil},
+ {"repo", "repo", nil},
+ {"repo,repo:status", "repo", nil},
+ {"repo,public_repo", "repo", nil},
+ {"admin:public_key,write:public_key", "admin:public_key", nil},
+ {"admin:public_key,read:public_key", "admin:public_key", nil},
+ {"write:public_key,read:public_key", "write:public_key", nil}, // read is include in write
+ {"admin:repo_hook,write:repo_hook", "admin:repo_hook", nil},
+ {"admin:repo_hook,read:repo_hook", "admin:repo_hook", nil},
+ {"repo,admin:repo_hook,read:repo_hook", "repo", nil}, // admin:repo_hook is a child scope of repo
+ {"repo,read:repo_hook", "repo", nil}, // read:repo_hook is a child scope of repo
+ {"user", "user", nil},
+ {"user,read:user", "user", nil},
+ {"user,admin:org,write:org", "admin:org,user", nil},
+ {"admin:org,write:org,user", "admin:org,user", nil},
+ {"package", "package", nil},
+ {"package,write:package", "package", nil},
+ {"package,write:package,delete:package", "package", nil},
+ {"write:package,read:package", "write:package", nil}, // read is include in write
+ {"write:package,delete:package", "write:package,delete:package", nil}, // write and delete are not include in each other
+ {"admin:gpg_key", "admin:gpg_key", nil},
+ {"admin:gpg_key,write:gpg_key", "admin:gpg_key", nil},
+ {"admin:gpg_key,write:gpg_key,user", "user,admin:gpg_key", nil},
+ {"admin:application,write:application,user", "user,admin:application", nil},
+ {"all", "all", nil},
+ {"repo,admin:org,admin:public_key,admin:repo_hook,admin:org_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application", "all", nil},
+ {"repo,admin:org,admin:public_key,admin:repo_hook,admin:org_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application,sudo", "all,sudo", nil},
+ }
+
+ for _, test := range tests {
+ t.Run(string(test.in), func(t *testing.T) {
+ scope, err := test.in.Normalize()
+ assert.Equal(t, test.out, scope)
+ assert.Equal(t, test.err, err)
+ })
+ }
+}
+
+func TestAccessTokenScope_HasScope(t *testing.T) {
+ tests := []struct {
+ in AccessTokenScope
+ scope AccessTokenScope
+ out bool
+ err error
+ }{
+ {"repo", "repo", true, nil},
+ {"repo", "repo:status", true, nil},
+ {"repo", "public_repo", true, nil},
+ {"repo", "admin:org", false, nil},
+ {"repo", "admin:public_key", false, nil},
+ {"repo:status", "repo", false, nil},
+ {"repo:status", "public_repo", false, nil},
+ {"admin:org", "write:org", true, nil},
+ {"admin:org", "read:org", true, nil},
+ {"admin:org", "admin:org", true, nil},
+ {"user", "read:user", true, nil},
+ {"package", "write:package", true, nil},
+ }
+
+ for _, test := range tests {
+ t.Run(string(test.in), func(t *testing.T) {
+ scope, err := test.in.HasScope(test.scope)
+ assert.Equal(t, test.out, scope)
+ assert.Equal(t, test.err, err)
+ })
+ }
+}