[feature] Add token review / delete to backend + settings panel (#3845)

This commit is contained in:
tobi
2025-03-04 11:01:25 +01:00
committed by GitHub
parent ee60732cf7
commit 829143d263
25 changed files with 1637 additions and 1 deletions

View File

@@ -54,6 +54,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/api/client/streaming"
"github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
"github.com/superseriousbusiness/gotosocial/internal/api/client/timelines"
"github.com/superseriousbusiness/gotosocial/internal/api/client/tokens"
"github.com/superseriousbusiness/gotosocial/internal/api/client/user"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/middleware"
@@ -99,6 +100,7 @@ type Client struct {
streaming *streaming.Module // api/v1/streaming
tags *tags.Module // api/v1/tags
timelines *timelines.Module // api/v1/timelines
tokens *tokens.Module // api/v1/tokens
user *user.Module // api/v1/user
}
@@ -152,6 +154,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
c.streaming.Route(h)
c.tags.Route(h)
c.timelines.Route(h)
c.tokens.Route(h)
c.user.Route(h)
}
@@ -193,6 +196,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
streaming: streaming.New(p, time.Second*30, 4096),
tags: tags.New(p),
timelines: timelines.New(p),
tokens: tokens.New(p),
user: user.New(p),
}
}

View File

@@ -0,0 +1,98 @@
// 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 tokens
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
// TokenInfoGETHandler swagger:operation GET /api/v1/tokens/{id} tokenInfoGet
//
// Get information about a single token.
//
// ---
// tags:
// - tokens
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: The id of the requested token.
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - read:accounts
//
// responses:
// '200':
// description: The requested token.
// schema:
// "$ref": "#/definitions/tokenInfo"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) TokenInfoGETHandler(c *gin.Context) {
authed, errWithCode := apiutil.TokenAuth(c,
true, true, true, true,
apiutil.ScopeReadAccounts,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
tokenID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
tokenInfo, errWithCode := m.processor.Account().TokenGet(
c.Request.Context(),
authed.User.ID,
tokenID,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, tokenInfo)
}

View File

@@ -0,0 +1,78 @@
// 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 tokens_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/tokens"
)
type TokenGetTestSuite struct {
TokensStandardTestSuite
}
func (suite *TokenGetTestSuite) TestTokenGet() {
var (
testToken = suite.testTokens["local_account_1"]
testPath = "/api" + tokens.BasePath + "/" + testToken.ID
)
out, code := suite.req(
http.MethodGet,
testPath,
suite.tokens.TokenInfoGETHandler,
map[string]string{"id": testToken.ID},
)
suite.Equal(http.StatusOK, code)
suite.Equal(`{
"id": "01F8MGTQW4DKTDF8SW5CT9HYGA",
"created_at": "2021-06-20T10:53:00.164Z",
"scope": "read write push",
"application": {
"name": "really cool gts application",
"website": "https://reallycool.app"
}
}`, out)
}
func (suite *TokenGetTestSuite) TestTokenGetNotOurs() {
var (
testToken = suite.testTokens["admin_account"]
testPath = "/api" + tokens.BasePath + "/" + testToken.ID
)
out, code := suite.req(
http.MethodGet,
testPath,
suite.tokens.TokenInfoGETHandler,
map[string]string{"id": testToken.ID},
)
suite.Equal(http.StatusNotFound, code)
suite.Equal(`{
"error": "Not Found"
}`, out)
}
func TestTokenGetTestSuite(t *testing.T) {
suite.Run(t, new(TokenGetTestSuite))
}

View File

@@ -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 tokens
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
// TokenInvalidatePOSTHandler swagger:operation POST /api/v1/tokens/{id}/invalidate tokenInvalidatePost
//
// Invalidate the target token, removing it from the database and making it unusable.
//
// ---
// tags:
// - tokens
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: The id of the target token.
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - write:accounts
//
// responses:
// '200':
// description: Info about the invalidated token.
// schema:
// "$ref": "#/definitions/tokenInfo"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) TokenInvalidatePOSTHandler(c *gin.Context) {
authed, errWithCode := apiutil.TokenAuth(c,
true, true, true, true,
apiutil.ScopeWriteAccounts,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
tokenID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
tokenInfo, errWithCode := m.processor.Account().TokenInvalidate(
c.Request.Context(),
authed.User.ID,
tokenID,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, tokenInfo)
}

View File

