diff options
Diffstat (limited to 'internal/api/client/auth')
-rw-r--r-- | internal/api/client/auth/auth.go | 105 | ||||
-rw-r--r-- | internal/api/client/auth/auth_test.go | 139 | ||||
-rw-r--r-- | internal/api/client/auth/authorize.go | 335 | ||||
-rw-r--r-- | internal/api/client/auth/authorize_test.go | 118 | ||||
-rw-r--r-- | internal/api/client/auth/callback.go | 311 | ||||
-rw-r--r-- | internal/api/client/auth/oob.go | 111 | ||||
-rw-r--r-- | internal/api/client/auth/signin.go | 145 | ||||
-rw-r--r-- | internal/api/client/auth/token.go | 115 | ||||
-rw-r--r-- | internal/api/client/auth/token_test.go | 215 | ||||
-rw-r--r-- | internal/api/client/auth/util.go | 31 |
10 files changed, 0 insertions, 1625 deletions
diff --git a/internal/api/client/auth/auth.go b/internal/api/client/auth/auth.go deleted file mode 100644 index 8a1d9d483..000000000 --- a/internal/api/client/auth/auth.go +++ /dev/null @@ -1,105 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - 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 auth - -import ( - "net/http" - - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/oidc" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -/* #nosec G101 */ -const ( - // AuthSignInPath is the API path for users to sign in through - AuthSignInPath = "/auth/sign_in" - - // CheckYourEmailPath users land here after registering a new account, instructs them to confirm thier email - CheckYourEmailPath = "/check_your_email" - - // WaitForApprovalPath users land here after confirming thier email but before an admin approves thier account - // (if such is required) - WaitForApprovalPath = "/wait_for_approval" - - // AccountDisabledPath users land here when thier account is suspended by an admin - AccountDisabledPath = "/account_disabled" - - // OauthTokenPath is the API path to use for granting token requests to users with valid credentials - OauthTokenPath = "/oauth/token" - - // OauthAuthorizePath is the API path for authorization requests (eg., authorize this app to act on my behalf as a user) - OauthAuthorizePath = "/oauth/authorize" - - // OauthFinalizePath is the API path for completing user registration with additional user details - OauthFinalizePath = "/oauth/finalize" - - // CallbackPath is the API path for receiving callback tokens from external OIDC providers - CallbackPath = oidc.CallbackPath - - callbackStateParam = "state" - callbackCodeParam = "code" - - sessionUserID = "userid" - sessionClientID = "client_id" - sessionRedirectURI = "redirect_uri" - sessionForceLogin = "force_login" - sessionResponseType = "response_type" - sessionScope = "scope" - sessionInternalState = "internal_state" - sessionClientState = "client_state" - sessionClaims = "claims" - sessionAppID = "app_id" -) - -// Module implements the ClientAPIModule interface for -type Module struct { - db db.DB - idp oidc.IDP - processor processing.Processor -} - -// New returns a new auth module -func New(db db.DB, idp oidc.IDP, processor processing.Processor) api.ClientModule { - return &Module{ - db: db, - idp: idp, - processor: processor, - } -} - -// Route satisfies the RESTAPIModule interface -func (m *Module) Route(s router.Router) error { - s.AttachHandler(http.MethodGet, AuthSignInPath, m.SignInGETHandler) - s.AttachHandler(http.MethodPost, AuthSignInPath, m.SignInPOSTHandler) - - s.AttachHandler(http.MethodPost, OauthTokenPath, m.TokenPOSTHandler) - - s.AttachHandler(http.MethodGet, OauthAuthorizePath, m.AuthorizeGETHandler) - s.AttachHandler(http.MethodPost, OauthAuthorizePath, m.AuthorizePOSTHandler) - - s.AttachHandler(http.MethodGet, CallbackPath, m.CallbackGETHandler) - s.AttachHandler(http.MethodPost, OauthFinalizePath, m.FinalizePOSTHandler) - - s.AttachHandler(http.MethodGet, oauth.OOBTokenPath, m.OobHandler) - return nil -} diff --git a/internal/api/client/auth/auth_test.go b/internal/api/client/auth/auth_test.go deleted file mode 100644 index 75e958418..000000000 --- a/internal/api/client/auth/auth_test.go +++ /dev/null @@ -1,139 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - 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 auth_test - -import ( - "bytes" - "context" - "fmt" - "net/http/httptest" - - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/memstore" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/auth" - "github.com/superseriousbusiness/gotosocial/internal/concurrency" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/oidc" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type AuthStandardTestSuite struct { - suite.Suite - db db.DB - storage *storage.Driver - mediaManager media.Manager - federator federation.Federator - processor processing.Processor - emailSender email.Sender - idp oidc.IDP - oauthServer oauth.Server - - // standard suite models - testTokens map[string]*gtsmodel.Token - testClients map[string]*gtsmodel.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - - // module being tested - authModule *auth.Module -} - -const ( - sessionUserID = "userid" - sessionClientID = "client_id" -) - -func (suite *AuthStandardTestSuite) SetupSuite() { - suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() - suite.testApplications = testrig.NewTestApplications() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() -} - -func (suite *AuthStandardTestSuite) SetupTest() { - testrig.InitTestConfig() - testrig.InitTestLog() - - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - - suite.db = testrig.NewTestDB() - suite.storage = testrig.NewInMemoryStorage() - suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) - suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) - - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - var err error - suite.idp, err = oidc.NewIDP(context.Background()) - if err != nil { - panic(err) - } - suite.authModule = auth.New(suite.db, suite.idp, suite.processor).(*auth.Module) - testrig.StandardDBSetup(suite.db, suite.testAccounts) -} - -func (suite *AuthStandardTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) -} - -func (suite *AuthStandardTestSuite) newContext(requestMethod string, requestPath string, requestBody []byte, bodyContentType string) (*gin.Context, *httptest.ResponseRecorder) { - // create the recorder and gin test context - recorder := httptest.NewRecorder() - ctx, engine := testrig.CreateGinTestContext(recorder, nil) - - // load templates into the engine - testrig.ConfigureTemplatesWithGin(engine, "../../../../web/template") - - // create the request - protocol := config.GetProtocol() - host := config.GetHost() - baseURI := fmt.Sprintf("%s://%s", protocol, host) - requestURI := fmt.Sprintf("%s/%s", baseURI, requestPath) - - ctx.Request = httptest.NewRequest(requestMethod, requestURI, bytes.NewReader(requestBody)) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "text/html") - - if bodyContentType != "" { - ctx.Request.Header.Set("Content-Type", bodyContentType) - } - - // trigger the session middleware on the context - store := memstore.NewStore(make([]byte, 32), make([]byte, 32)) - store.Options(router.SessionOptions()) - sessionMiddleware := sessions.Sessions("gotosocial-localhost", store) - sessionMiddleware(ctx) - - return ctx, recorder -} diff --git a/internal/api/client/auth/authorize.go b/internal/api/client/auth/authorize.go deleted file mode 100644 index f28d1dfc9..000000000 --- a/internal/api/client/auth/authorize.go +++ /dev/null @@ -1,335 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - 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 auth - -import ( - "errors" - "fmt" - "net/http" - "net/url" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AuthorizeGETHandler should be served as GET at https://example.org/oauth/authorize -// The idea here is to present an oauth authorize page to the user, with a button -// that they have to click to accept. -func (m *Module) AuthorizeGETHandler(c *gin.Context) { - s := sessions.Default(c) - - if _, err := api.NegotiateAccept(c, api.HTMLAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - // UserID will be set in the session by AuthorizePOSTHandler if the caller has already gone through the authentication flow - // If it's not set, then we don't know yet who the user is, so we need to redirect them to the sign in page. - userID, ok := s.Get(sessionUserID).(string) - if !ok || userID == "" { - form := &model.OAuthAuthorize{} - if err := c.ShouldBind(form); err != nil { - m.clearSession(s) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - if errWithCode := saveAuthFormToSession(s, form); errWithCode != nil { - m.clearSession(s) - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.Redirect(http.StatusSeeOther, AuthSignInPath) - return - } - - // use session information to validate app, user, and account for this request - clientID, ok := s.Get(sessionClientID).(string) - if !ok || clientID == "" { - m.clearSession(s) - err := fmt.Errorf("key %s was not found in session", sessionClientID) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - app := >smodel.Application{} - if err := m.db.GetWhere(c.Request.Context(), []db.Where{{Key: sessionClientID, Value: clientID}}, app); err != nil { - m.clearSession(s) - safe := fmt.Sprintf("application for %s %s could not be retrieved", sessionClientID, clientID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - user, err := m.db.GetUserByID(c.Request.Context(), userID) - if err != nil { - m.clearSession(s) - safe := fmt.Sprintf("user with id %s could not be retrieved", userID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID) - if err != nil { - m.clearSession(s) - safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - if ensureUserIsAuthorizedOrRedirect(c, user, acct) { - return - } - - // Finally we should also get the redirect and scope of this particular request, as stored in the session. - redirect, ok := s.Get(sessionRedirectURI).(string) - if !ok || redirect == "" { - m.clearSession(s) - err := fmt.Errorf("key %s was not found in session", sessionRedirectURI) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - scope, ok := s.Get(sessionScope).(string) - if !ok || scope == "" { - m.clearSession(s) - err := fmt.Errorf("key %s was not found in session", sessionScope) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), config.GetHost()) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - // the authorize template will display a form to the user where they can get some information - // about the app that's trying to authorize, and the scope of the request. - // They can then approve it if it looks OK to them, which will POST to the AuthorizePOSTHandler - c.HTML(http.StatusOK, "authorize.tmpl", gin.H{ - "appname": app.Name, - "appwebsite": app.Website, - "redirect": redirect, - "scope": scope, - "user": acct.Username, - "instance": instance, - }) -} - -// AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize -// At this point we assume that the user has A) logged in and B) accepted that the app should act for them, -// so we should proceed with the authentication flow and generate an oauth token for them if we can. -func (m *Module) AuthorizePOSTHandler(c *gin.Context) { - s := sessions.Default(c) - - // We need to retrieve the original form submitted to the authorizeGEThandler, and - // recreate it on the request so that it can be used further by the oauth2 library. - errs := []string{} - - forceLogin, ok := s.Get(sessionForceLogin).(string) - if !ok { - forceLogin = "false" - } - - responseType, ok := s.Get(sessionResponseType).(string) - if !ok || responseType == "" { - errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionResponseType)) - } - - clientID, ok := s.Get(sessionClientID).(string) - if !ok || clientID == "" { - errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionClientID)) - } - - redirectURI, ok := s.Get(sessionRedirectURI).(string) - if !ok || redirectURI == "" { - errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionRedirectURI)) - } - - scope, ok := s.Get(sessionScope).(string) - if !ok { - errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionScope)) - } - - var clientState string - if s, ok := s.Get(sessionClientState).(string); ok { - clientState = s - } - - userID, ok := s.Get(sessionUserID).(string) - if !ok { - errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionUserID)) - } - - if len(errs) != 0 { - errs = append(errs, oauth.HelpfulAdvice) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(errors.New("one or more missing keys on session during AuthorizePOSTHandler"), errs...), m.processor.InstanceGet) - return - } - - user, err := m.db.GetUserByID(c.Request.Context(), userID) - if err != nil { - m.clearSession(s) - safe := fmt.Sprintf("user with id %s could not be retrieved", userID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID) - if err != nil { - m.clearSession(s) - safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - if ensureUserIsAuthorizedOrRedirect(c, user, acct) { - return - } - - if redirectURI != oauth.OOBURI { - // we're done with the session now, so just clear it out - m.clearSession(s) - } - - // we have to set the values on the request form - // so that they're picked up by the oauth server - c.Request.Form = url.Values{ - sessionForceLogin: {forceLogin}, - sessionResponseType: {responseType}, - sessionClientID: {clientID}, - sessionRedirectURI: {redirectURI}, - sessionScope: {scope}, - sessionUserID: {userID}, - } - - if clientState != "" { - c.Request.Form.Set("state", clientState) - } - - if errWithCode := m.processor.OAuthHandleAuthorizeRequest(c.Writer, c.Request); errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - } -} - -// saveAuthFormToSession checks the given OAuthAuthorize form, -// and stores the values in the form into the session. -func saveAuthFormToSession(s sessions.Session, form *model.OAuthAuthorize) gtserror.WithCode { - if form == nil { - err := errors.New("OAuthAuthorize form was nil") - return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice) - } - - if form.ResponseType == "" { - err := errors.New("field response_type was not set on OAuthAuthorize form") - return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice) - } - - if form.ClientID == "" { - err := errors.New("field client_id was not set on OAuthAuthorize form") - return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice) - } - - if form.RedirectURI == "" { - err := errors.New("field redirect_uri was not set on OAuthAuthorize form") - return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice) - } - - // set default scope to read - if form.Scope == "" { - form.Scope = "read" - } - - // save these values from the form so we can use them elsewhere in the session - s.Set(sessionForceLogin, form.ForceLogin) - s.Set(sessionResponseType, form.ResponseType) - s.Set(sessionClientID, form.ClientID) - s.Set(sessionRedirectURI, form.RedirectURI) - s.Set(sessionScope, form.Scope) - s.Set(sessionInternalState, uuid.NewString()) - s.Set(sessionClientState, form.State) - - if err := s.Save(); err != nil { - err := fmt.Errorf("error saving form values onto session: %s", err) - return gtserror.NewErrorInternalError(err, oauth.HelpfulAdvice) - } - - return nil -} - -func ensureUserIsAuthorizedOrRedirect(ctx *gin.Context, user *gtsmodel.User, account *gtsmodel.Account) (redirected bool) { - if user.ConfirmedAt.IsZero() { - ctx.Redirect(http.StatusSeeOther, CheckYourEmailPath) - redirected = true - return - } - - if !*user.Approved { - ctx.Redirect(http.StatusSeeOther, WaitForApprovalPath) - redirected = true - return - } - - if *user.Disabled || !account.SuspendedAt.IsZero() { - ctx.Redirect(http.StatusSeeOther, AccountDisabledPath) - redirected = true - return - } - - return -} diff --git a/internal/api/client/auth/authorize_test.go b/internal/api/client/auth/authorize_test.go deleted file mode 100644 index 738b3b910..000000000 --- a/internal/api/client/auth/authorize_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package auth_test - -import ( - "context" - "fmt" - "net/http" - "testing" - "time" - - "github.com/gin-contrib/sessions" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/auth" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type AuthAuthorizeTestSuite struct { - AuthStandardTestSuite -} - -type authorizeHandlerTestCase struct { - description string - mutateUserAccount func(*gtsmodel.User, *gtsmodel.Account) []string - expectedStatusCode int - expectedLocationHeader string -} - -func (suite *AuthAuthorizeTestSuite) TestAccountAuthorizeHandler() { - tests := []authorizeHandlerTestCase{ - { - description: "user has their email unconfirmed", - mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) []string { - user.ConfirmedAt = time.Time{} - return []string{"confirmed_at"} - }, - expectedStatusCode: http.StatusSeeOther, - expectedLocationHeader: auth.CheckYourEmailPath, - }, - { - description: "user has their email confirmed but is not approved", - mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) []string { - user.ConfirmedAt = time.Now() - user.Email = user.UnconfirmedEmail - return []string{"confirmed_at", "email"} - }, - expectedStatusCode: http.StatusSeeOther, - expectedLocationHeader: auth.WaitForApprovalPath, - }, - { - description: "user has their email confirmed and is approved, but User entity has been disabled", - mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) []string { - user.ConfirmedAt = time.Now() - user.Email = user.UnconfirmedEmail - user.Approved = testrig.TrueBool() - user.Disabled = testrig.TrueBool() - return []string{"confirmed_at", "email", "approved", "disabled"} - }, - expectedStatusCode: http.StatusSeeOther, - expectedLocationHeader: auth.AccountDisabledPath, - }, - { - description: "user has their email confirmed and is approved, but Account entity has been suspended", - mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) []string { - user.ConfirmedAt = time.Now() - user.Email = user.UnconfirmedEmail - user.Approved = testrig.TrueBool() - user.Disabled = testrig.FalseBool() - account.SuspendedAt = time.Now() - return []string{"confirmed_at", "email", "approved", "disabled"} - }, - expectedStatusCode: http.StatusSeeOther, - expectedLocationHeader: auth.AccountDisabledPath, - }, - } - - doTest := func(testCase authorizeHandlerTestCase) { - ctx, recorder := suite.newContext(http.MethodGet, auth.OauthAuthorizePath, nil, "") - - user := >smodel.User{} - account := >smodel.Account{} - - *user = *suite.testUsers["unconfirmed_account"] - *account = *suite.testAccounts["unconfirmed_account"] - - testSession := sessions.Default(ctx) - testSession.Set(sessionUserID, user.ID) - testSession.Set(sessionClientID, suite.testApplications["application_1"].ClientID) - if err := testSession.Save(); err != nil { - panic(fmt.Errorf("failed on case %s: %w", testCase.description, err)) - } - - columns := testCase.mutateUserAccount(user, account) - - testCase.description = fmt.Sprintf("%s, %t, %s", user.Email, *user.Disabled, account.SuspendedAt) - - err := suite.db.UpdateUser(context.Background(), user, columns...) - suite.NoError(err) - err = suite.db.UpdateAccount(context.Background(), account) - suite.NoError(err) - - // call the handler - suite.authModule.AuthorizeGETHandler(ctx) - - // 1. we should have a redirect - suite.Equal(testCase.expectedStatusCode, recorder.Code, fmt.Sprintf("failed on case: %s", testCase.description)) - - // 2. we should have a redirect to the check your email path, as this user has not confirmed their email yet. - suite.Equal(testCase.expectedLocationHeader, recorder.Header().Get("Location"), fmt.Sprintf("failed on case: %s", testCase.description)) - } - - for _, testCase := range tests { - doTest(testCase) - } -} - -func TestAccountUpdateTestSuite(t *testing.T) { - suite.Run(t, new(AuthAuthorizeTestSuite)) -} diff --git a/internal/api/client/auth/callback.go b/internal/api/client/auth/callback.go deleted file mode 100644 index c97abf7aa..000000000 --- a/internal/api/client/auth/callback.go +++ /dev/null @@ -1,311 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - 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 auth - -import ( - "context" - "errors" - "fmt" - "net" - "net/http" - "strings" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/oidc" - "github.com/superseriousbusiness/gotosocial/internal/validate" -) - -// extraInfo wraps a form-submitted username and transmitted name -type extraInfo struct { - Username string `form:"username"` - Name string `form:"name"` // note that this is only used for re-rendering the page in case of an error -} - -// CallbackGETHandler parses a token from an external auth provider. -func (m *Module) CallbackGETHandler(c *gin.Context) { - s := sessions.Default(c) - - // check the query vs session state parameter to mitigate csrf - // https://auth0.com/docs/secure/attack-protection/state-parameters - - returnedInternalState := c.Query(callbackStateParam) - if returnedInternalState == "" { - m.clearSession(s) - err := fmt.Errorf("%s parameter not found on callback query", callbackStateParam) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - savedInternalStateI := s.Get(sessionInternalState) - savedInternalState, ok := savedInternalStateI.(string) - if !ok { - m.clearSession(s) - err := fmt.Errorf("key %s was not found in session", sessionInternalState) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - if returnedInternalState != savedInternalState { - m.clearSession(s) - err := errors.New("mismatch between callback state and saved state") - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - // retrieve stored claims using code - code := c.Query(callbackCodeParam) - if code == "" { - m.clearSession(s) - err := fmt.Errorf("%s parameter not found on callback query", callbackCodeParam) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - claims, errWithCode := m.idp.HandleCallback(c.Request.Context(), code) - if errWithCode != nil { - m.clearSession(s) - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - // We can use the client_id on the session to retrieve - // info about the app associated with the client_id - clientID, ok := s.Get(sessionClientID).(string) - if !ok || clientID == "" { - m.clearSession(s) - err := fmt.Errorf("key %s was not found in session", sessionClientID) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - app := >smodel.Application{} - if err := m.db.GetWhere(c.Request.Context(), []db.Where{{Key: sessionClientID, Value: clientID}}, app); err != nil { - m.clearSession(s) - safe := fmt.Sprintf("application for %s %s could not be retrieved", sessionClientID, clientID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - user, errWithCode := m.fetchUserForClaims(c.Request.Context(), claims, net.IP(c.ClientIP()), app.ID) - if errWithCode != nil { - m.clearSession(s) - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - if user == nil { - // no user exists yet - let's ask them for their preferred username - instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), config.GetHost()) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - // store the claims in the session - that way we know the user is authenticated when processing the form later - s.Set(sessionClaims, claims) - s.Set(sessionAppID, app.ID) - if err := s.Save(); err != nil { - m.clearSession(s) - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - c.HTML(http.StatusOK, "finalize.tmpl", gin.H{ - "instance": instance, - "name": claims.Name, - "preferredUsername": claims.PreferredUsername, - }) - return - } - s.Set(sessionUserID, user.ID) - if err := s.Save(); err != nil { - m.clearSession(s) - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - c.Redirect(http.StatusFound, OauthAuthorizePath) -} - -// FinalizePOSTHandler registers the user after additional data has been provided -func (m *Module) FinalizePOSTHandler(c *gin.Context) { - s := sessions.Default(c) - - form := &extraInfo{} - if err := c.ShouldBind(form); err != nil { - m.clearSession(s) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - // since we have multiple possible validation error, `validationError` is a shorthand for rendering them - validationError := func(err error) { - instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), config.GetHost()) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - c.HTML(http.StatusOK, "finalize.tmpl", gin.H{ - "instance": instance, - "name": form.Name, - "preferredUsername": form.Username, - "error": err, - }) - } - - // check if the username conforms to the spec - if err := validate.Username(form.Username); err != nil { - validationError(err) - return - } - - // see if the username is still available - usernameAvailable, err := m.db.IsUsernameAvailable(c.Request.Context(), form.Username) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - if !usernameAvailable { - validationError(fmt.Errorf("Username %s is already taken", form.Username)) - return - } - - // retrieve the information previously set by the oidc logic - appID, ok := s.Get(sessionAppID).(string) - if !ok { - err := fmt.Errorf("key %s was not found in session", sessionAppID) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - // retrieve the claims returned by the IDP. Having this present means that we previously already verified these claims - claims, ok := s.Get(sessionClaims).(*oidc.Claims) - if !ok { - err := fmt.Errorf("key %s was not found in session", sessionClaims) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - // we're now ready to actually create the user - user, errWithCode := m.createUserFromOIDC(c.Request.Context(), claims, form, net.IP(c.ClientIP()), appID) - if errWithCode != nil { - m.clearSession(s) - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - s.Delete(sessionClaims) - s.Delete(sessionAppID) - s.Set(sessionUserID, user.ID) - if err := s.Save(); err != nil { - m.clearSession(s) - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - c.Redirect(http.StatusFound, OauthAuthorizePath) -} - -func (m *Module) fetchUserForClaims(ctx context.Context, claims *oidc.Claims, ip net.IP, appID string) (*gtsmodel.User, gtserror.WithCode) { - if claims.Sub == "" { - err := errors.New("no sub claim found - is your provider OIDC compliant?") - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - user, err := m.db.GetUserByExternalID(ctx, claims.Sub) - if err == nil { - return user, nil - } - if err != db.ErrNoEntries { - err := fmt.Errorf("error checking database for externalID %s: %s", claims.Sub, err) - return nil, gtserror.NewErrorInternalError(err) - } - if !config.GetOIDCLinkExisting() { - return nil, nil - } - // fallback to email if we want to link existing users - user, err = m.db.GetUserByEmailAddress(ctx, claims.Email) - if err == db.ErrNoEntries { - return nil, nil - } else if err != nil { - err := fmt.Errorf("error checking database for email %s: %s", claims.Email, err) - return nil, gtserror.NewErrorInternalError(err) - } - // at this point we have found a matching user but still need to link the newly received external ID - - user.ExternalID = claims.Sub - err = m.db.UpdateUser(ctx, user, "external_id") - if err != nil { - err := fmt.Errorf("error linking existing user %s: %s", claims.Email, err) - return nil, gtserror.NewErrorInternalError(err) - } - return user, nil -} - -func (m *Module) createUserFromOIDC(ctx context.Context, claims *oidc.Claims, extraInfo *extraInfo, ip net.IP, appID string) (*gtsmodel.User, gtserror.WithCode) { - // check if the email address is available for use; if it's not there's nothing we can so - emailAvailable, err := m.db.IsEmailAvailable(ctx, claims.Email) - if err != nil { - return nil, gtserror.NewErrorBadRequest(err) - } - if !emailAvailable { - help := "The email address given to us by your authentication provider already exists in our records and the server administrator has not enabled account migration" - return nil, gtserror.NewErrorConflict(fmt.Errorf("email address %s is not available", claims.Email), help) - } - - // check if the user is in any recognised admin groups - var admin bool - for _, g := range claims.Groups { - if strings.EqualFold(g, "admin") || strings.EqualFold(g, "admins") { - admin = true - } - } - - // We still need to set *a* password even if it's not a password the user will end up using, so set something random. - // We'll just set two uuids on top of each other, which should be long + random enough to baffle any attempts to crack. - // - // If the user ever wants to log in using gts password rather than oidc flow, they'll have to request a password reset, which is fine - password := uuid.NewString() + uuid.NewString() - - // Since this user is created via oidc, which has been set up by the admin, we can assume that the account is already - // implicitly approved, and that the email address has already been verified: otherwise, we end up in situations where - // the admin first approves the user in OIDC, and then has to approve them again in GoToSocial, which doesn't make sense. - // - // In other words, if a user logs in via OIDC, they should be able to use their account straight away. - // - // See: https://github.com/superseriousbusiness/gotosocial/issues/357 - requireApproval := false - emailVerified := true - - // create the user! this will also create an account and store it in the database so we don't need to do that here - user, err := m.db.NewSignup(ctx, extraInfo.Username, "", requireApproval, claims.Email, password, ip, "", appID, emailVerified, claims.Sub, admin) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - return user, nil -} diff --git a/internal/api/client/auth/oob.go b/internal/api/client/auth/oob.go deleted file mode 100644 index 92e49d328..000000000 --- a/internal/api/client/auth/oob.go +++ /dev/null @@ -1,111 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - 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 auth - -import ( - "context" - "errors" - "fmt" - "net/http" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -func (m *Module) OobHandler(c *gin.Context) { - host := config.GetHost() - instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), host) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - instanceGet := func(ctx context.Context, domain string) (*model.Instance, gtserror.WithCode) { return instance, nil } - - oobToken := c.Query("code") - if oobToken == "" { - err := errors.New("no 'code' query value provided in callback redirect") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice), instanceGet) - return - } - - s := sessions.Default(c) - - errs := []string{} - - scope, ok := s.Get(sessionScope).(string) - if !ok { - errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionScope)) - } - - userID, ok := s.Get(sessionUserID).(string) - if !ok { - errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionUserID)) - } - - if len(errs) != 0 { - errs = append(errs, oauth.HelpfulAdvice) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(errors.New("one or more missing keys on session during OobHandler"), errs...), m.processor.InstanceGet) - return - } - - user, err := m.db.GetUserByID(c.Request.Context(), userID) - if err != nil { - m.clearSession(s) - safe := fmt.Sprintf("user with id %s could not be retrieved", userID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, instanceGet) - return - } - - acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID) - if err != nil { - m.clearSession(s) - safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, instanceGet) - return - } - - // we're done with the session now, so just clear it out - m.clearSession(s) - - c.HTML(http.StatusOK, "oob.tmpl", gin.H{ - "instance": instance, - "user": acct.Username, - "oobToken": oobToken, - "scope": scope, - }) -} diff --git a/internal/api/client/auth/signin.go b/internal/api/client/auth/signin.go deleted file mode 100644 index 73a5de398..000000000 --- a/internal/api/client/auth/signin.go +++ /dev/null @@ -1,145 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - 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 auth - -import ( - "context" - "errors" - "fmt" - "net/http" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "golang.org/x/crypto/bcrypt" -) - -// login just wraps a form-submitted username (we want an email) and password -type login struct { - Email string `form:"username"` - Password string `form:"password"` -} - -// SignInGETHandler should be served at https://example.org/auth/sign_in. -// The idea is to present a sign in page to the user, where they can enter their username and password. -// The form will then POST to the sign in page, which will be handled by SignInPOSTHandler. -// If an idp provider is set, then the user will be redirected to that to do their sign in. -func (m *Module) SignInGETHandler(c *gin.Context) { - if _, err := api.NegotiateAccept(c, api.HTMLAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - if m.idp == nil { - instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), config.GetHost()) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - // no idp provider, use our own funky little sign in page - c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{ - "instance": instance, - }) - return - } - - // idp provider is in use, so redirect to it - s := sessions.Default(c) - - internalStateI := s.Get(sessionInternalState) - internalState, ok := internalStateI.(string) - if !ok { - m.clearSession(s) - err := fmt.Errorf("key %s was not found in session", sessionInternalState) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - c.Redirect(http.StatusSeeOther, m.idp.AuthCodeURL(internalState)) -} - -// SignInPOSTHandler should be served at https://example.org/auth/sign_in. -// The idea is to present a sign in page to the user, where they can enter their username and password. -// The handler will then redirect to the auth handler served at /auth -func (m *Module) SignInPOSTHandler(c *gin.Context) { - s := sessions.Default(c) - - form := &login{} - if err := c.ShouldBind(form); err != nil { - m.clearSession(s) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - userid, errWithCode := m.ValidatePassword(c.Request.Context(), form.Email, form.Password) - if errWithCode != nil { - // don't clear session here, so the user can just press back and try again - // if they accidentally gave the wrong password or something - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - s.Set(sessionUserID, userid) - if err := s.Save(); err != nil { - err := fmt.Errorf("error saving user id onto session: %s", err) - api.ErrorHandler(c, gtserror.NewErrorInternalError(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - } - - c.Redirect(http.StatusFound, OauthAuthorizePath) -} - -// ValidatePassword takes an email address and a password. -// The goal is to authenticate the password against the one for that email -// address stored in the database. If OK, we return the userid (a ulid) for that user, -// so that it can be used in further Oauth flows to generate a token/retreieve an oauth client from the db. -func (m *Module) ValidatePassword(ctx context.Context, email string, password string) (string, gtserror.WithCode) { - if email == "" || password == "" { - err := errors.New("email or password was not provided") - return incorrectPassword(err) - } - - user, err := m.db.GetUserByEmailAddress(ctx, email) - if err != nil { - err := fmt.Errorf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err) - return incorrectPassword(err) - } - - if user.EncryptedPassword == "" { - err := fmt.Errorf("encrypted password for user %s was empty for some reason", user.Email) - return incorrectPassword(err) - } - - if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password)); err != nil { - err := fmt.Errorf("password hash didn't match for user %s during login attempt: %s", user.Email, err) - return incorrectPassword(err) - } - - return user.ID, nil -} - -// incorrectPassword wraps the given error in a gtserror.WithCode, and returns -// only a generic 'safe' error message to the user, to not give any info away. -func incorrectPassword(err error) (string, gtserror.WithCode) { - safeErr := fmt.Errorf("password/email combination was incorrect") - return "", gtserror.NewErrorUnauthorized(err, safeErr.Error(), oauth.HelpfulAdvice) -} diff --git a/internal/api/client/auth/token.go b/internal/api/client/auth/token.go deleted file mode 100644 index fbbd08404..000000000 --- a/internal/api/client/auth/token.go +++ /dev/null @@ -1,115 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - 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 auth - -import ( - "net/http" - "net/url" - - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - - "github.com/gin-gonic/gin" -) - -type tokenRequestForm struct { - GrantType *string `form:"grant_type" json:"grant_type" xml:"grant_type"` - Code *string `form:"code" json:"code" xml:"code"` - RedirectURI *string `form:"redirect_uri" json:"redirect_uri" xml:"redirect_uri"` - ClientID *string `form:"client_id" json:"client_id" xml:"client_id"` - ClientSecret *string `form:"client_secret" json:"client_secret" xml:"client_secret"` - Scope *string `form:"scope" json:"scope" xml:"scope"` -} - -// TokenPOSTHandler should be served as a POST at https://example.org/oauth/token -// The idea here is to serve an oauth access token to a user, which can be used for authorizing against non-public APIs. -func (m *Module) TokenPOSTHandler(c *gin.Context) { - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - help := []string{} - - form := &tokenRequestForm{} - if err := c.ShouldBind(form); err != nil { - api.OAuthErrorHandler(c, gtserror.NewErrorBadRequest(oauth.InvalidRequest(), err.Error())) - return - } - - c.Request.Form = url.Values{} - - var grantType string - if form.GrantType != nil { - grantType = *form.GrantType - c.Request.Form.Set("grant_type", grantType) - } else { - help = append(help, "grant_type was not set in the token request form, but must be set to authorization_code or client_credentials") - } - - if form.ClientID != nil { - c.Request.Form.Set("client_id", *form.ClientID) - } else { - help = append(help, "client_id was not set in the token request form") - } - - if form.ClientSecret != nil { - c.Request.Form.Set("client_secret", *form.ClientSecret) - } else { - help = append(help, "client_secret was not set in the token request form") - } - - if form.RedirectURI != nil { - c.Request.Form.Set("redirect_uri", *form.RedirectURI) - } else { - help = append(help, "redirect_uri was not set in the token request form") - } - - var code string - if form.Code != nil { - if grantType != "authorization_code" { - help = append(help, "a code was provided in the token request form, but grant_type was not set to authorization_code") - } else { - code = *form.Code - c.Request.Form.Set("code", code) - } - } else if grantType == "authorization_code" { - help = append(help, "code was not set in the token request form, but must be set since grant_type is authorization_code") - } - - if form.Scope != nil { - c.Request.Form.Set("scope", *form.Scope) - } - - if len(help) != 0 { - api.OAuthErrorHandler(c, gtserror.NewErrorBadRequest(oauth.InvalidRequest(), help...)) - return - } - - token, errWithCode := m.processor.OAuthHandleTokenRequest(c.Request) - if errWithCode != nil { - api.OAuthErrorHandler(c, errWithCode) - return - } - - c.Header("Cache-Control", "no-store") - c.Header("Pragma", "no-cache") - c.JSON(http.StatusOK, token) -} diff --git a/internal/api/client/auth/token_test.go b/internal/api/client/auth/token_test.go deleted file mode 100644 index 50bbd6918..000000000 --- a/internal/api/client/auth/token_test.go +++ /dev/null @@ -1,215 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - 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 auth_test - -import ( - "context" - "encoding/json" - "io/ioutil" - "net/http" - "testing" - "time" - - "github.com/stretchr/testify/suite" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type TokenTestSuite struct { - AuthStandardTestSuite -} - -func (suite *TokenTestSuite) TestPOSTTokenEmptyForm() { - ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", []byte{}, "") - ctx.Request.Header.Set("accept", "application/json") - - suite.authModule.TokenPOSTHandler(ctx) - - suite.Equal(http.StatusBadRequest, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - suite.Equal(`{"error":"invalid_request","error_description":"Bad Request: grant_type was not set in the token request form, but must be set to authorization_code or client_credentials: client_id was not set in the token request form: client_secret was not set in the token request form: redirect_uri was not set in the token request form"}`, string(b)) -} - -func (suite *TokenTestSuite) TestRetrieveClientCredentialsOK() { - testClient := suite.testClients["local_account_1"] - - requestBody, w, err := testrig.CreateMultipartFormData( - "", "", - map[string]string{ - "grant_type": "client_credentials", - "client_id": testClient.ID, - "client_secret": testClient.Secret, - "redirect_uri": "http://localhost:8080", - }) - if err != nil { - panic(err) - } - bodyBytes := requestBody.Bytes() - - ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType()) - ctx.Request.Header.Set("accept", "application/json") - - suite.authModule.TokenPOSTHandler(ctx) - - suite.Equal(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - t := &apimodel.Token{} - err = json.Unmarshal(b, t) - suite.NoError(err) - - suite.Equal("Bearer", t.TokenType) - suite.NotEmpty(t.AccessToken) - suite.NotEmpty(t.CreatedAt) - suite.WithinDuration(time.Now(), time.Unix(t.CreatedAt, 0), 1*time.Minute) - - // there should be a token in the database now too - dbToken := >smodel.Token{} - err = suite.db.GetWhere(context.Background(), []db.Where{{Key: "access", Value: t.AccessToken}}, dbToken) - suite.NoError(err) - suite.NotNil(dbToken) -} - -func (suite *TokenTestSuite) TestRetrieveAuthorizationCodeOK() { - testClient := suite.testClients["local_account_1"] - testUserAuthorizationToken := suite.testTokens["local_account_1_user_authorization_token"] - - requestBody, w, err := testrig.CreateMultipartFormData( - "", "", - map[string]string{ - "grant_type": "authorization_code", - "client_id": testClient.ID, - "client_secret": testClient.Secret, - "redirect_uri": "http://localhost:8080", - "code": testUserAuthorizationToken.Code, - }) - if err != nil { - panic(err) - } - bodyBytes := requestBody.Bytes() - - ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType()) - ctx.Request.Header.Set("accept", "application/json") - - suite.authModule.TokenPOSTHandler(ctx) - - suite.Equal(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - t := &apimodel.Token{} - err = json.Unmarshal(b, t) - suite.NoError(err) - - suite.Equal("Bearer", t.TokenType) - suite.NotEmpty(t.AccessToken) - suite.NotEmpty(t.CreatedAt) - suite.WithinDuration(time.Now(), time.Unix(t.CreatedAt, 0), 1*time.Minute) - - dbToken := >smodel.Token{} - err = suite.db.GetWhere(context.Background(), []db.Where{{Key: "access", Value: t.AccessToken}}, dbToken) - suite.NoError(err) - suite.NotNil(dbToken) -} - -func (suite *TokenTestSuite) TestRetrieveAuthorizationCodeNoCode() { - testClient := suite.testClients["local_account_1"] - - requestBody, w, err := testrig.CreateMultipartFormData( - "", "", - map[string]string{ - "grant_type": "authorization_code", - "client_id": testClient.ID, - "client_secret": testClient.Secret, - "redirect_uri": "http://localhost:8080", - }) - if err != nil { - panic(err) - } - bodyBytes := requestBody.Bytes() - - ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType()) - ctx.Request.Header.Set("accept", "application/json") - - suite.authModule.TokenPOSTHandler(ctx) - - suite.Equal(http.StatusBadRequest, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - suite.Equal(`{"error":"invalid_request","error_description":"Bad Request: code was not set in the token request form, but must be set since grant_type is authorization_code"}`, string(b)) -} - -func (suite *TokenTestSuite) TestRetrieveAuthorizationCodeWrongGrantType() { - testClient := suite.testClients["local_account_1"] - - requestBody, w, err := testrig.CreateMultipartFormData( - "", "", - map[string]string{ - "grant_type": "client_credentials", - "client_id": testClient.ID, - "client_secret": testClient.Secret, - "redirect_uri": "http://localhost:8080", - "code": "peepeepoopoo", - }) - if err != nil { - panic(err) - } - bodyBytes := requestBody.Bytes() - - ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType()) - ctx.Request.Header.Set("accept", "application/json") - - suite.authModule.TokenPOSTHandler(ctx) - - suite.Equal(http.StatusBadRequest, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - suite.Equal(`{"error":"invalid_request","error_description":"Bad Request: a code was provided in the token request form, but grant_type was not set to authorization_code"}`, string(b)) -} - -func TestTokenTestSuite(t *testing.T) { - suite.Run(t, &TokenTestSuite{}) -} diff --git a/internal/api/client/auth/util.go b/internal/api/client/auth/util.go deleted file mode 100644 index d59983c55..000000000 --- a/internal/api/client/auth/util.go +++ /dev/null @@ -1,31 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - 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 auth - -import ( - "github.com/gin-contrib/sessions" -) - -func (m *Module) clearSession(s sessions.Session) { - s.Clear() - - if err := s.Save(); err != nil { - panic(err) - } -} |