diff options
| author | 2025-02-26 13:04:55 +0100 | |
|---|---|---|
| committer | 2025-02-26 13:04:55 +0100 | |
| commit | eb720241da3d786c6ec79f2325277fa4af23846f (patch) | |
| tree | 36e0e08699e55a56d247353d082cc0a2b8144999 /internal/api/util | |
| parent | [chore]: Bump golang.org/x/crypto from 0.33.0 to 0.34.0 (#3824) (diff) | |
| download | gotosocial-eb720241da3d786c6ec79f2325277fa4af23846f.tar.xz | |
[feature] Enforce OAuth token scopes (#3835)
* move tokenauth to apiutil
* enforce scopes
* docs
* update test models, remove deprecated "follow"
* file header
* tests
* tweak scope matcher
* simplify...
* fix tests
* log user out of settings panel in case of oauth error
Diffstat (limited to 'internal/api/util')
| -rw-r--r-- | internal/api/util/auth.go | 152 | ||||
| -rw-r--r-- | internal/api/util/scopes.go | 103 | ||||
| -rw-r--r-- | internal/api/util/scopes_test.go | 101 |
3 files changed, 356 insertions, 0 deletions
diff --git a/internal/api/util/auth.go b/internal/api/util/auth.go new file mode 100644 index 000000000..5c6afb306 --- /dev/null +++ b/internal/api/util/auth.go @@ -0,0 +1,152 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package util + +import ( + "errors" + "slices" + "strings" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/oauth2/v4" +) + +// Auth wraps an authorized token, application, user, and account. +// It is used in the functions GetAuthed and MustAuth. +// Because the user might *not* be authed, any of the fields in this struct +// might be nil, so make sure to check that when you're using this struct anywhere. +type Auth struct { + Token oauth2.TokenInfo + Application *gtsmodel.Application + User *gtsmodel.User + Account *gtsmodel.Account +} + +// TokenAuth is a convenience function for returning an TokenAuth struct from a gin context. +// In essence, it tries to extract a token, application, user, and account from the context, +// and then sets them on a struct for convenience. +// +// If any are not present in the context, they will be set to nil on the returned TokenAuth struct. +// +// If *ALL* are not present, then nil and an error will be returned. +// +// If something goes wrong during parsing, then nil and an error will be returned (consider this not authed). +// TokenAuth is like GetAuthed, but will fail if one of the requirements is not met. +func TokenAuth( + c *gin.Context, + requireToken bool, + requireApp bool, + requireUser bool, + requireAccount bool, + requireScope ...Scope, +) (*Auth, gtserror.WithCode) { + var ( + ctx = c.Copy() + a = &Auth{} + i interface{} + ok bool + ) + + i, ok = ctx.Get(oauth.SessionAuthorizedToken) + if ok { + parsed, ok := i.(oauth2.TokenInfo) + if !ok { + const errText = "could not parse token from session context" + return nil, gtserror.NewErrorUnauthorized(errors.New(errText), errText) + } + a.Token = parsed + } + + i, ok = ctx.Get(oauth.SessionAuthorizedApplication) + if ok { + parsed, ok := i.(*gtsmodel.Application) + if !ok { + const errText = "could not parse application from session context" + return nil, gtserror.NewErrorUnauthorized(errors.New(errText), errText) + } + a.Application = parsed + } + + i, ok = ctx.Get(oauth.SessionAuthorizedUser) + if ok { + parsed, ok := i.(*gtsmodel.User) + if !ok { + const errText = "could not parse user from session context" + return nil, gtserror.NewErrorUnauthorized(errors.New(errText), errText) + } + a.User = parsed + } + + i, ok = ctx.Get(oauth.SessionAuthorizedAccount) + if ok { + parsed, ok := i.(*gtsmodel.Account) + if !ok { + const errText = "could not parse account from session context" + return nil, gtserror.NewErrorUnauthorized(errors.New(errText), errText) + } + a.Account = parsed + } + + if requireToken && a.Token == nil { + const errText = "token not supplied" + return nil, gtserror.NewErrorUnauthorized(errors.New(errText), errText) + } + + if requireApp && a.Application == nil { + const errText = "application not supplied" + return nil, gtserror.NewErrorUnauthorized(errors.New(errText), errText) + } + + if requireUser && a.User == nil { + const errText = "user not supplied or not authorized" + return nil, gtserror.NewErrorUnauthorized(errors.New(errText), errText) + } + + if requireAccount && a.Account == nil { + const errText = "account not supplied or not authorized" + return nil, gtserror.NewErrorUnauthorized(errors.New(errText), errText) + } + + if len(requireScope) != 0 { + // We need to match one of the + // required scopes, check if we can. + hasScopes := strings.Split(a.Token.GetScope(), " ") + scopeOK := slices.ContainsFunc( + hasScopes, + func(hasScope string) bool { + for _, requiredScope := range requireScope { + if Scope(hasScope).Permits(requiredScope) { + // Got it. + return true + } + } + return false + }, + ) + + if !scopeOK { + const errText = "token has insufficient scope permission" + return nil, gtserror.NewErrorForbidden(errors.New(errText), errText) + } + } + + return a, nil +} diff --git a/internal/api/util/scopes.go b/internal/api/util/scopes.go new file mode 100644 index 000000000..d02d3cc0d --- /dev/null +++ b/internal/api/util/scopes.go @@ -0,0 +1,103 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package util + +import ( + "strings" +) + +type Scope string + +const ( + /* Sub-scopes / scope components */ + + scopeAccounts = "accounts" + scopeBlocks = "blocks" + scopeBookmarks = "bookmarks" + scopeConversations = "conversations" + scopeDomainAllows = "domain_allows" + scopeDomainBlocks = "domain_blocks" + scopeFavourites = "favourites" + scopeFilters = "filters" + scopeFollows = "follows" + scopeLists = "lists" + scopeMedia = "media" + scopeMutes = "mutes" + scopeNotifications = "notifications" + scopeReports = "reports" + scopeSearch = "search" + scopeStatuses = "statuses" + + /* Top-level scopes */ + + ScopeProfile Scope = "profile" + ScopePush Scope = "push" + ScopeRead Scope = "read" + ScopeWrite Scope = "write" + ScopeAdmin Scope = "admin" + ScopeAdminRead Scope = ScopeAdmin + ":" + ScopeRead + ScopeAdminWrite Scope = ScopeAdmin + ":" + ScopeWrite + + /* Granular scopes */ + + ScopeReadAccounts Scope = ScopeRead + ":" + scopeAccounts + ScopeWriteAccounts Scope = ScopeWrite + ":" + scopeAccounts + ScopeReadBlocks Scope = ScopeRead + ":" + scopeBlocks + ScopeWriteBlocks Scope = ScopeWrite + ":" + scopeBlocks + ScopeReadBookmarks Scope = ScopeRead + ":" + scopeBookmarks + ScopeWriteBookmarks Scope = ScopeWrite + ":" + scopeBookmarks + ScopeWriteConversations Scope = ScopeWrite + ":" + scopeConversations + ScopeReadFavourites Scope = ScopeRead + ":" + scopeFavourites + ScopeWriteFavourites Scope = ScopeWrite + ":" + scopeFavourites + ScopeReadFilters Scope = ScopeRead + ":" + scopeFilters + ScopeWriteFilters Scope = ScopeWrite + ":" + scopeFilters + ScopeReadFollows Scope = ScopeRead + ":" + scopeFollows + ScopeWriteFollows Scope = ScopeWrite + ":" + scopeFollows + ScopeReadLists Scope = ScopeRead + ":" + scopeLists + ScopeWriteLists Scope = ScopeWrite + ":" + scopeLists + ScopeWriteMedia Scope = ScopeWrite + ":" + scopeMedia + ScopeReadMutes Scope = ScopeRead + ":" + scopeMutes + ScopeWriteMutes Scope = ScopeWrite + ":" + scopeMutes + ScopeReadNotifications Scope = ScopeRead + ":" + scopeNotifications + ScopeWriteNotifications Scope = ScopeWrite + ":" + scopeNotifications + ScopeWriteReports Scope = ScopeWrite + ":" + scopeReports + ScopeReadSearch Scope = ScopeRead + ":" + scopeSearch + ScopeReadStatuses Scope = ScopeRead + ":" + scopeStatuses + ScopeWriteStatuses Scope = ScopeWrite + ":" + scopeStatuses + ScopeAdminReadAccounts Scope = ScopeAdminRead + ":" + scopeAccounts + ScopeAdminWriteAccounts Scope = ScopeAdminWrite + ":" + scopeAccounts + ScopeAdminReadReports Scope = ScopeAdminRead + ":" + scopeReports + ScopeAdminWriteReports Scope = ScopeAdminWrite + ":" + scopeReports + ScopeAdminReadDomainAllows Scope = ScopeAdminRead + ":" + scopeDomainAllows + ScopeAdminWriteDomainAllows Scope = ScopeAdminWrite + ":" + scopeDomainAllows + ScopeAdminReadDomainBlocks Scope = ScopeAdminRead + ":" + scopeDomainBlocks + ScopeAdminWriteDomainBlocks Scope = ScopeAdminWrite + ":" + scopeDomainBlocks +) + +// Permits returns true if the +// scope permits the wanted scope. +func (has Scope) Permits(wanted Scope) bool { + if has == wanted { + // Exact match. + return true + } + + // Check if we have a parent scope of what's wanted, + // eg., we have scope "admin", we want "admin:read". + return strings.HasPrefix(string(wanted), string(has)) +} diff --git a/internal/api/util/scopes_test.go b/internal/api/util/scopes_test.go new file mode 100644 index 000000000..bd533585b --- /dev/null +++ b/internal/api/util/scopes_test.go @@ -0,0 +1,101 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package util_test + +import ( + "testing" + + "github.com/superseriousbusiness/gotosocial/internal/api/util" +) + +func TestScopes(t *testing.T) { + for _, test := range []struct { + HasScope util.Scope + WantsScope util.Scope + Expect bool + }{ + { + HasScope: util.ScopeRead, + WantsScope: util.ScopeRead, + Expect: true, + }, + { + HasScope: util.ScopeRead, + WantsScope: util.ScopeWrite, + Expect: false, + }, + { + HasScope: util.ScopeWrite, + WantsScope: util.ScopeWrite, + Expect: true, + }, + { + HasScope: util.ScopeWrite, + WantsScope: util.ScopeRead, + Expect: false, + }, + { + HasScope: util.ScopePush, + WantsScope: util.ScopePush, + Expect: true, + }, + { + HasScope: util.ScopeAdmin, + WantsScope: util.ScopeAdmin, + Expect: true, + }, + { + HasScope: util.ScopeProfile, + WantsScope: util.ScopeProfile, + Expect: true, + }, + { + HasScope: util.ScopeReadAccounts, + WantsScope: util.ScopeWriteAccounts, + Expect: false, + }, + { + HasScope: util.ScopeWriteAccounts, + WantsScope: util.ScopeWriteAccounts, + Expect: true, + }, + { + HasScope: util.ScopeWrite, + WantsScope: util.ScopeWriteAccounts, + Expect: true, + }, + { + HasScope: util.ScopeRead, + WantsScope: util.ScopeWriteAccounts, + Expect: false, + }, + { + HasScope: util.ScopeWriteAccounts, + WantsScope: util.ScopeWrite, + Expect: false, + }, + } { + res := test.HasScope.Permits(test.WantsScope) + if res != test.Expect { + t.Errorf( + "did not get expected result %v for input: has %s, wants %s", + test.Expect, test.HasScope, test.WantsScope, + ) + } + } +} |