@@ -0,0 +1,87 @@
// 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 tokens_test
import (
"context"
"net/http"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/tokens"
"github.com/superseriousbusiness/gotosocial/internal/db"
)
type TokenInvalidateTestSuite struct {
TokensStandardTestSuite
}
func (suite *TokenInvalidateTestSuite) TestTokenInvalidate() {
var (
testToken = suite.testTokens["local_account_1"]
testPath = "/api" + tokens.BasePath + "/" + testToken.ID + "/invalidate"
)
out, code := suite.req(
http.MethodPost,
testPath,
suite.tokens.TokenInvalidatePOSTHandler,
map[string]string{"id": testToken.ID},
)
suite.Equal(http.StatusOK, code)
suite.Equal(`{
"id": "01F8MGTQW4DKTDF8SW5CT9HYGA",
"created_at": "2021-06-20T10:53:00.164Z",
"scope": "read write push",
"application": {
"name": "really cool gts application",
"website": "https://reallycool.app"
}
}`, out)
// Check database for token we
// just invalidated, should be gone.
_, err := suite.testStructs.State.DB.GetTokenByID(
context.Background(), testToken.ID,
)
suite.ErrorIs(err, db.ErrNoEntries)
}
func (suite *TokenInvalidateTestSuite) TestTokenInvalidateNotOurs() {
var (
testToken = suite.testTokens["admin_account"]
testPath = "/api" + tokens.BasePath + "/" + testToken.ID + "/invalidate"
)
out, code := suite.req(
http.MethodGet,
testPath,
suite.tokens.TokenInfoGETHandler,
map[string]string{"id": testToken.ID},
)
suite.Equal(http.StatusNotFound, code)
suite.Equal(`{
"error": "Not Found"
}`, out)
}
func TestTokenInvalidateTestSuite(t *testing.T) {
suite.Run(t, new(TokenInvalidateTestSuite))
}

View File

@@ -0,0 +1,48 @@
// 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 tokens
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/processing"
)
const (
BasePath = "/v1/tokens"
BasePathWithID = BasePath + "/:" + apiutil.IDKey
InvalidateTokenPath = BasePathWithID + "/invalidate"
)
type Module struct {
processor *processing.Processor
}
func New(processor *processing.Processor) *Module {
return &Module{
processor: processor,
}
}
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
attachHandler(http.MethodGet, BasePath, m.TokensInfoGETHandler)
attachHandler(http.MethodGet, BasePathWithID, m.TokensInfoGETHandler)
attachHandler(http.MethodPost, InvalidateTokenPath, m.TokenInvalidatePOSTHandler)
}

View File

@@ -0,0 +1,117 @@
// 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 tokens_test
import (
"bytes"
"encoding/json"
"io"
"net/http/httptest"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/tokens"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type TokensStandardTestSuite struct {
suite.Suite
// standard suite models
testTokens map[string]*gtsmodel.Token
testApplications map[string]*gtsmodel.Application
testUsers map[string]*gtsmodel.User
testAccounts map[string]*gtsmodel.Account
testStructs *testrig.TestStructs
// module being tested
tokens *tokens.Module
}
func (suite *TokensStandardTestSuite) req(
httpMethod string,
requestPath string,
handler gin.HandlerFunc,
pathParams map[string]string,
) (string, int) {
var (
recorder = httptest.NewRecorder()
ctx, _ = testrig.CreateGinTestContext(recorder, nil)
)
// Prepare test context.
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
// Prepare test context request.
request := httptest.NewRequest(httpMethod, requestPath, nil)
request.Header.Set("accept", "application/json")
ctx.Request = request
// Inject path parameters.
if pathParams != nil {
for k, v := range pathParams {
ctx.AddParam(k, v)
}
}
// Trigger the handler
handler(ctx)
// Read the response
result := recorder.Result()
defer result.Body.Close()
b, err := io.ReadAll(result.Body)
if err != nil {
suite.FailNow(err.Error())
}
// Format as nice indented json.
dst := &bytes.Buffer{}
if err := json.Indent(dst, b, "", " "); err != nil {
suite.FailNow(err.Error())
}
return dst.String(), recorder.Code
}
func (suite *TokensStandardTestSuite) SetupSuite() {
testrig.InitTestConfig()
testrig.InitTestLog()
suite.testTokens = testrig.NewTestTokens()
suite.testApplications = testrig.NewTestApplications()
suite.testUsers = testrig.NewTestUsers()
suite.testAccounts = testrig.NewTestAccounts()
}
func (suite *TokensStandardTestSuite) SetupTest() {
suite.testStructs = testrig.SetupTestStructs(
"../../../../testrig/media",
"../../../../web/template",
)
suite.tokens = tokens.New(suite.testStructs.Processor)
}
func (suite *TokensStandardTestSuite) TearDownTest() {
testrig.TearDownTestStructs(suite.testStructs)
}

View File

@@ -0,0 +1,144 @@
// 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 tokens
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// TokensInfoGETHandler swagger:operation GET /api/v1/tokens tokensInfoGet
//
// See info about tokens created for/by your account.
//
// The items will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
//
// The returned Link header can be used to generate the previous and next queries when paging up or down.
//
// Example:
//
// ```
// <https://example.org/api/v1/tokens?limit=20&max_id=01FC3GSQ8A3MMJ43BPZSGEG29M>; rel="next", <https://example.org/api/v1/tokens?limit=20&min_id=01FC3KJW2GYXSDDRA6RWNDM46M>; rel="prev"
// ````
//
// ---
// tags:
// - tokens
//
// produces:
// - application/json
//
// parameters:
// -
// name: max_id
// type: string
// description: >-
// Return only items *OLDER* than the given max status ID.
// The item with the specified ID will not be included in the response.
// in: query
// required: false
// -
// name: since_id
// type: string
// description: >-
// Return only items *newer* than the given since status ID.
// The item with the specified ID will not be included in the response.
// in: query
// -
// name: min_id
// type: string
// description: >-
// Return only items *immediately newer* than the given since status ID.
// The item with the specified ID will not be included in the response.
// in: query
// required: false
// -
// name: limit
// type: integer
// description: Number of items to return.
// default: 20
// in: query
// required: false
// max: 80
// min: 0
//
// security:
// - OAuth2 Bearer:
// - read:accounts
//
// responses:
// '200':
// name: tokens
// description: Array of token info entries.
// schema:
// type: array
// items:
// "$ref": "#/definitions/tokenInfo"
// headers:
// Link:
// type: string
// description: Links to the next and previous queries.
// '401':
// description: unauthorized
// '400':
// description: bad request
func (m *Module) TokensInfoGETHandler(c *gin.Context) {
authed, errWithCode := apiutil.TokenAuth(c,
true, true, true, true,
apiutil.ScopeReadAccounts,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
page, errWithCode := paging.ParseIDPage(c,
0, // min limit
80, // max limit
20, // default limit
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
resp, errWithCode := m.processor.Account().TokensGet(
c.Request.Context(),
authed.User.ID,
page,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if resp.LinkHeader != "" {
c.Header("Link", resp.LinkHeader)
}
apiutil.JSON(c, http.StatusOK, resp.Items)
}

View File

@@ -0,0 +1,69 @@
// 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 tokens_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/tokens"
)
type TokensGetTestSuite struct {
TokensStandardTestSuite
}
func (suite *TokensGetTestSuite) TestTokensGet() {
var (
testPath = "/api" + tokens.BasePath
)
out, code := suite.req(
http.MethodGet,
testPath,
suite.tokens.TokensInfoGETHandler,
nil,
)
suite.Equal(http.StatusOK, code)
suite.Equal(`[
{
"id": "01JN0X2D9GJTZQ5KYPYFWN16QW",
"created_at": "2025-02-26T10:33:04.560Z",
"scope": "push",
"application": {
"name": "really cool gts application",
"website": "https://reallycool.app"
}
},
{
"id": "01F8MGTQW4DKTDF8SW5CT9HYGA",
"created_at": "2021-06-20T10:53:00.164Z",
"scope": "read write push",
"application": {
"name": "really cool gts application",
"website": "https://reallycool.app"
}
}
]`, out)
}
func TestTokensGetTestSuite(t *testing.T) {
suite.Run(t, new(TokensGetTestSuite))
}

View File

@@ -33,3 +33,25 @@ type Token struct {
// example: 1627644520
CreatedAt int64 `json:"created_at"`
}
// TokenInfo represents metadata about one user-level access token.
// The actual access token itself will never be sent via the API.
//
// swagger:model tokenInfo
type TokenInfo struct {
// Database ID of this token.
// example: 01JMW7QBAZYZ8T8H73PCEX12XG
ID string `json:"id"`
// When the token was created (ISO 8601 Datetime).
// example: 2021-07-30T09:20:25+00:00
CreatedAt string `json:"created_at"`
// Approximate time (accurate to within an hour) when the token was last used (ISO 8601 Datetime).
// Omitted if token has never been used, or it is not known when it was last used (eg., it was last used before tracking "last_used" became a thing).
// example: 2021-07-30T09:20:25+00:00
LastUsed string `json:"last_used,omitempty"`
// OAuth scopes granted by the token, space-separated.
// example: read write admin
Scope string `json:"scope"`
// Application used to create this token.
Application *Application `json:"application"`
}