From 7adeaf414b26105bca9f674507a503625cb929dc Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Tue, 26 Mar 2024 19:41:52 -0300 Subject: [PATCH 01/24] Implementing delete oauth endpoint --- app/data/account_store.go | 1 + app/data/mock/account_store.go | 13 +++++ app/data/mysql/account_store.go | 9 ++++ app/data/postgres/account_store.go | 9 ++++ app/data/sqlite3/account_store.go | 9 ++++ app/services/account_oauth_ender.go | 32 +++++++++++++ app/services/account_oauth_ender_test.go | 40 ++++++++++++++++ app/services/validations.go | 19 ++++---- server/handlers/delete_oauth.go | 28 +++++++++++ server/handlers/delete_oauth_test.go | 60 ++++++++++++++++++++++++ server/public_routes.go | 3 ++ 11 files changed, 214 insertions(+), 9 deletions(-) create mode 100644 app/services/account_oauth_ender.go create mode 100644 app/services/account_oauth_ender_test.go create mode 100644 server/handlers/delete_oauth.go create mode 100644 server/handlers/delete_oauth_test.go diff --git a/app/data/account_store.go b/app/data/account_store.go index 8a6ab28456..28b3b4652b 100644 --- a/app/data/account_store.go +++ b/app/data/account_store.go @@ -17,6 +17,7 @@ type AccountStore interface { FindByUsername(u string) (*models.Account, error) FindByOauthAccount(p string, pid string) (*models.Account, error) AddOauthAccount(id int, p string, pid string, tok string) error + DeleteOauthAccount(id int, p string) (bool, error) GetOauthAccounts(id int) ([]*models.OauthAccount, error) Archive(id int) (bool, error) Lock(id int) (bool, error) diff --git a/app/data/mock/account_store.go b/app/data/mock/account_store.go index 6d59c8da3a..eec864d5ee 100644 --- a/app/data/mock/account_store.go +++ b/app/data/mock/account_store.go @@ -124,6 +124,19 @@ func (s *accountStore) GetOauthAccounts(accountID int) ([]*models.OauthAccount, return s.oauthAccountsByID[accountID], nil } +func (s *accountStore) DeleteOauthAccount(accountID int, provider string) (bool, error) { + oauthAccounts := s.oauthAccountsByID[accountID] + + for i, oauthAccount := range oauthAccounts { + if oauthAccount.Provider == provider { + s.oauthAccountsByID[accountID] = append(oauthAccounts[:i], oauthAccounts[i+1:]...) + return true, nil + } + } + + return false, nil +} + func (s *accountStore) Archive(id int) (bool, error) { account := s.accountsByID[id] if account == nil { diff --git a/app/data/mysql/account_store.go b/app/data/mysql/account_store.go index 730254d3d6..1cf84173bb 100644 --- a/app/data/mysql/account_store.go +++ b/app/data/mysql/account_store.go @@ -99,6 +99,15 @@ func (db *AccountStore) GetOauthAccounts(accountID int) ([]*models.OauthAccount, return accounts, err } +func (db *AccountStore) DeleteOauthAccount(accountId int, provider string) (bool, error) { + result, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = ? AND provider = ?", accountId, provider) + if err != nil { + return false, err + } + + return ok(result, err) +} + func (db *AccountStore) Archive(id int) (bool, error) { _, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = ?", id) if err != nil { diff --git a/app/data/postgres/account_store.go b/app/data/postgres/account_store.go index 99e195d27a..e9558111a9 100644 --- a/app/data/postgres/account_store.go +++ b/app/data/postgres/account_store.go @@ -111,6 +111,15 @@ func (db *AccountStore) GetOauthAccounts(accountID int) ([]*models.OauthAccount, return accounts, err } +func (db *AccountStore) DeleteOauthAccount(accountId int, provider string) (bool, error) { + result, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = ? AND provider = ?", accountId, provider) + if err != nil { + return false, err + } + + return ok(result, err) +} + func (db *AccountStore) Archive(id int) (bool, error) { _, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = $1", id) if err != nil { diff --git a/app/data/sqlite3/account_store.go b/app/data/sqlite3/account_store.go index 6d35b55ac4..c3b5c40c7a 100644 --- a/app/data/sqlite3/account_store.go +++ b/app/data/sqlite3/account_store.go @@ -99,6 +99,15 @@ func (db *AccountStore) GetOauthAccounts(accountID int) ([]*models.OauthAccount, return accounts, err } +func (db *AccountStore) DeleteOauthAccount(accountId int, provider string) (bool, error) { + result, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = ? AND provider = ?", accountId, provider) + if err != nil { + return false, err + } + + return ok(result, err) +} + func (db *AccountStore) Archive(id int) (bool, error) { _, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = ?", id) if err != nil { diff --git a/app/services/account_oauth_ender.go b/app/services/account_oauth_ender.go new file mode 100644 index 0000000000..7579b67fb5 --- /dev/null +++ b/app/services/account_oauth_ender.go @@ -0,0 +1,32 @@ +package services + +import ( + "math" + + "github.com/keratin/authn-server/app/data" +) + +func AccountOauthEnder(store data.AccountStore, accountId int, provider string) error { + account, err := store.Find(accountId) + if err != nil { + return err + } + + oauthAccounts, err := store.GetOauthAccounts(accountId) + if err != nil { + return err + } + + for _, oAccount := range oauthAccounts { + if math.Abs(oAccount.CreatedAt.Sub(account.PasswordChangedAt).Seconds()) < 5 { + return FieldErrors{{Field: "password", Message: ErrPasswordResetRequired}} + } + } + + _, err = store.DeleteOauthAccount(accountId, provider) + if err != nil { + return err + } + + return nil +} diff --git a/app/services/account_oauth_ender_test.go b/app/services/account_oauth_ender_test.go new file mode 100644 index 0000000000..6fdf4152cc --- /dev/null +++ b/app/services/account_oauth_ender_test.go @@ -0,0 +1,40 @@ +package services_test + +import ( + "testing" + "time" + + "github.com/keratin/authn-server/app/data/mock" + "github.com/keratin/authn-server/app/services" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAccountOauthEnder(t *testing.T) { + + t.Run("require password reset before delete an account register with oauth flow", func(t *testing.T) { + accountStore := mock.NewAccountStore() + account, err := accountStore.Create("requirepasswordreset@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "TOKEN") + require.NoError(t, err) + + err = services.AccountOauthEnder(accountStore, account.ID, "test") + assert.Equal(t, err, services.FieldErrors{{Field: "password", Message: services.ErrPasswordResetRequired}}) + }) + + t.Run("delete account", func(t *testing.T) { + accountStore := mock.NewAccountStore() + account, err := accountStore.Create("deleted@keratin.tech", []byte("password")) + require.NoError(t, err) + + time.Sleep(5 * time.Second) + + err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "TOKEN") + require.NoError(t, err) + + err = services.AccountOauthEnder(accountStore, account.ID, "test") + require.NoError(t, err) + }) +} diff --git a/app/services/validations.go b/app/services/validations.go index 479ac0852d..70b0399219 100644 --- a/app/services/validations.go +++ b/app/services/validations.go @@ -8,15 +8,16 @@ import ( ) var ( - ErrMissing = "MISSING" - ErrTaken = "TAKEN" - ErrFormatInvalid = "FORMAT_INVALID" - ErrInsecure = "INSECURE" - ErrFailed = "FAILED" - ErrLocked = "LOCKED" - ErrExpired = "EXPIRED" - ErrNotFound = "NOT_FOUND" - ErrInvalidOrExpired = "INVALID_OR_EXPIRED" + ErrMissing = "MISSING" + ErrTaken = "TAKEN" + ErrFormatInvalid = "FORMAT_INVALID" + ErrInsecure = "INSECURE" + ErrFailed = "FAILED" + ErrLocked = "LOCKED" + ErrExpired = "EXPIRED" + ErrNotFound = "NOT_FOUND" + ErrInvalidOrExpired = "INVALID_OR_EXPIRED" + ErrPasswordResetRequired = "PASSWORD_RESET_REQUIRED" ) type FieldError struct { diff --git a/server/handlers/delete_oauth.go b/server/handlers/delete_oauth.go new file mode 100644 index 0000000000..8c93edd4be --- /dev/null +++ b/server/handlers/delete_oauth.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "net/http" + + "github.com/keratin/authn-server/app" + "github.com/keratin/authn-server/app/services" + "github.com/keratin/authn-server/server/sessions" +) + +func DeleteOauth(app *app.App, providerName string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + accountID := sessions.GetAccountID(r) + if accountID == 0 { + WriteJSON(w, http.StatusUnauthorized, nil) + return + } + + err := services.AccountOauthEnder(app.AccountStore, accountID, providerName) + if err != nil { + app.Logger.WithError(err).Error("AccountOauthEnder") + WriteErrors(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/handlers/delete_oauth_test.go b/server/handlers/delete_oauth_test.go new file mode 100644 index 0000000000..1582d623fd --- /dev/null +++ b/server/handlers/delete_oauth_test.go @@ -0,0 +1,60 @@ +package handlers_test + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + oauthlib "github.com/keratin/authn-server/lib/oauth" + "github.com/keratin/authn-server/lib/route" + "github.com/keratin/authn-server/server/test" +) + +func TestDeleteOauthAccount(t *testing.T) { + providerServer := httptest.NewServer(test.ProviderApp()) + defer providerServer.Close() + + app := test.App() + app.OauthProviders["test"] = *oauthlib.NewTestProvider(providerServer) + + server := test.Server(app) + defer server.Close() + + client := route.NewClient(server.URL).WithCookie(&http.Cookie{ + Name: app.Config.OAuthCookieName, + Value: "", + }) + + http.DefaultClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + t.Run("unanthorized", func(t *testing.T) { + res, err := client.Delete("/oauth/test") + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, res.StatusCode) + }) + + t.Run("delete social account", func(t *testing.T) { + account, err := app.AccountStore.Create("deleted@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID", "TOKEN") + require.NoError(t, err) + + time.Sleep(5 * time.Second) + + _, err = app.AccountStore.SetPassword(account.ID, []byte("password")) + require.NoError(t, err) + + session := test.CreateSession(app.RefreshTokenStore, app.Config, account.ID) + + res, err := client.WithCookie(session).Delete("/oauth/test") + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, res.StatusCode) + }) +} diff --git a/server/public_routes.go b/server/public_routes.go index 67ccb564b0..29b4a091e0 100644 --- a/server/public_routes.go +++ b/server/public_routes.go @@ -103,6 +103,9 @@ func PublicRoutes(app *app.App) []*route.HandledRoute { returnRoute. SecuredWith(route.Unsecured()). Handle(handlers.GetOauthReturn(app, providerName)), + route.Delete("/oauth/"+providerName). + SecuredWith(route.Unsecured()). + Handle(handlers.DeleteOauth(app, providerName)), ) } From 847ebed390a5cd0d5ecd6a0aa5e992cb3316fcf7 Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Tue, 26 Mar 2024 20:58:44 -0300 Subject: [PATCH 02/24] Update account oauth ender to reset user password instead of return an error. --- app/services/account_oauth_ender.go | 19 +++++++++++++------ app/services/account_oauth_ender_test.go | 17 ++++++++++++----- server/handlers/delete_oauth.go | 8 +++++--- server/handlers/delete_oauth_test.go | 10 +++++++--- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/app/services/account_oauth_ender.go b/app/services/account_oauth_ender.go index 7579b67fb5..5afdc1c6d6 100644 --- a/app/services/account_oauth_ender.go +++ b/app/services/account_oauth_ender.go @@ -6,27 +6,34 @@ import ( "github.com/keratin/authn-server/app/data" ) -func AccountOauthEnder(store data.AccountStore, accountId int, provider string) error { +type AccountOauthEnderResult struct { + RequirePasswordReset bool +} + +func AccountOauthEnder(store data.AccountStore, accountId int, provider string) (*AccountOauthEnderResult, error) { + result := &AccountOauthEnderResult{} + account, err := store.Find(accountId) if err != nil { - return err + return nil, err } oauthAccounts, err := store.GetOauthAccounts(accountId) if err != nil { - return err + return nil, err } for _, oAccount := range oauthAccounts { if math.Abs(oAccount.CreatedAt.Sub(account.PasswordChangedAt).Seconds()) < 5 { - return FieldErrors{{Field: "password", Message: ErrPasswordResetRequired}} + result.RequirePasswordReset = true + store.RequireNewPassword(accountId) } } _, err = store.DeleteOauthAccount(accountId, provider) if err != nil { - return err + return nil, err } - return nil + return result, nil } diff --git a/app/services/account_oauth_ender_test.go b/app/services/account_oauth_ender_test.go index 6fdf4152cc..8e404be72b 100644 --- a/app/services/account_oauth_ender_test.go +++ b/app/services/account_oauth_ender_test.go @@ -6,13 +6,12 @@ import ( "github.com/keratin/authn-server/app/data/mock" "github.com/keratin/authn-server/app/services" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestAccountOauthEnder(t *testing.T) { - t.Run("require password reset before delete an account register with oauth flow", func(t *testing.T) { + t.Run("require password reset for an account registered with oauth flow", func(t *testing.T) { accountStore := mock.NewAccountStore() account, err := accountStore.Create("requirepasswordreset@keratin.tech", []byte("password")) require.NoError(t, err) @@ -20,8 +19,14 @@ func TestAccountOauthEnder(t *testing.T) { err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "TOKEN") require.NoError(t, err) - err = services.AccountOauthEnder(accountStore, account.ID, "test") - assert.Equal(t, err, services.FieldErrors{{Field: "password", Message: services.ErrPasswordResetRequired}}) + result, err := services.AccountOauthEnder(accountStore, account.ID, "test") + require.NoError(t, err) + + updatedAccount, err := accountStore.Find(account.ID) + require.NoError(t, err) + + require.Equal(t, updatedAccount.RequireNewPassword, true) + require.Equal(t, result.RequirePasswordReset, true) }) t.Run("delete account", func(t *testing.T) { @@ -34,7 +39,9 @@ func TestAccountOauthEnder(t *testing.T) { err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "TOKEN") require.NoError(t, err) - err = services.AccountOauthEnder(accountStore, account.ID, "test") + result, err := services.AccountOauthEnder(accountStore, account.ID, "test") require.NoError(t, err) + + require.Equal(t, result.RequirePasswordReset, false) }) } diff --git a/server/handlers/delete_oauth.go b/server/handlers/delete_oauth.go index 8c93edd4be..a85302a210 100644 --- a/server/handlers/delete_oauth.go +++ b/server/handlers/delete_oauth.go @@ -12,17 +12,19 @@ func DeleteOauth(app *app.App, providerName string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { accountID := sessions.GetAccountID(r) if accountID == 0 { - WriteJSON(w, http.StatusUnauthorized, nil) + w.WriteHeader(http.StatusUnauthorized) return } - err := services.AccountOauthEnder(app.AccountStore, accountID, providerName) + result, err := services.AccountOauthEnder(app.AccountStore, accountID, providerName) if err != nil { app.Logger.WithError(err).Error("AccountOauthEnder") WriteErrors(w, err) return } - w.WriteHeader(http.StatusNoContent) + WriteData(w, http.StatusOK, map[string]interface{}{ + "require_password_reset": result.RequirePasswordReset, + }) } } diff --git a/server/handlers/delete_oauth_test.go b/server/handlers/delete_oauth_test.go index 1582d623fd..7143df2d2b 100644 --- a/server/handlers/delete_oauth_test.go +++ b/server/handlers/delete_oauth_test.go @@ -6,7 +6,6 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" oauthlib "github.com/keratin/authn-server/lib/oauth" @@ -36,10 +35,13 @@ func TestDeleteOauthAccount(t *testing.T) { t.Run("unanthorized", func(t *testing.T) { res, err := client.Delete("/oauth/test") require.NoError(t, err) - assert.Equal(t, http.StatusUnauthorized, res.StatusCode) + + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + require.Equal(t, []byte{}, test.ReadBody(res)) }) t.Run("delete social account", func(t *testing.T) { + expected := "{\"result\":{\"require_password_reset\":false}}" account, err := app.AccountStore.Create("deleted@keratin.tech", []byte("password")) require.NoError(t, err) @@ -55,6 +57,8 @@ func TestDeleteOauthAccount(t *testing.T) { res, err := client.WithCookie(session).Delete("/oauth/test") require.NoError(t, err) - assert.Equal(t, http.StatusNoContent, res.StatusCode) + + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, []byte(expected), test.ReadBody(res)) }) } From 2a0d7d74ddd565069915b00634d7d2823e9ce707 Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Wed, 27 Mar 2024 16:20:56 -0300 Subject: [PATCH 03/24] Updating GET account/{id} endpoint to returns oauth info, adding public GET oauth/info endpoint, and private DELETE /account/{id}/oauth endpoint --- app/services/account_oauth_ender.go | 10 +-- app/services/account_oauth_ender_test.go | 44 ++++++++++++- app/services/account_oauth_getter.go | 27 ++++++++ app/services/account_oauth_getter_test.go | 57 +++++++++++++++++ app/services/validations.go | 19 +++--- lib/route/client.go | 12 +++- server/handlers/delete_account_oauth.go | 41 ++++++++++++ server/handlers/delete_account_oauth_test.go | 41 ++++++++++++ server/handlers/delete_oauth.go | 2 +- server/handlers/delete_oauth_test.go | 10 +-- server/handlers/get_account.go | 7 +++ server/handlers/get_account_test.go | 65 ++++++++++++++------ server/handlers/get_oauth_info.go | 27 ++++++++ server/handlers/get_oauth_info_test.go | 47 ++++++++++++++ server/private_routes.go | 4 ++ server/public_routes.go | 6 +- 16 files changed, 374 insertions(+), 45 deletions(-) create mode 100644 app/services/account_oauth_getter.go create mode 100644 app/services/account_oauth_getter_test.go create mode 100644 server/handlers/delete_account_oauth.go create mode 100644 server/handlers/delete_account_oauth_test.go create mode 100644 server/handlers/get_oauth_info.go create mode 100644 server/handlers/get_oauth_info_test.go diff --git a/app/services/account_oauth_ender.go b/app/services/account_oauth_ender.go index 5afdc1c6d6..1d48486ffb 100644 --- a/app/services/account_oauth_ender.go +++ b/app/services/account_oauth_ender.go @@ -10,7 +10,7 @@ type AccountOauthEnderResult struct { RequirePasswordReset bool } -func AccountOauthEnder(store data.AccountStore, accountId int, provider string) (*AccountOauthEnderResult, error) { +func AccountOauthEnder(store data.AccountStore, accountId int, providers []string) (*AccountOauthEnderResult, error) { result := &AccountOauthEnderResult{} account, err := store.Find(accountId) @@ -30,9 +30,11 @@ func AccountOauthEnder(store data.AccountStore, accountId int, provider string) } } - _, err = store.DeleteOauthAccount(accountId, provider) - if err != nil { - return nil, err + for _, provider := range providers { + _, err = store.DeleteOauthAccount(accountId, provider) + if err != nil { + return nil, err + } } return result, nil diff --git a/app/services/account_oauth_ender_test.go b/app/services/account_oauth_ender_test.go index 8e404be72b..5db8c84124 100644 --- a/app/services/account_oauth_ender_test.go +++ b/app/services/account_oauth_ender_test.go @@ -19,7 +19,7 @@ func TestAccountOauthEnder(t *testing.T) { err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "TOKEN") require.NoError(t, err) - result, err := services.AccountOauthEnder(accountStore, account.ID, "test") + result, err := services.AccountOauthEnder(accountStore, account.ID, []string{"test"}) require.NoError(t, err) updatedAccount, err := accountStore.Find(account.ID) @@ -29,6 +29,21 @@ func TestAccountOauthEnder(t *testing.T) { require.Equal(t, result.RequirePasswordReset, true) }) + t.Run("delete non existing oauth accounts", func(t *testing.T) { + accountStore := mock.NewAccountStore() + account, err := accountStore.Create("deleted@keratin.tech", []byte("password")) + require.NoError(t, err) + + result, err := services.AccountOauthEnder(accountStore, account.ID, []string{"test"}) + require.NoError(t, err) + + oAccount, err := accountStore.GetOauthAccounts(account.ID) + require.NoError(t, err) + + require.Equal(t, result.RequirePasswordReset, false) + require.Equal(t, len(oAccount), 0) + }) + t.Run("delete account", func(t *testing.T) { accountStore := mock.NewAccountStore() account, err := accountStore.Create("deleted@keratin.tech", []byte("password")) @@ -39,9 +54,34 @@ func TestAccountOauthEnder(t *testing.T) { err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "TOKEN") require.NoError(t, err) - result, err := services.AccountOauthEnder(accountStore, account.ID, "test") + result, err := services.AccountOauthEnder(accountStore, account.ID, []string{"test"}) + require.NoError(t, err) + + oAccount, err := accountStore.GetOauthAccounts(account.ID) require.NoError(t, err) require.Equal(t, result.RequirePasswordReset, false) + require.Equal(t, len(oAccount), 0) + }) + + t.Run("delete multiple accounts", func(t *testing.T) { + accountStore := mock.NewAccountStore() + account, err := accountStore.Create("deleted@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "TOKEN") + require.NoError(t, err) + + err = accountStore.AddOauthAccount(account.ID, "trial", "TESTID", "TOKEN") + require.NoError(t, err) + + result, err := services.AccountOauthEnder(accountStore, account.ID, []string{"test", "trial"}) + require.NoError(t, err) + + oAccount, err := accountStore.GetOauthAccounts(account.ID) + require.NoError(t, err) + + require.Equal(t, result.RequirePasswordReset, true) + require.Equal(t, len(oAccount), 0) }) } diff --git a/app/services/account_oauth_getter.go b/app/services/account_oauth_getter.go new file mode 100644 index 0000000000..7af53184e4 --- /dev/null +++ b/app/services/account_oauth_getter.go @@ -0,0 +1,27 @@ +package services + +import ( + "github.com/keratin/authn-server/app/data" + "github.com/pkg/errors" +) + +func AccountOauthGetter(accountStore data.AccountStore, accountID int) ([]map[string]interface{}, error) { + oAccountsMapped := []map[string]interface{}{} + + oauthAccounts, err := accountStore.GetOauthAccounts(accountID) + if err != nil { + return nil, errors.Wrap(err, "GetOauthAccounts") + } + + for _, oAccount := range oauthAccounts { + oAccountsMapped = append( + oAccountsMapped, + map[string]interface{}{ + "provider": oAccount.Provider, + "provider_id": oAccount.ProviderID, + }, + ) + } + + return oAccountsMapped, nil +} diff --git a/app/services/account_oauth_getter_test.go b/app/services/account_oauth_getter_test.go new file mode 100644 index 0000000000..ec8613ba44 --- /dev/null +++ b/app/services/account_oauth_getter_test.go @@ -0,0 +1,57 @@ +package services_test + +import ( + "sort" + "testing" + + "github.com/keratin/authn-server/app/data/mock" + "github.com/keratin/authn-server/app/services" + "github.com/stretchr/testify/require" +) + +func TestAccountOauthGetter(t *testing.T) { + t.Run("returns empty map when no oauth accounts", func(t *testing.T) { + accountStore := mock.NewAccountStore() + account, err := accountStore.Create("user@keratin.tech", []byte("password")) + require.NoError(t, err) + + accountOauth, err := services.AccountOauthGetter(accountStore, account.ID) + require.NoError(t, err) + + require.Equal(t, 0, len(accountOauth)) + }) + + t.Run("returns oauth accounts for different providers", func(t *testing.T) { + accountStore := mock.NewAccountStore() + account, err := accountStore.Create("user@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = accountStore.AddOauthAccount(account.ID, "test", "ID1", "TOKEN1") + require.NoError(t, err) + + err = accountStore.AddOauthAccount(account.ID, "trial", "ID2", "TOKEN2") + require.NoError(t, err) + + accountOauth, err := services.AccountOauthGetter(accountStore, 1) + require.NoError(t, err) + + sort.Slice(accountOauth, func(i, j int) bool { + return accountOauth[i]["provider_id"].(string) < accountOauth[j]["provider_id"].(string) + }) + + require.Equal( + t, + []map[string]interface{}{ + { + "provider": "test", + "provider_id": "ID1", + }, + { + "provider": "trial", + "provider_id": "ID2", + }, + }, + accountOauth, + ) + }) +} diff --git a/app/services/validations.go b/app/services/validations.go index 70b0399219..479ac0852d 100644 --- a/app/services/validations.go +++ b/app/services/validations.go @@ -8,16 +8,15 @@ import ( ) var ( - ErrMissing = "MISSING" - ErrTaken = "TAKEN" - ErrFormatInvalid = "FORMAT_INVALID" - ErrInsecure = "INSECURE" - ErrFailed = "FAILED" - ErrLocked = "LOCKED" - ErrExpired = "EXPIRED" - ErrNotFound = "NOT_FOUND" - ErrInvalidOrExpired = "INVALID_OR_EXPIRED" - ErrPasswordResetRequired = "PASSWORD_RESET_REQUIRED" + ErrMissing = "MISSING" + ErrTaken = "TAKEN" + ErrFormatInvalid = "FORMAT_INVALID" + ErrInsecure = "INSECURE" + ErrFailed = "FAILED" + ErrLocked = "LOCKED" + ErrExpired = "EXPIRED" + ErrNotFound = "NOT_FOUND" + ErrInvalidOrExpired = "INVALID_OR_EXPIRED" ) type FieldError struct { diff --git a/lib/route/client.go b/lib/route/client.go index bbe0d0bedd..22586549b2 100644 --- a/lib/route/client.go +++ b/lib/route/client.go @@ -28,7 +28,7 @@ const ( put = "PUT" options = "OPTIONS" - contentTypeJSON = "application/json" + contentTypeJSON = "application/json" contentTypeFormURLEncoded = "application/x-www-form-urlencoded" ) @@ -102,6 +102,16 @@ func (c *Client) Delete(path string) (*http.Response, error) { return c.do(delete, contentTypeFormURLEncoded, path, nil) } +// DeleteJSON issues a DELETE to the specified path like net/http's Delete, but with any modifications +// configured for the current client and accepting a JSON content. +func (c *Client) DeleteJSON(path string, content map[string]interface{}) (*http.Response, error) { + marshalled, err := json.Marshal(content) + if err != nil { + return nil, err + } + return c.do(delete, contentTypeJSON, path, bytes.NewReader(marshalled)) +} + // PostForm issues a POST to the specified path like net/http's PostForm, but with any modifications // configured for the current client. func (c *Client) PostForm(path string, form url.Values) (*http.Response, error) { diff --git a/server/handlers/delete_account_oauth.go b/server/handlers/delete_account_oauth.go new file mode 100644 index 0000000000..6ec57c3642 --- /dev/null +++ b/server/handlers/delete_account_oauth.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/keratin/authn-server/app" + "github.com/keratin/authn-server/app/services" + "github.com/keratin/authn-server/lib/parse" +) + +func DeleteAccountOauth(app *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + accountID, err := strconv.Atoi(mux.Vars(r)["id"]) + if err != nil { + WriteNotFound(w, "account") + return + } + + var payload struct { + OauthProviders []string `json:"oauth_providers"` + } + + if err := parse.Payload(r, &payload); err != nil { + WriteErrors(w, err) + return + } + + result, err := services.AccountOauthEnder(app.AccountStore, accountID, payload.OauthProviders) + if err != nil { + app.Logger.WithError(err).Error("AccountOauthEnder") + WriteErrors(w, err) + return + } + + WriteData(w, http.StatusOK, map[string]interface{}{ + "require_password_reset": result.RequirePasswordReset, + }) + } +} diff --git a/server/handlers/delete_account_oauth_test.go b/server/handlers/delete_account_oauth_test.go new file mode 100644 index 0000000000..5c67fa0534 --- /dev/null +++ b/server/handlers/delete_account_oauth_test.go @@ -0,0 +1,41 @@ +package handlers_test + +import ( + "net/http" + "testing" + + "github.com/keratin/authn-server/lib/route" + "github.com/keratin/authn-server/server/test" + "github.com/stretchr/testify/require" +) + +func TestDeleteAccountOauth(t *testing.T) { + app := test.App() + + server := test.Server(app) + defer server.Close() + + client := route.NewClient(server.URL).Authenticated(app.Config.AuthUsername, app.Config.AuthPassword) + + http.DefaultClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + t.Run("delete social account", func(t *testing.T) { + expected := "{\"result\":{\"require_password_reset\":true}}" + account, err := app.AccountStore.Create("deleted-social-account@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID", "TOKEN") + require.NoError(t, err) + + session := test.CreateSession(app.RefreshTokenStore, app.Config, account.ID) + + payload := map[string]interface{}{"oauth_providers": []string{"test"}} + res, err := client.WithCookie(session).DeleteJSON("/accounts/1/oauth", payload) + require.NoError(t, err) + + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, []byte(expected), test.ReadBody(res)) + }) +} diff --git a/server/handlers/delete_oauth.go b/server/handlers/delete_oauth.go index a85302a210..44b479d724 100644 --- a/server/handlers/delete_oauth.go +++ b/server/handlers/delete_oauth.go @@ -16,7 +16,7 @@ func DeleteOauth(app *app.App, providerName string) http.HandlerFunc { return } - result, err := services.AccountOauthEnder(app.AccountStore, accountID, providerName) + result, err := services.AccountOauthEnder(app.AccountStore, accountID, []string{providerName}) if err != nil { app.Logger.WithError(err).Error("AccountOauthEnder") WriteErrors(w, err) diff --git a/server/handlers/delete_oauth_test.go b/server/handlers/delete_oauth_test.go index 7143df2d2b..6342454d22 100644 --- a/server/handlers/delete_oauth_test.go +++ b/server/handlers/delete_oauth_test.go @@ -4,7 +4,6 @@ import ( "net/http" "net/http/httptest" "testing" - "time" "github.com/stretchr/testify/require" @@ -23,7 +22,7 @@ func TestDeleteOauthAccount(t *testing.T) { server := test.Server(app) defer server.Close() - client := route.NewClient(server.URL).WithCookie(&http.Cookie{ + client := route.NewClient(server.URL).Referred(&app.Config.ApplicationDomains[0]).WithCookie(&http.Cookie{ Name: app.Config.OAuthCookieName, Value: "", }) @@ -41,18 +40,13 @@ func TestDeleteOauthAccount(t *testing.T) { }) t.Run("delete social account", func(t *testing.T) { - expected := "{\"result\":{\"require_password_reset\":false}}" + expected := "{\"result\":{\"require_password_reset\":true}}" account, err := app.AccountStore.Create("deleted@keratin.tech", []byte("password")) require.NoError(t, err) err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID", "TOKEN") require.NoError(t, err) - time.Sleep(5 * time.Second) - - _, err = app.AccountStore.SetPassword(account.ID, []byte("password")) - require.NoError(t, err) - session := test.CreateSession(app.RefreshTokenStore, app.Config, account.ID) res, err := client.WithCookie(session).Delete("/oauth/test") diff --git a/server/handlers/get_account.go b/server/handlers/get_account.go index 386f6ef659..be8a6427fb 100644 --- a/server/handlers/get_account.go +++ b/server/handlers/get_account.go @@ -28,6 +28,12 @@ func GetAccount(app *app.App) http.HandlerFunc { panic(err) } + oautAccounts, err := services.AccountOauthGetter(app.AccountStore, id) + if err != nil { + WriteErrors(w, err) + return + } + formattedLastLogin := "" if account.LastLoginAt != nil { formattedLastLogin = account.LastLoginAt.Format(time.RFC3339) @@ -41,6 +47,7 @@ func GetAccount(app *app.App) http.HandlerFunc { WriteData(w, http.StatusOK, map[string]interface{}{ "id": account.ID, "username": account.Username, + "oauth_accounts": oautAccounts, "last_login_at": formattedLastLogin, "password_changed_at": formattedPasswordChangedAt, "locked": account.Locked, diff --git a/server/handlers/get_account_test.go b/server/handlers/get_account_test.go index 4423ad8a00..af5c84130b 100644 --- a/server/handlers/get_account_test.go +++ b/server/handlers/get_account_test.go @@ -3,6 +3,7 @@ package handlers_test import ( "fmt" "net/http" + "sort" "testing" "github.com/keratin/authn-server/app/models" @@ -35,32 +36,60 @@ func TestGetAccount(t *testing.T) { account, err := app.AccountStore.Create("unlocked@test.com", []byte("bar")) require.NoError(t, err) + err = app.AccountStore.AddOauthAccount(account.ID, "test", "ID1", "TOKEN1") + require.NoError(t, err) + + err = app.AccountStore.AddOauthAccount(account.ID, "trial", "ID2", "TOKEN2") + require.NoError(t, err) + + oauthAccounts, err := app.AccountStore.GetOauthAccounts(account.ID) + require.NoError(t, err) + res, err := client.Get(fmt.Sprintf("/accounts/%v", account.ID)) require.NoError(t, err) assert.Equal(t, http.StatusOK, res.StatusCode) - assertGetAccountResponse(t, res, account) + + assertGetAccountResponse(t, res, account, oauthAccounts) }) } -func assertGetAccountResponse(t *testing.T, res *http.Response, acc *models.Account) { +func assertGetAccountResponse(t *testing.T, res *http.Response, acc *models.Account, oAccs []*models.OauthAccount) { // check that the response contains the expected json - assert.Equal(t, []string{"application/json"}, res.Header["Content-Type"]) - responseData := struct { - ID int `json:"id"` - Username string `json:"username"` - LastLoginAt string `json:"last_login_at"` - PasswordChangedAt string `json:"password_changed_at"` - Locked bool `json:"locked"` - Deleted bool `json:"deleted_at"` - }{} + type response struct { + ID int `json:"id"` + Username string `json:"username"` + OauthAccounts []map[string]interface{} `json:"oauth_accounts"` + LastLoginAt string `json:"last_login_at"` + PasswordChangedAt string `json:"password_changed_at"` + Locked bool `json:"locked"` + Deleted bool `json:"deleted_at"` + } + + var responseData response err := test.ExtractResult(res, &responseData) assert.NoError(t, err) - assert.Equal(t, acc.Username, responseData.Username) - assert.Equal(t, acc.ID, responseData.ID) - // NOTE: acc.LastLoginAt is empty so the API returns an empty response - assert.Equal(t, "", responseData.LastLoginAt) - assert.Equal(t, acc.PasswordChangedAt.Format("2006-01-02T15:04:05Z07:00"), responseData.PasswordChangedAt) - assert.Equal(t, false, responseData.Locked) - assert.Equal(t, false, responseData.Deleted) + sort.Slice(oAccs, func(i, j int) bool { + return oAccs[i].Provider < oAccs[j].Provider + }) + + assert.Equal(t, []string{"application/json"}, res.Header["Content-Type"]) + assert.Equal(t, responseData, response{ + ID: acc.ID, + Username: acc.Username, + OauthAccounts: []map[string]interface{}{ + { + "provider": "test", + "provider_id": oAccs[0].ProviderID, + }, + { + "provider": "trial", + "provider_id": oAccs[1].ProviderID, + }, + }, + LastLoginAt: "", + PasswordChangedAt: acc.PasswordChangedAt.Format("2006-01-02T15:04:05Z07:00"), + Locked: false, + Deleted: false, + }) } diff --git a/server/handlers/get_oauth_info.go b/server/handlers/get_oauth_info.go new file mode 100644 index 0000000000..2740e4deab --- /dev/null +++ b/server/handlers/get_oauth_info.go @@ -0,0 +1,27 @@ +package handlers + +import ( + "net/http" + + "github.com/keratin/authn-server/app" + "github.com/keratin/authn-server/app/services" + "github.com/keratin/authn-server/server/sessions" +) + +func GetOauthInfo(app *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + accountID := sessions.GetAccountID(r) + if accountID == 0 { + w.WriteHeader(http.StatusUnauthorized) + return + } + + oAccounts, err := services.AccountOauthGetter(app.AccountStore, accountID) + if err != nil { + WriteErrors(w, err) + return + } + + WriteData(w, http.StatusOK, oAccounts) + } +} diff --git a/server/handlers/get_oauth_info_test.go b/server/handlers/get_oauth_info_test.go new file mode 100644 index 0000000000..069a1c2f1c --- /dev/null +++ b/server/handlers/get_oauth_info_test.go @@ -0,0 +1,47 @@ +package handlers_test + +import ( + "net/http" + "testing" + + "github.com/keratin/authn-server/lib/route" + "github.com/keratin/authn-server/server/test" + "github.com/stretchr/testify/require" +) + +func TestGetOauthInfo(t *testing.T) { + app := test.App() + + server := test.Server(app) + defer server.Close() + + client := route.NewClient(server.URL).Referred(&app.Config.ApplicationDomains[0]).WithCookie(&http.Cookie{ + Name: app.Config.OAuthCookieName, + Value: "", + }) + + t.Run("unanthorized", func(t *testing.T) { + res, err := client.Get("/oauth/info") + require.NoError(t, err) + + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + require.Equal(t, []byte{}, test.ReadBody(res)) + }) + + t.Run("get oauth info", func(t *testing.T) { + expected := "{\"result\":[{\"provider\":\"test\",\"provider_id\":\"ID\"}]}" + account, err := app.AccountStore.Create("get-oauth-info@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = app.AccountStore.AddOauthAccount(account.ID, "test", "ID", "TOKEN") + require.NoError(t, err) + + session := test.CreateSession(app.RefreshTokenStore, app.Config, account.ID) + + res, err := client.WithCookie(session).Get("/oauth/info") + require.NoError(t, err) + + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, []byte(expected), test.ReadBody(res)) + }) +} diff --git a/server/private_routes.go b/server/private_routes.go index 9ab0c55730..ac922ceb2c 100644 --- a/server/private_routes.go +++ b/server/private_routes.go @@ -71,6 +71,10 @@ func PrivateRoutes(app *app.App) []*route.HandledRoute { route.Delete("/accounts/{id:[0-9]+}"). SecuredWith(authentication). Handle(handlers.DeleteAccount(app)), + + route.Delete("/accounts/{id:[0-9]+}/oauth"). + SecuredWith(authentication). + Handle(handlers.DeleteAccountOauth(app)), ) if app.Actives != nil { diff --git a/server/public_routes.go b/server/public_routes.go index 29b4a091e0..00c5c67484 100644 --- a/server/public_routes.go +++ b/server/public_routes.go @@ -56,6 +56,10 @@ func PublicRoutes(app *app.App) []*route.HandledRoute { route.Delete("/totp"). SecuredWith(originSecurity). Handle(handlers.DeleteTOTP(app)), + + route.Get("/oauth/info"). + SecuredWith(originSecurity). + Handle(handlers.GetOauthInfo(app)), ) if app.Config.EnableSignup { @@ -104,7 +108,7 @@ func PublicRoutes(app *app.App) []*route.HandledRoute { SecuredWith(route.Unsecured()). Handle(handlers.GetOauthReturn(app, providerName)), route.Delete("/oauth/"+providerName). - SecuredWith(route.Unsecured()). + SecuredWith(originSecurity). Handle(handlers.DeleteOauth(app, providerName)), ) } From effa17cc853e4804575581fb098900ce23969b09 Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Wed, 27 Mar 2024 17:36:04 -0300 Subject: [PATCH 04/24] Adding oauth email column, updating AccountOauthGetter to return the email --- app/data/account_store.go | 2 +- app/data/mock/account_store.go | 3 ++- app/data/mysql/account_store.go | 7 ++++--- app/data/mysql/migrations.go | 13 +++++++++++++ app/data/postgres/account_store.go | 7 ++++--- app/data/postgres/migrations.go | 8 ++++++++ app/data/sqlite3/account_store.go | 7 ++++--- app/data/sqlite3/migrations.go | 11 +++++++++++ app/data/testers/account_store_testers.go | 8 ++++---- app/models/oauth_account.go | 1 + app/services/account_oauth_ender_test.go | 8 ++++---- app/services/account_oauth_getter.go | 1 + app/services/account_oauth_getter_test.go | 6 ++++-- app/services/identity_reconciler.go | 4 ++-- app/services/identity_reconciler_test.go | 6 +++--- server/handlers/delete_account_oauth_test.go | 2 +- server/handlers/delete_oauth_test.go | 2 +- server/handlers/get_account_test.go | 6 ++++-- server/handlers/get_oauth_info_test.go | 4 ++-- server/handlers/get_oauth_return_test.go | 6 +++--- 20 files changed, 77 insertions(+), 35 deletions(-) diff --git a/app/data/account_store.go b/app/data/account_store.go index 28b3b4652b..fed5fd5047 100644 --- a/app/data/account_store.go +++ b/app/data/account_store.go @@ -16,7 +16,7 @@ type AccountStore interface { Find(id int) (*models.Account, error) FindByUsername(u string) (*models.Account, error) FindByOauthAccount(p string, pid string) (*models.Account, error) - AddOauthAccount(id int, p string, pid string, tok string) error + AddOauthAccount(id int, p string, pid string, email string, tok string) error DeleteOauthAccount(id int, p string) (bool, error) GetOauthAccounts(id int) ([]*models.OauthAccount, error) Archive(id int) (bool, error) diff --git a/app/data/mock/account_store.go b/app/data/mock/account_store.go index eec864d5ee..416c4bcb76 100644 --- a/app/data/mock/account_store.go +++ b/app/data/mock/account_store.go @@ -94,7 +94,7 @@ func (s *accountStore) Create(u string, p []byte) (*models.Account, error) { return dupAccount(acc), nil } -func (s *accountStore) AddOauthAccount(accountID int, provider string, providerID string, tok string) error { +func (s *accountStore) AddOauthAccount(accountID int, provider, providerID, email, tok string) error { p := provider + "|" + providerID if s.idByOauthID[p] != 0 { return Error{ErrNotUnique} @@ -107,6 +107,7 @@ func (s *accountStore) AddOauthAccount(accountID int, provider string, providerI now := time.Now() oauthAccount := &models.OauthAccount{ + Email: email, AccountID: accountID, Provider: provider, ProviderID: providerID, diff --git a/app/data/mysql/account_store.go b/app/data/mysql/account_store.go index 1cf84173bb..0fa8ea7b6d 100644 --- a/app/data/mysql/account_store.go +++ b/app/data/mysql/account_store.go @@ -76,13 +76,14 @@ func (db *AccountStore) Create(u string, p []byte) (*models.Account, error) { return account, nil } -func (db *AccountStore) AddOauthAccount(accountID int, provider string, providerID string, accessToken string) error { +func (db *AccountStore) AddOauthAccount(accountID int, provider, providerID, email, accessToken string) error { now := time.Now() _, err := sqlx.NamedExec(db, ` - INSERT INTO oauth_accounts (account_id, provider, provider_id, access_token, created_at, updated_at) - VALUES (:account_id, :provider, :provider_id, :access_token, :created_at, :updated_at) + INSERT INTO oauth_accounts (account_id, provider, provider_id, email, access_token, created_at, updated_at) + VALUES (:account_id, :provider, :provider_id, :email, :access_token, :created_at, :updated_at) `, map[string]interface{}{ + "email": email, "account_id": accountID, "provider": provider, "provider_id": providerID, diff --git a/app/data/mysql/migrations.go b/app/data/mysql/migrations.go index 2e562d46f3..af6976898d 100644 --- a/app/data/mysql/migrations.go +++ b/app/data/mysql/migrations.go @@ -15,6 +15,7 @@ func MigrateDB(db *sqlx.DB) error { createOauthAccounts, createAccountLastLoginAtField, createAccountTOTPFields, + addOauthAccountEmail, } for _, m := range migrations { if err := m(db); err != nil { @@ -61,6 +62,18 @@ func createOauthAccounts(db *sqlx.DB) error { return err } +func addOauthAccountEmail(db *sqlx.DB) error { + _, err := db.Exec(` + ALTER TABLE oauth_accounts ADD COLUMN email VARCHAR(255) DEFAULT NULL; + `) + if mysqlError, ok := err.(*mysql.MySQLError); ok { + if mysqlError.Number == 1060 { // 1060 = Duplicate column name + err = nil + } + } + return err +} + func createAccountLastLoginAtField(db *sqlx.DB) error { _, err := db.Exec(` ALTER TABLE accounts ADD last_login_at DATETIME DEFAULT NULL diff --git a/app/data/postgres/account_store.go b/app/data/postgres/account_store.go index e9558111a9..d4fc6cc45a 100644 --- a/app/data/postgres/account_store.go +++ b/app/data/postgres/account_store.go @@ -88,13 +88,14 @@ func (db *AccountStore) Create(u string, p []byte) (*models.Account, error) { return account, nil } -func (db *AccountStore) AddOauthAccount(accountID int, provider string, providerID string, accessToken string) error { +func (db *AccountStore) AddOauthAccount(accountID int, provider, providerID, email, accessToken string) error { now := time.Now() _, err := sqlx.NamedExec(db, ` - INSERT INTO oauth_accounts (account_id, provider, provider_id, access_token, created_at, updated_at) - VALUES (:account_id, :provider, :provider_id, :access_token, :created_at, :updated_at) + INSERT INTO oauth_accounts (account_id, provider, provider_id, email, access_token, created_at, updated_at) + VALUES (:account_id, :provider, :provider_id, :email, :access_token, :created_at, :updated_at) `, map[string]interface{}{ + "email": email, "account_id": accountID, "provider": provider, "provider_id": providerID, diff --git a/app/data/postgres/migrations.go b/app/data/postgres/migrations.go index a45a1b6067..34440fcc95 100644 --- a/app/data/postgres/migrations.go +++ b/app/data/postgres/migrations.go @@ -13,6 +13,7 @@ func MigrateDB(db *sqlx.DB) error { createAccountLastLoginAtField, caseInsensitiveUsername, createAccountTOTPFields, + addOauthAccountEmail, } for _, m := range migrations { if err := m(db); err != nil { @@ -56,6 +57,13 @@ func createOauthAccounts(db *sqlx.DB) error { return err } +func addOauthAccountEmail(db *sqlx.DB) error { + _, err := db.Exec(` + ALTER TABLE oauth_accounts ADD COLUMN IF NOT EXISTS email VARCHAR(255) DEFAULT NULL; + `) + return err +} + func createAccountLastLoginAtField(db *sqlx.DB) error { _, err := db.Exec(` ALTER TABLE accounts ADD COLUMN IF NOT EXISTS last_login_at timestamptz DEFAULT NULL diff --git a/app/data/sqlite3/account_store.go b/app/data/sqlite3/account_store.go index c3b5c40c7a..f87f114a01 100644 --- a/app/data/sqlite3/account_store.go +++ b/app/data/sqlite3/account_store.go @@ -76,13 +76,14 @@ func (db *AccountStore) Create(u string, p []byte) (*models.Account, error) { return account, nil } -func (db *AccountStore) AddOauthAccount(accountID int, provider string, providerID string, accessToken string) error { +func (db *AccountStore) AddOauthAccount(accountID int, provider, providerID, email, accessToken string) error { now := time.Now() _, err := sqlx.NamedExec(db, ` - INSERT INTO oauth_accounts (account_id, provider, provider_id, access_token, created_at, updated_at) - VALUES (:account_id, :provider, :provider_id, :access_token, :created_at, :updated_at) + INSERT INTO oauth_accounts (account_id, provider, provider_id, email, access_token, created_at, updated_at) + VALUES (:account_id, :provider, :provider_id, :email, :access_token, :created_at, :updated_at) `, map[string]interface{}{ + "email": email, "account_id": accountID, "provider": provider, "provider_id": providerID, diff --git a/app/data/sqlite3/migrations.go b/app/data/sqlite3/migrations.go index 174a2cbc87..71a900b394 100644 --- a/app/data/sqlite3/migrations.go +++ b/app/data/sqlite3/migrations.go @@ -20,6 +20,7 @@ func MigrateDB(db *sqlx.DB) error { createAccountLastLoginAtField, caseInsensitiveUsername, createAccountTOTPFields, + addOauthAccountEmail, } for _, m := range migrations { if err := m(db); err != nil { @@ -96,6 +97,16 @@ func createOauthAccounts(db *sqlx.DB) error { return err } +func addOauthAccountEmail(db *sqlx.DB) error { + _, err := db.Exec(` + ALTER TABLE oauth_accounts ADD COLUMN email VARCHAR(255) DEFAULT NULL; + `) + if isDuplicateError(err) { + return nil + } + return err +} + func createAccountLastLoginAtField(db *sqlx.DB) error { _, err := db.Exec(` ALTER TABLE accounts ADD last_login_at DATETIME diff --git a/app/data/testers/account_store_testers.go b/app/data/testers/account_store_testers.go index f18345feae..9b48d093b6 100644 --- a/app/data/testers/account_store_testers.go +++ b/app/data/testers/account_store_testers.go @@ -141,7 +141,7 @@ func testArchive(t *testing.T, store data.AccountStore) { func testArchiveWithOauth(t *testing.T, store data.AccountStore) { account, err := store.Create("authn@keratin.tech", []byte("password")) require.NoError(t, err) - err = store.AddOauthAccount(account.ID, "PROVIDER", "PROVIDERID", "token") + err = store.AddOauthAccount(account.ID, "PROVIDER", "PROVIDERID", "email", "token") require.NoError(t, err) ok, err := store.Archive(account.ID) @@ -261,7 +261,7 @@ func testAddOauthAccount(t *testing.T, store data.AccountStore) { account, err := store.Create("authn@keratin.tech", []byte("password")) assert.NoError(t, err) - err = store.AddOauthAccount(account.ID, "OAUTHPROVIDER", "PROVIDERID", "TOKEN") + err = store.AddOauthAccount(account.ID, "OAUTHPROVIDER", "PROVIDERID", "email", "TOKEN") assert.NoError(t, err) found, err = store.GetOauthAccounts(account.ID) @@ -274,7 +274,7 @@ func testAddOauthAccount(t *testing.T, store data.AccountStore) { assert.NotEmpty(t, found[0].CreatedAt) assert.NotEmpty(t, found[0].UpdatedAt) - err = store.AddOauthAccount(account.ID, "OAUTHPROVIDER", "PROVIDERID2", "TOKEN") + err = store.AddOauthAccount(account.ID, "OAUTHPROVIDER", "PROVIDERID2", "email", "TOKEN") if err == nil || !data.IsUniquenessError(err) { t.Errorf("expected uniqueness error, got %T %v", err, err) } @@ -290,7 +290,7 @@ func testFindByOauthAccount(t *testing.T, store data.AccountStore) { account, err := store.Create("authn@keratin.tech", []byte("password")) require.NoError(t, err) - err = store.AddOauthAccount(account.ID, "OAUTHPROVIDER", "PROVIDERID", "TOKEN") + err = store.AddOauthAccount(account.ID, "OAUTHPROVIDER", "PROVIDERID", "email", "TOKEN") require.NoError(t, err) found, err = store.FindByOauthAccount("unknown", "PROVIDERID") diff --git a/app/models/oauth_account.go b/app/models/oauth_account.go index ecf48d56f4..5a5a134447 100644 --- a/app/models/oauth_account.go +++ b/app/models/oauth_account.go @@ -7,6 +7,7 @@ type OauthAccount struct { AccountID int `db:"account_id"` Provider string ProviderID string `db:"provider_id"` + Email string `db:"email"` AccessToken string `db:"access_token"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` diff --git a/app/services/account_oauth_ender_test.go b/app/services/account_oauth_ender_test.go index 5db8c84124..a2169e7d59 100644 --- a/app/services/account_oauth_ender_test.go +++ b/app/services/account_oauth_ender_test.go @@ -16,7 +16,7 @@ func TestAccountOauthEnder(t *testing.T) { account, err := accountStore.Create("requirepasswordreset@keratin.tech", []byte("password")) require.NoError(t, err) - err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "TOKEN") + err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "email", "TOKEN") require.NoError(t, err) result, err := services.AccountOauthEnder(accountStore, account.ID, []string{"test"}) @@ -51,7 +51,7 @@ func TestAccountOauthEnder(t *testing.T) { time.Sleep(5 * time.Second) - err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "TOKEN") + err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "email", "TOKEN") require.NoError(t, err) result, err := services.AccountOauthEnder(accountStore, account.ID, []string{"test"}) @@ -69,10 +69,10 @@ func TestAccountOauthEnder(t *testing.T) { account, err := accountStore.Create("deleted@keratin.tech", []byte("password")) require.NoError(t, err) - err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "TOKEN") + err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "email", "TOKEN") require.NoError(t, err) - err = accountStore.AddOauthAccount(account.ID, "trial", "TESTID", "TOKEN") + err = accountStore.AddOauthAccount(account.ID, "trial", "TESTID", "email", "TOKEN") require.NoError(t, err) result, err := services.AccountOauthEnder(accountStore, account.ID, []string{"test", "trial"}) diff --git a/app/services/account_oauth_getter.go b/app/services/account_oauth_getter.go index 7af53184e4..d61f52a792 100644 --- a/app/services/account_oauth_getter.go +++ b/app/services/account_oauth_getter.go @@ -19,6 +19,7 @@ func AccountOauthGetter(accountStore data.AccountStore, accountID int) ([]map[st map[string]interface{}{ "provider": oAccount.Provider, "provider_id": oAccount.ProviderID, + "email": oAccount.Email, }, ) } diff --git a/app/services/account_oauth_getter_test.go b/app/services/account_oauth_getter_test.go index ec8613ba44..562e28b123 100644 --- a/app/services/account_oauth_getter_test.go +++ b/app/services/account_oauth_getter_test.go @@ -26,10 +26,10 @@ func TestAccountOauthGetter(t *testing.T) { account, err := accountStore.Create("user@keratin.tech", []byte("password")) require.NoError(t, err) - err = accountStore.AddOauthAccount(account.ID, "test", "ID1", "TOKEN1") + err = accountStore.AddOauthAccount(account.ID, "test", "ID1", "email1", "TOKEN1") require.NoError(t, err) - err = accountStore.AddOauthAccount(account.ID, "trial", "ID2", "TOKEN2") + err = accountStore.AddOauthAccount(account.ID, "trial", "ID2", "email2", "TOKEN2") require.NoError(t, err) accountOauth, err := services.AccountOauthGetter(accountStore, 1) @@ -45,10 +45,12 @@ func TestAccountOauthGetter(t *testing.T) { { "provider": "test", "provider_id": "ID1", + "email": "email1", }, { "provider": "trial", "provider_id": "ID2", + "email": "email2", }, }, accountOauth, diff --git a/app/services/identity_reconciler.go b/app/services/identity_reconciler.go index e386849335..aae3980e0d 100644 --- a/app/services/identity_reconciler.go +++ b/app/services/identity_reconciler.go @@ -43,7 +43,7 @@ func IdentityReconciler(accountStore data.AccountStore, cfg *app.Config, provide // 2. attempt linking to existing account if linkableAccountID != 0 { - err = accountStore.AddOauthAccount(linkableAccountID, providerName, providerUser.ID, providerToken.AccessToken) + err = accountStore.AddOauthAccount(linkableAccountID, providerName, providerUser.ID, providerUser.Email, providerToken.AccessToken) if err != nil { if data.IsUniquenessError(err) { return nil, errors.New("session conflict") @@ -68,7 +68,7 @@ func IdentityReconciler(accountStore data.AccountStore, cfg *app.Config, provide if err != nil { return nil, errors.Wrap(err, "AccountCreator") } - err = accountStore.AddOauthAccount(newAccount.ID, providerName, providerUser.ID, providerToken.AccessToken) + err = accountStore.AddOauthAccount(newAccount.ID, providerName, providerUser.ID, providerUser.Email, providerToken.AccessToken) if err != nil { // this should not happen since oauth details used to lookup account above // not sure how best to test but feels appropriate to return error if encountered diff --git a/app/services/identity_reconciler_test.go b/app/services/identity_reconciler_test.go index f003ad5deb..fc000e2530 100644 --- a/app/services/identity_reconciler_test.go +++ b/app/services/identity_reconciler_test.go @@ -22,7 +22,7 @@ func TestIdentityReconciler(t *testing.T) { t.Run("linked account", func(t *testing.T) { acct, err := store.Create("linked@test.com", []byte("password")) require.NoError(t, err) - err = store.AddOauthAccount(acct.ID, "testProvider", "123", "TOKEN") + err = store.AddOauthAccount(acct.ID, "testProvider", "123", "email", "TOKEN") require.NoError(t, err) found, err := services.IdentityReconciler(store, cfg, "testProvider", &oauth.UserInfo{ID: "123", Email: "linked@test.com"}, &oauth2.Token{}, 0) @@ -35,7 +35,7 @@ func TestIdentityReconciler(t *testing.T) { t.Run("linked account that is locked", func(t *testing.T) { acct, err := store.Create("linkedlocked@test.com", []byte("password")) require.NoError(t, err) - err = store.AddOauthAccount(acct.ID, "testProvider", "234", "TOKEN") + err = store.AddOauthAccount(acct.ID, "testProvider", "234", "email", "TOKEN") require.NoError(t, err) _, err = store.Lock(acct.ID) require.NoError(t, err) @@ -59,7 +59,7 @@ func TestIdentityReconciler(t *testing.T) { t.Run("linkable account that is linked", func(t *testing.T) { acct, err := store.Create("linkablelinked@test.com", []byte("password")) require.NoError(t, err) - err = store.AddOauthAccount(acct.ID, "testProvider", "0", "TOKEN") + err = store.AddOauthAccount(acct.ID, "testProvider", "0", "email", "TOKEN") require.NoError(t, err) found, err := services.IdentityReconciler(store, cfg, "testProvider", &oauth.UserInfo{ID: "456", Email: "linkablelinked@test.com"}, &oauth2.Token{}, acct.ID) diff --git a/server/handlers/delete_account_oauth_test.go b/server/handlers/delete_account_oauth_test.go index 5c67fa0534..8ed212d792 100644 --- a/server/handlers/delete_account_oauth_test.go +++ b/server/handlers/delete_account_oauth_test.go @@ -26,7 +26,7 @@ func TestDeleteAccountOauth(t *testing.T) { account, err := app.AccountStore.Create("deleted-social-account@keratin.tech", []byte("password")) require.NoError(t, err) - err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID", "TOKEN") + err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID", "email", "TOKEN") require.NoError(t, err) session := test.CreateSession(app.RefreshTokenStore, app.Config, account.ID) diff --git a/server/handlers/delete_oauth_test.go b/server/handlers/delete_oauth_test.go index 6342454d22..c4960f6f5b 100644 --- a/server/handlers/delete_oauth_test.go +++ b/server/handlers/delete_oauth_test.go @@ -44,7 +44,7 @@ func TestDeleteOauthAccount(t *testing.T) { account, err := app.AccountStore.Create("deleted@keratin.tech", []byte("password")) require.NoError(t, err) - err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID", "TOKEN") + err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID", "email", "TOKEN") require.NoError(t, err) session := test.CreateSession(app.RefreshTokenStore, app.Config, account.ID) diff --git a/server/handlers/get_account_test.go b/server/handlers/get_account_test.go index af5c84130b..d02abfb5a6 100644 --- a/server/handlers/get_account_test.go +++ b/server/handlers/get_account_test.go @@ -36,10 +36,10 @@ func TestGetAccount(t *testing.T) { account, err := app.AccountStore.Create("unlocked@test.com", []byte("bar")) require.NoError(t, err) - err = app.AccountStore.AddOauthAccount(account.ID, "test", "ID1", "TOKEN1") + err = app.AccountStore.AddOauthAccount(account.ID, "test", "ID1", "email", "TOKEN1") require.NoError(t, err) - err = app.AccountStore.AddOauthAccount(account.ID, "trial", "ID2", "TOKEN2") + err = app.AccountStore.AddOauthAccount(account.ID, "trial", "ID2", "email", "TOKEN2") require.NoError(t, err) oauthAccounts, err := app.AccountStore.GetOauthAccounts(account.ID) @@ -81,10 +81,12 @@ func assertGetAccountResponse(t *testing.T, res *http.Response, acc *models.Acco { "provider": "test", "provider_id": oAccs[0].ProviderID, + "email": oAccs[0].Email, }, { "provider": "trial", "provider_id": oAccs[1].ProviderID, + "email": oAccs[1].Email, }, }, LastLoginAt: "", diff --git a/server/handlers/get_oauth_info_test.go b/server/handlers/get_oauth_info_test.go index 069a1c2f1c..0d3fffb906 100644 --- a/server/handlers/get_oauth_info_test.go +++ b/server/handlers/get_oauth_info_test.go @@ -29,11 +29,11 @@ func TestGetOauthInfo(t *testing.T) { }) t.Run("get oauth info", func(t *testing.T) { - expected := "{\"result\":[{\"provider\":\"test\",\"provider_id\":\"ID\"}]}" + expected := "{\"result\":[{\"email\":\"email\",\"provider\":\"test\",\"provider_id\":\"ID\"}]}" account, err := app.AccountStore.Create("get-oauth-info@keratin.tech", []byte("password")) require.NoError(t, err) - err = app.AccountStore.AddOauthAccount(account.ID, "test", "ID", "TOKEN") + err = app.AccountStore.AddOauthAccount(account.ID, "test", "ID", "email", "TOKEN") require.NoError(t, err) session := test.CreateSession(app.RefreshTokenStore, app.Config, account.ID) diff --git a/server/handlers/get_oauth_return_test.go b/server/handlers/get_oauth_return_test.go index 54abfd6fc7..ba02154a19 100644 --- a/server/handlers/get_oauth_return_test.go +++ b/server/handlers/get_oauth_return_test.go @@ -73,7 +73,7 @@ func TestGetOauthReturn(t *testing.T) { t.Run("not connect new identity with current session that is already linked", func(t *testing.T) { account, err := app.AccountStore.Create("linked@keratin.tech", []byte("password")) require.NoError(t, err) - err = app.AccountStore.AddOauthAccount(account.ID, "test", "PREVIOUSID", "TOKEN") + err = app.AccountStore.AddOauthAccount(account.ID, "test", "PREVIOUSID", "email", "TOKEN") require.NoError(t, err) session := test.CreateSession(app.RefreshTokenStore, app.Config, account.ID) @@ -86,7 +86,7 @@ func TestGetOauthReturn(t *testing.T) { linkedAccount, err := app.AccountStore.Create("linked.account@keratin.tech", []byte("password")) require.NoError(t, err) - err = app.AccountStore.AddOauthAccount(linkedAccount.ID, "test", "LINKEDID", "TOKEN") + err = app.AccountStore.AddOauthAccount(linkedAccount.ID, "test", "LINKEDID", "email", "TOKEN") require.NoError(t, err) account, err := app.AccountStore.Create("registered.account@keratin.tech", []byte("password")) @@ -102,7 +102,7 @@ func TestGetOauthReturn(t *testing.T) { t.Run("log in to existing identity", func(t *testing.T) { account, err := app.AccountStore.Create("registered@keratin.tech", []byte("password")) require.NoError(t, err) - err = app.AccountStore.AddOauthAccount(account.ID, "test", "REGISTEREDID", "TOKEN") + err = app.AccountStore.AddOauthAccount(account.ID, "test", "REGISTEREDID", "email", "TOKEN") require.NoError(t, err) // codes don't normally specify the id, but our test provider is set up to reflect the code From 359f9e0036948aed814cd6afbff3511d1f6358a2 Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Wed, 27 Mar 2024 18:53:12 -0300 Subject: [PATCH 05/24] Returning not found status code when attempting to delete an oauth account from an user that does not exists. --- app/services/account_oauth_ender.go | 4 ++++ server/handlers/delete_account_oauth.go | 6 ++++++ server/handlers/delete_account_oauth_test.go | 12 +++++++++--- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/services/account_oauth_ender.go b/app/services/account_oauth_ender.go index 1d48486ffb..194413ac08 100644 --- a/app/services/account_oauth_ender.go +++ b/app/services/account_oauth_ender.go @@ -18,6 +18,10 @@ func AccountOauthEnder(store data.AccountStore, accountId int, providers []strin return nil, err } + if account == nil { + return nil, FieldErrors{{"account", ErrNotFound}} + } + oauthAccounts, err := store.GetOauthAccounts(accountId) if err != nil { return nil, err diff --git a/server/handlers/delete_account_oauth.go b/server/handlers/delete_account_oauth.go index 6ec57c3642..6a6115042e 100644 --- a/server/handlers/delete_account_oauth.go +++ b/server/handlers/delete_account_oauth.go @@ -30,6 +30,12 @@ func DeleteAccountOauth(app *app.App) http.HandlerFunc { result, err := services.AccountOauthEnder(app.AccountStore, accountID, payload.OauthProviders) if err != nil { app.Logger.WithError(err).Error("AccountOauthEnder") + + if _, ok := err.(services.FieldErrors); ok { + WriteNotFound(w, "account") + return + } + WriteErrors(w, err) return } diff --git a/server/handlers/delete_account_oauth_test.go b/server/handlers/delete_account_oauth_test.go index 8ed212d792..4c509ac9a9 100644 --- a/server/handlers/delete_account_oauth_test.go +++ b/server/handlers/delete_account_oauth_test.go @@ -29,13 +29,19 @@ func TestDeleteAccountOauth(t *testing.T) { err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID", "email", "TOKEN") require.NoError(t, err) - session := test.CreateSession(app.RefreshTokenStore, app.Config, account.ID) - payload := map[string]interface{}{"oauth_providers": []string{"test"}} - res, err := client.WithCookie(session).DeleteJSON("/accounts/1/oauth", payload) + res, err := client.DeleteJSON("/accounts/1/oauth", payload) require.NoError(t, err) require.Equal(t, http.StatusOK, res.StatusCode) require.Equal(t, []byte(expected), test.ReadBody(res)) }) + + t.Run("return not found when user does not exists", func(t *testing.T) { + payload := map[string]interface{}{"oauth_providers": []string{"test"}} + res, err := client.DeleteJSON("/accounts/9999/oauth", payload) + require.NoError(t, err) + + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) } From b5ebe3f905c24251855f0aa336ba909dfb76e797 Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Wed, 27 Mar 2024 18:53:52 -0300 Subject: [PATCH 06/24] Adding three new endpoints to the api documentation. --- docs/api.md | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/docs/api.md b/docs/api.md index c07d92a105..ebccd22160 100644 --- a/docs/api.md +++ b/docs/api.md @@ -295,6 +295,30 @@ Visibility: Private ] } +### Archive Account + +Visibility: Private + +`DELETE /accounts/:id/oauth` + +| Params | Type | Notes | +| ------ | ------- | --------------- | +| `id` | integer | User account Id | + +#### Success: + + 200 Ok + + { + "result": { + "require_password_reset": true|false + } + } + +#### Failure: + + 404 Not Found + ### Import Account Visibility: Private @@ -658,6 +682,56 @@ If the OAuth process failed, the redirect will have `status=failed` appended to 303 See Other Location: (redirect URI with status=failed) +#### Get OAuth accounts info + +Visibility: Public + +`Get /oauth/info` + +Returns relevant oauth information for the current session. + +#### Success: + + 200 Ok + + { + "result": [ + "provider": "google"|"apple", + "provider_id": "91293", + "email": "authn@keratin.com" + ] + } + +#### Failure: + + 401 Unauthorized + +#### Delete OAuth account + +Visibility: Public + +`DELETE /oauth/:providerName` + +| Params | Type | Notes | +| ------ | ---- | ----- | +| `providerName` | string | * google | + +Delete an OAuth account from the current session. If the session was initiated via the OAuth flow, the account will be flagged to reset the password during the next sign-in attempt. + +#### Success: + + 200 Ok + + { + "result": { + "require_password_reset": true|false + } + } + +#### Failure: + + 401 Unauthorized + ### Multi-Factor Authentication (MFA) **NOTE** - AuthN MFA support is currently considered in beta. The API will not be considered stable until v2. From 7af21935806c5904b31b2891fe5c52988ee099bd Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Wed, 27 Mar 2024 22:15:52 -0300 Subject: [PATCH 07/24] Fixing the PostgreSQL query for deleting OAuth accounts. --- app/data/postgres/account_store.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/data/postgres/account_store.go b/app/data/postgres/account_store.go index d4fc6cc45a..f6b7fa3715 100644 --- a/app/data/postgres/account_store.go +++ b/app/data/postgres/account_store.go @@ -113,7 +113,7 @@ func (db *AccountStore) GetOauthAccounts(accountID int) ([]*models.OauthAccount, } func (db *AccountStore) DeleteOauthAccount(accountId int, provider string) (bool, error) { - result, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = ? AND provider = ?", accountId, provider) + result, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = $1 AND provider = $2", accountId, provider) if err != nil { return false, err } From 31f938236777bf75d4b161bc3c742c9d5c84680f Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Wed, 27 Mar 2024 23:04:20 -0300 Subject: [PATCH 08/24] Adding new field oauth_accounts to GET /account/{id} documentation. --- docs/api.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/api.md b/docs/api.md index ebccd22160..d37f6d8660 100644 --- a/docs/api.md +++ b/docs/api.md @@ -142,6 +142,13 @@ Visibility: Private "result": { "id": , "username": "...", + "oauth_accounts": [ + { + "provider": "google"|"apple", + "provider_id": "91293", + "email": "authn@keratin.com" + } + ], "last_login_at": "2006-01-02T15:04:05Z07:00", "password_changed_at": "2006-01-02T15:04:05Z07:00", "locked": false, @@ -696,9 +703,11 @@ Returns relevant oauth information for the current session. { "result": [ - "provider": "google"|"apple", - "provider_id": "91293", - "email": "authn@keratin.com" + { + "provider": "google"|"apple", + "provider_id": "91293", + "email": "authn@keratin.com" + } ] } From 0084a84411b73ecb1296a7dcb7d37d1aeb68fef2 Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Thu, 28 Mar 2024 10:30:12 -0300 Subject: [PATCH 09/24] Implementing 422 when trying to delete oauth provider and user has a random provider. --- app/services/account_oauth_ender.go | 32 +++++++++++--------- app/services/account_oauth_ender_test.go | 21 +++++-------- app/services/validations.go | 19 ++++++------ docs/api.md | 29 +++++++++++------- lib/route/client.go | 2 +- server/handlers/delete_account_oauth.go | 24 +++++++++++---- server/handlers/delete_account_oauth_test.go | 23 ++++++++++++-- server/handlers/delete_oauth.go | 6 ++-- server/handlers/delete_oauth_test.go | 22 +++++++++++++- 9 files changed, 115 insertions(+), 63 deletions(-) diff --git a/app/services/account_oauth_ender.go b/app/services/account_oauth_ender.go index 194413ac08..17f3ff8307 100644 --- a/app/services/account_oauth_ender.go +++ b/app/services/account_oauth_ender.go @@ -2,44 +2,46 @@ package services import ( "math" + "strings" "github.com/keratin/authn-server/app/data" ) -type AccountOauthEnderResult struct { - RequirePasswordReset bool -} - -func AccountOauthEnder(store data.AccountStore, accountId int, providers []string) (*AccountOauthEnderResult, error) { - result := &AccountOauthEnderResult{} - +func AccountOauthEnder(store data.AccountStore, accountId int, providers []string) error { account, err := store.Find(accountId) if err != nil { - return nil, err + return err } if account == nil { - return nil, FieldErrors{{"account", ErrNotFound}} + return FieldErrors{{"account", ErrNotFound}} } oauthAccounts, err := store.GetOauthAccounts(accountId) if err != nil { - return nil, err + return err + } + + mappedProviders := map[string]uint8{} + for _, provider := range providers { + mappedProviders[strings.ToLower(provider)] = 1 } for _, oAccount := range oauthAccounts { - if math.Abs(oAccount.CreatedAt.Sub(account.PasswordChangedAt).Seconds()) < 5 { - result.RequirePasswordReset = true - store.RequireNewPassword(accountId) + _, isProviderMatched := mappedProviders[strings.ToLower(oAccount.Provider)] + hasRandomOauthPassword := math.Abs(oAccount.CreatedAt.Sub(account.PasswordChangedAt).Seconds()) < 5 + + if hasRandomOauthPassword && isProviderMatched { + return FieldErrors{{"password", ErrNewPasswordRequired}} } } for _, provider := range providers { _, err = store.DeleteOauthAccount(accountId, provider) if err != nil { - return nil, err + return err } } - return result, nil + return nil } diff --git a/app/services/account_oauth_ender_test.go b/app/services/account_oauth_ender_test.go index a2169e7d59..35a626396b 100644 --- a/app/services/account_oauth_ender_test.go +++ b/app/services/account_oauth_ender_test.go @@ -19,14 +19,8 @@ func TestAccountOauthEnder(t *testing.T) { err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "email", "TOKEN") require.NoError(t, err) - result, err := services.AccountOauthEnder(accountStore, account.ID, []string{"test"}) - require.NoError(t, err) - - updatedAccount, err := accountStore.Find(account.ID) - require.NoError(t, err) - - require.Equal(t, updatedAccount.RequireNewPassword, true) - require.Equal(t, result.RequirePasswordReset, true) + err = services.AccountOauthEnder(accountStore, account.ID, []string{"test"}) + require.Equal(t, err, services.FieldErrors{{"password", services.ErrNewPasswordRequired}}) }) t.Run("delete non existing oauth accounts", func(t *testing.T) { @@ -34,13 +28,12 @@ func TestAccountOauthEnder(t *testing.T) { account, err := accountStore.Create("deleted@keratin.tech", []byte("password")) require.NoError(t, err) - result, err := services.AccountOauthEnder(accountStore, account.ID, []string{"test"}) + err = services.AccountOauthEnder(accountStore, account.ID, []string{"test"}) require.NoError(t, err) oAccount, err := accountStore.GetOauthAccounts(account.ID) require.NoError(t, err) - require.Equal(t, result.RequirePasswordReset, false) require.Equal(t, len(oAccount), 0) }) @@ -54,13 +47,12 @@ func TestAccountOauthEnder(t *testing.T) { err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "email", "TOKEN") require.NoError(t, err) - result, err := services.AccountOauthEnder(accountStore, account.ID, []string{"test"}) + err = services.AccountOauthEnder(accountStore, account.ID, []string{"test"}) require.NoError(t, err) oAccount, err := accountStore.GetOauthAccounts(account.ID) require.NoError(t, err) - require.Equal(t, result.RequirePasswordReset, false) require.Equal(t, len(oAccount), 0) }) @@ -69,19 +61,20 @@ func TestAccountOauthEnder(t *testing.T) { account, err := accountStore.Create("deleted@keratin.tech", []byte("password")) require.NoError(t, err) + time.Sleep(5 * time.Second) + err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "email", "TOKEN") require.NoError(t, err) err = accountStore.AddOauthAccount(account.ID, "trial", "TESTID", "email", "TOKEN") require.NoError(t, err) - result, err := services.AccountOauthEnder(accountStore, account.ID, []string{"test", "trial"}) + err = services.AccountOauthEnder(accountStore, account.ID, []string{"test", "trial"}) require.NoError(t, err) oAccount, err := accountStore.GetOauthAccounts(account.ID) require.NoError(t, err) - require.Equal(t, result.RequirePasswordReset, true) require.Equal(t, len(oAccount), 0) }) } diff --git a/app/services/validations.go b/app/services/validations.go index 479ac0852d..c6bae9a08d 100644 --- a/app/services/validations.go +++ b/app/services/validations.go @@ -8,15 +8,16 @@ import ( ) var ( - ErrMissing = "MISSING" - ErrTaken = "TAKEN" - ErrFormatInvalid = "FORMAT_INVALID" - ErrInsecure = "INSECURE" - ErrFailed = "FAILED" - ErrLocked = "LOCKED" - ErrExpired = "EXPIRED" - ErrNotFound = "NOT_FOUND" - ErrInvalidOrExpired = "INVALID_OR_EXPIRED" + ErrMissing = "MISSING" + ErrTaken = "TAKEN" + ErrFormatInvalid = "FORMAT_INVALID" + ErrInsecure = "INSECURE" + ErrFailed = "FAILED" + ErrLocked = "LOCKED" + ErrExpired = "EXPIRED" + ErrNotFound = "NOT_FOUND" + ErrInvalidOrExpired = "INVALID_OR_EXPIRED" + ErrNewPasswordRequired = "NEW_PASSWORD_REQUIRED" ) type FieldError struct { diff --git a/docs/api.md b/docs/api.md index d37f6d8660..4fae7daf45 100644 --- a/docs/api.md +++ b/docs/api.md @@ -316,16 +316,19 @@ Visibility: Private 200 Ok - { - "result": { - "require_password_reset": true|false - } - } + {} #### Failure: - 404 Not Found + 422 Unprocessable Entity + + { + "errors": [ + {"field": "password", "message": "NEW_PASSWORD_REQUIRED"} + ] + } + ### Import Account Visibility: Private @@ -731,16 +734,20 @@ Delete an OAuth account from the current session. If the session was initiated v 200 Ok - { - "result": { - "require_password_reset": true|false - } - } + {} #### Failure: 401 Unauthorized + 422 Unprocessable Entity + + { + "errors": [ + {"field": "password", "message": "NEW_PASSWORD_REQUIRED"} + ] + } + ### Multi-Factor Authentication (MFA) **NOTE** - AuthN MFA support is currently considered in beta. The API will not be considered stable until v2. diff --git a/lib/route/client.go b/lib/route/client.go index 22586549b2..884fc00345 100644 --- a/lib/route/client.go +++ b/lib/route/client.go @@ -155,7 +155,7 @@ func (c *Client) do(verb string, contentType string, path string, body io.Reader return nil, err } - if verb == post || verb == patch || verb == put { + if verb == post || verb == patch || verb == put || verb == delete { req.Header.Add("Content-Type", contentType) } diff --git a/server/handlers/delete_account_oauth.go b/server/handlers/delete_account_oauth.go index 6a6115042e..c27cc4f66e 100644 --- a/server/handlers/delete_account_oauth.go +++ b/server/handlers/delete_account_oauth.go @@ -12,6 +12,20 @@ import ( func DeleteAccountOauth(app *app.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + isNotFoundErr := func(err error) (string, bool) { + fieldErr, ok := err.(services.FieldErrors) + + if ok { + for _, err := range fieldErr { + if err.Message == services.ErrNotFound { + return err.Field, true + } + } + } + + return "", false + } + accountID, err := strconv.Atoi(mux.Vars(r)["id"]) if err != nil { WriteNotFound(w, "account") @@ -27,12 +41,12 @@ func DeleteAccountOauth(app *app.App) http.HandlerFunc { return } - result, err := services.AccountOauthEnder(app.AccountStore, accountID, payload.OauthProviders) + err = services.AccountOauthEnder(app.AccountStore, accountID, payload.OauthProviders) if err != nil { app.Logger.WithError(err).Error("AccountOauthEnder") - if _, ok := err.(services.FieldErrors); ok { - WriteNotFound(w, "account") + if resource, ok := isNotFoundErr(err); ok { + WriteNotFound(w, resource) return } @@ -40,8 +54,6 @@ func DeleteAccountOauth(app *app.App) http.HandlerFunc { return } - WriteData(w, http.StatusOK, map[string]interface{}{ - "require_password_reset": result.RequirePasswordReset, - }) + w.WriteHeader(http.StatusOK) } } diff --git a/server/handlers/delete_account_oauth_test.go b/server/handlers/delete_account_oauth_test.go index 4c509ac9a9..6ee9d03e80 100644 --- a/server/handlers/delete_account_oauth_test.go +++ b/server/handlers/delete_account_oauth_test.go @@ -1,8 +1,10 @@ package handlers_test import ( + "fmt" "net/http" "testing" + "time" "github.com/keratin/authn-server/lib/route" "github.com/keratin/authn-server/server/test" @@ -22,10 +24,11 @@ func TestDeleteAccountOauth(t *testing.T) { } t.Run("delete social account", func(t *testing.T) { - expected := "{\"result\":{\"require_password_reset\":true}}" account, err := app.AccountStore.Create("deleted-social-account@keratin.tech", []byte("password")) require.NoError(t, err) + time.Sleep(5 * time.Second) + err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID", "email", "TOKEN") require.NoError(t, err) @@ -34,7 +37,7 @@ func TestDeleteAccountOauth(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusOK, res.StatusCode) - require.Equal(t, []byte(expected), test.ReadBody(res)) + require.Equal(t, []byte{}, test.ReadBody(res)) }) t.Run("return not found when user does not exists", func(t *testing.T) { @@ -44,4 +47,20 @@ func TestDeleteAccountOauth(t *testing.T) { require.Equal(t, http.StatusNotFound, res.StatusCode) }) + + t.Run("return unprocessable entity when user requires a new password", func(t *testing.T) { + expected := "{\"errors\":[{\"field\":\"password\",\"message\":\"NEW_PASSWORD_REQUIRED\"}]}" + account, err := app.AccountStore.Create("deleted-unprocessable-entity@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID3", "email", "TOKEN") + require.NoError(t, err) + + payload := map[string]interface{}{"oauth_providers": []string{"test"}} + res, err := client.DeleteJSON(fmt.Sprintf("/accounts/%d/oauth", account.ID), payload) + require.NoError(t, err) + + require.Equal(t, http.StatusUnprocessableEntity, res.StatusCode) + require.Equal(t, []byte(expected), test.ReadBody(res)) + }) } diff --git a/server/handlers/delete_oauth.go b/server/handlers/delete_oauth.go index 44b479d724..569649232c 100644 --- a/server/handlers/delete_oauth.go +++ b/server/handlers/delete_oauth.go @@ -16,15 +16,13 @@ func DeleteOauth(app *app.App, providerName string) http.HandlerFunc { return } - result, err := services.AccountOauthEnder(app.AccountStore, accountID, []string{providerName}) + err := services.AccountOauthEnder(app.AccountStore, accountID, []string{providerName}) if err != nil { app.Logger.WithError(err).Error("AccountOauthEnder") WriteErrors(w, err) return } - WriteData(w, http.StatusOK, map[string]interface{}{ - "require_password_reset": result.RequirePasswordReset, - }) + w.WriteHeader(http.StatusOK) } } diff --git a/server/handlers/delete_oauth_test.go b/server/handlers/delete_oauth_test.go index c4960f6f5b..b24c86208e 100644 --- a/server/handlers/delete_oauth_test.go +++ b/server/handlers/delete_oauth_test.go @@ -4,6 +4,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/stretchr/testify/require" @@ -40,10 +41,11 @@ func TestDeleteOauthAccount(t *testing.T) { }) t.Run("delete social account", func(t *testing.T) { - expected := "{\"result\":{\"require_password_reset\":true}}" account, err := app.AccountStore.Create("deleted@keratin.tech", []byte("password")) require.NoError(t, err) + time.Sleep(5 * time.Second) + err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID", "email", "TOKEN") require.NoError(t, err) @@ -53,6 +55,24 @@ func TestDeleteOauthAccount(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, []byte{}, test.ReadBody(res)) + }) + + t.Run("return unprocessable entity when user requires a new password", func(t *testing.T) { + expected := "{\"errors\":[{\"field\":\"password\",\"message\":\"NEW_PASSWORD_REQUIRED\"}]}" + account, err := app.AccountStore.Create("deleted-unprocessable-entity@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID4", "email", "TOKEN") + require.NoError(t, err) + + session := test.CreateSession(app.RefreshTokenStore, app.Config, account.ID) + + payload := map[string]interface{}{"oauth_providers": []string{"test"}} + res, err := client.WithCookie(session).DeleteJSON("/oauth/test", payload) + require.NoError(t, err) + + require.Equal(t, http.StatusUnprocessableEntity, res.StatusCode) require.Equal(t, []byte(expected), test.ReadBody(res)) }) } From 7587cf5ab16161606d87c8cb65639d19d0aeaf1f Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Thu, 28 Mar 2024 10:53:37 -0300 Subject: [PATCH 10/24] Updating api doc --- docs/api.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index 4fae7daf45..583b50bb72 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10,8 +10,10 @@ * [Username Availability](#username-availability) * [Lock Account](#lock-account) * [Unlock Account](#unlock-account) + * [Delete OAuth account by user id](#delete-oauth-account-by-user-id) * [Archive Account](#archive-account) * [Import Account](#import-account) + * Sessions * [Login](#login) * [Refresh Session](#refresh-session) @@ -26,6 +28,8 @@ * OAuth * [Begin OAuth](#begin-oauth) * [OAuth Return URL](#oauth-return) + * [Get OAuth accounts info](#get-oauth-accounts-info) + * [Delete OAuth account](#delete-oauth-account) * Multi-Factor Authentication (MFA) **BETA** * [New](#totp-new) * [Confirm](#totp-post) @@ -302,7 +306,7 @@ Visibility: Private ] } -### Archive Account +### Delete OAuth account by user id Visibility: Private From 4b0c62f455d0e63ba30af054554f115d46c803b3 Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Thu, 28 Mar 2024 12:02:36 -0300 Subject: [PATCH 11/24] Adressing few feedbacks --- app/services/account_oauth_getter.go | 6 +++--- app/services/account_oauth_getter_test.go | 14 +++++++------- docker-compose.yml | 2 +- docs/api.md | 13 +++++++------ server/handlers/get_account_test.go | 12 ++++++------ server/handlers/get_oauth_info_test.go | 2 +- 6 files changed, 25 insertions(+), 24 deletions(-) diff --git a/app/services/account_oauth_getter.go b/app/services/account_oauth_getter.go index d61f52a792..3c4cfa6d7a 100644 --- a/app/services/account_oauth_getter.go +++ b/app/services/account_oauth_getter.go @@ -17,9 +17,9 @@ func AccountOauthGetter(accountStore data.AccountStore, accountID int) ([]map[st oAccountsMapped = append( oAccountsMapped, map[string]interface{}{ - "provider": oAccount.Provider, - "provider_id": oAccount.ProviderID, - "email": oAccount.Email, + "provider": oAccount.Provider, + "provider_account_id": oAccount.ProviderID, + "email": oAccount.Email, }, ) } diff --git a/app/services/account_oauth_getter_test.go b/app/services/account_oauth_getter_test.go index 562e28b123..9980e832eb 100644 --- a/app/services/account_oauth_getter_test.go +++ b/app/services/account_oauth_getter_test.go @@ -36,21 +36,21 @@ func TestAccountOauthGetter(t *testing.T) { require.NoError(t, err) sort.Slice(accountOauth, func(i, j int) bool { - return accountOauth[i]["provider_id"].(string) < accountOauth[j]["provider_id"].(string) + return accountOauth[i]["provider_account_id"].(string) < accountOauth[j]["provider_account_id"].(string) }) require.Equal( t, []map[string]interface{}{ { - "provider": "test", - "provider_id": "ID1", - "email": "email1", + "provider": "test", + "provider_account_id": "ID1", + "email": "email1", }, { - "provider": "trial", - "provider_id": "ID2", - "email": "email2", + "provider": "trial", + "provider_account_id": "ID2", + "email": "email2", }, }, accountOauth, diff --git a/docker-compose.yml b/docker-compose.yml index eaf8415311..b4ff81aa6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: - "${REDIS_PORT:-8701}:6379" mysql: - platform: linux/x86_64 + # platform: linux/x86_64 image: mysql:5.7 platform: linux/amd64 ports: diff --git a/docs/api.md b/docs/api.md index 583b50bb72..643c6d4b18 100644 --- a/docs/api.md +++ b/docs/api.md @@ -149,7 +149,7 @@ Visibility: Private "oauth_accounts": [ { "provider": "google"|"apple", - "provider_id": "91293", + "provider_account_id": "91293", "email": "authn@keratin.com" } ], @@ -312,9 +312,10 @@ Visibility: Private `DELETE /accounts/:id/oauth` -| Params | Type | Notes | -| ------ | ------- | --------------- | -| `id` | integer | User account Id | +| Params | Type | Notes | +| ------------------- | ------- | --------------- | +| `id` | integer | User account Id | +| `oauth_providers` | integer | User account Id | #### Success: @@ -700,7 +701,7 @@ If the OAuth process failed, the redirect will have `status=failed` appended to Visibility: Public -`Get /oauth/info` +`GET /oauth/info` Returns relevant oauth information for the current session. @@ -712,7 +713,7 @@ Returns relevant oauth information for the current session. "result": [ { "provider": "google"|"apple", - "provider_id": "91293", + "provider_account_id": "91293", "email": "authn@keratin.com" } ] diff --git a/server/handlers/get_account_test.go b/server/handlers/get_account_test.go index d02abfb5a6..9ac388ba72 100644 --- a/server/handlers/get_account_test.go +++ b/server/handlers/get_account_test.go @@ -79,14 +79,14 @@ func assertGetAccountResponse(t *testing.T, res *http.Response, acc *models.Acco Username: acc.Username, OauthAccounts: []map[string]interface{}{ { - "provider": "test", - "provider_id": oAccs[0].ProviderID, - "email": oAccs[0].Email, + "provider": "test", + "provider_account_id": oAccs[0].ProviderID, + "email": oAccs[0].Email, }, { - "provider": "trial", - "provider_id": oAccs[1].ProviderID, - "email": oAccs[1].Email, + "provider": "trial", + "provider_account_id": oAccs[1].ProviderID, + "email": oAccs[1].Email, }, }, LastLoginAt: "", diff --git a/server/handlers/get_oauth_info_test.go b/server/handlers/get_oauth_info_test.go index 0d3fffb906..8a5766d345 100644 --- a/server/handlers/get_oauth_info_test.go +++ b/server/handlers/get_oauth_info_test.go @@ -29,7 +29,7 @@ func TestGetOauthInfo(t *testing.T) { }) t.Run("get oauth info", func(t *testing.T) { - expected := "{\"result\":[{\"email\":\"email\",\"provider\":\"test\",\"provider_id\":\"ID\"}]}" + expected := "{\"result\":[{\"email\":\"email\",\"provider\":\"test\",\"provider_account_id\":\"ID\"}]}" account, err := app.AccountStore.Create("get-oauth-info@keratin.tech", []byte("password")) require.NoError(t, err) From 977ce66ef389f65d563d0da54e8e4097d0b8aba2 Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Mon, 1 Apr 2024 18:05:43 -0300 Subject: [PATCH 12/24] Adding mechanism to update oauth user info live --- app/data/account_store.go | 1 + app/data/mock/account_store.go | 13 ++++++++++ app/data/mysql/account_store.go | 9 +++++++ app/data/postgres/account_store.go | 9 +++++++ app/data/sqlite3/account_store.go | 9 +++++++ app/services/identity_reconciler.go | 31 ++++++++++++++++++++++++ app/services/identity_reconciler_test.go | 21 ++++++++++++++++ 7 files changed, 93 insertions(+) diff --git a/app/data/account_store.go b/app/data/account_store.go index fed5fd5047..fcb57ffba3 100644 --- a/app/data/account_store.go +++ b/app/data/account_store.go @@ -17,6 +17,7 @@ type AccountStore interface { FindByUsername(u string) (*models.Account, error) FindByOauthAccount(p string, pid string) (*models.Account, error) AddOauthAccount(id int, p string, pid string, email string, tok string) error + UpdateOauthAccount(id int, p string, email string) (bool, error) DeleteOauthAccount(id int, p string) (bool, error) GetOauthAccounts(id int) ([]*models.OauthAccount, error) Archive(id int) (bool, error) diff --git a/app/data/mock/account_store.go b/app/data/mock/account_store.go index 416c4bcb76..10c484bee3 100644 --- a/app/data/mock/account_store.go +++ b/app/data/mock/account_store.go @@ -125,6 +125,19 @@ func (s *accountStore) GetOauthAccounts(accountID int) ([]*models.OauthAccount, return s.oauthAccountsByID[accountID], nil } +func (s *accountStore) UpdateOauthAccount(accountID int, provider, email string) (bool, error) { + oauthAccounts := s.oauthAccountsByID[accountID] + + for i, oauthAccount := range oauthAccounts { + if oauthAccount.Provider == provider { + s.oauthAccountsByID[accountID][i].Email = email + return true, nil + } + } + + return false, nil +} + func (s *accountStore) DeleteOauthAccount(accountID int, provider string) (bool, error) { oauthAccounts := s.oauthAccountsByID[accountID] diff --git a/app/data/mysql/account_store.go b/app/data/mysql/account_store.go index 0fa8ea7b6d..c7407e64e3 100644 --- a/app/data/mysql/account_store.go +++ b/app/data/mysql/account_store.go @@ -100,6 +100,15 @@ func (db *AccountStore) GetOauthAccounts(accountID int) ([]*models.OauthAccount, return accounts, err } +func (db *AccountStore) UpdateOauthAccount(accountId int, provider, email string) (bool, error) { + result, err := db.Exec("UDPATE oauth_accounts SET email = ? WHERE account_id = ? AND provider = ?", email, accountId, provider) + if err != nil { + return false, err + } + + return ok(result, err) +} + func (db *AccountStore) DeleteOauthAccount(accountId int, provider string) (bool, error) { result, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = ? AND provider = ?", accountId, provider) if err != nil { diff --git a/app/data/postgres/account_store.go b/app/data/postgres/account_store.go index f6b7fa3715..06c5b33360 100644 --- a/app/data/postgres/account_store.go +++ b/app/data/postgres/account_store.go @@ -112,6 +112,15 @@ func (db *AccountStore) GetOauthAccounts(accountID int) ([]*models.OauthAccount, return accounts, err } +func (db *AccountStore) UpdateOauthAccount(accountId int, provider, email string) (bool, error) { + result, err := db.Exec("UPDATE oauth_accounts SET email = $1 WHERE account_id = $2 AND provider = $3", email, accountId, provider) + if err != nil { + return false, err + } + + return ok(result, err) +} + func (db *AccountStore) DeleteOauthAccount(accountId int, provider string) (bool, error) { result, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = $1 AND provider = $2", accountId, provider) if err != nil { diff --git a/app/data/sqlite3/account_store.go b/app/data/sqlite3/account_store.go index f87f114a01..5549aea4a0 100644 --- a/app/data/sqlite3/account_store.go +++ b/app/data/sqlite3/account_store.go @@ -100,6 +100,15 @@ func (db *AccountStore) GetOauthAccounts(accountID int) ([]*models.OauthAccount, return accounts, err } +func (db *AccountStore) UpdateOauthAccount(accountId int, provider, email string) (bool, error) { + result, err := db.Exec("UPDATE oauth_accounts SET email = ? WHERE account_id = ? AND provider = ?", email, accountId, provider) + if err != nil { + return false, err + } + + return ok(result, err) +} + func (db *AccountStore) DeleteOauthAccount(accountId int, provider string) (bool, error) { result, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = ? AND provider = ?", accountId, provider) if err != nil { diff --git a/app/services/identity_reconciler.go b/app/services/identity_reconciler.go index aae3980e0d..160b09f195 100644 --- a/app/services/identity_reconciler.go +++ b/app/services/identity_reconciler.go @@ -38,6 +38,11 @@ func IdentityReconciler(accountStore data.AccountStore, cfg *app.Config, provide return nil, errors.New("account locked") } + err = updateUserInfo(accountStore, linkedAccount.ID, providerName, providerUser) + if err != nil { + return nil, errors.Wrap(err, "updateUserInfo") + } + return linkedAccount, nil } @@ -76,3 +81,29 @@ func IdentityReconciler(accountStore data.AccountStore, cfg *app.Config, provide } return newAccount, nil } + +func updateUserInfo(accountStore data.AccountStore, accountID int, providerName string, providerUser *oauth.UserInfo) error { + oAccounts, err := accountStore.GetOauthAccounts(accountID) + if err != nil { + return errors.Wrap(err, "GetOauthAccounts") + } + + if len(oAccounts) == 0 { + return nil + } + + for _, oAccount := range oAccounts { + if providerName != oAccount.Provider && providerUser.ID != oAccount.ProviderID { + continue + } + + if oAccount.Email == "" { + _, err = accountStore.UpdateOauthAccount(accountID, oAccount.Provider, providerUser.Email) + if err != nil { + return errors.Wrap(err, "UpdateOauthAccountEmail") + } + } + } + + return nil +} diff --git a/app/services/identity_reconciler_test.go b/app/services/identity_reconciler_test.go index fc000e2530..6fe989b336 100644 --- a/app/services/identity_reconciler_test.go +++ b/app/services/identity_reconciler_test.go @@ -83,4 +83,25 @@ func TestIdentityReconciler(t *testing.T) { assert.Error(t, err) assert.Nil(t, found) }) + + t.Run("update missing email after oauth migration table", func(t *testing.T) { + provider := "testProvider" + providerAccountId := "666" + email := "update-missing-oauth-email@test.com" + + account, err := store.Create(email, []byte("password")) + require.NoError(t, err) + + err = store.AddOauthAccount(account.ID, provider, providerAccountId, "", "TOKEN") + require.NoError(t, err) + + found, err := services.IdentityReconciler(store, cfg, provider, &oauth.UserInfo{ID: providerAccountId, Email: email}, &oauth2.Token{}, 0) + assert.NoError(t, err) + assert.NotNil(t, found) + + oAccounts, err := store.GetOauthAccounts(account.ID) + assert.NoError(t, err) + assert.Equal(t, 1, len(oAccounts)) + assert.Equal(t, email, oAccounts[0].Email) + }) } From 05201d483a80a4aabeae7b40d26651148538c31a Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Mon, 1 Apr 2024 18:16:51 -0300 Subject: [PATCH 13/24] Updating code to use error.As --- server/handlers/delete_account_oauth.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/handlers/delete_account_oauth.go b/server/handlers/delete_account_oauth.go index c27cc4f66e..3146a36c30 100644 --- a/server/handlers/delete_account_oauth.go +++ b/server/handlers/delete_account_oauth.go @@ -1,6 +1,7 @@ package handlers import ( + "errors" "net/http" "strconv" @@ -13,9 +14,9 @@ import ( func DeleteAccountOauth(app *app.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { isNotFoundErr := func(err error) (string, bool) { - fieldErr, ok := err.(services.FieldErrors) + var fieldErr services.FieldErrors - if ok { + if errors.As(err, &fieldErr) { for _, err := range fieldErr { if err.Message == services.ErrNotFound { return err.Field, true From 2c17afb4189b2ebf220afd7ca94971aa7b6fe0a9 Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Mon, 1 Apr 2024 18:34:28 -0300 Subject: [PATCH 14/24] Updating documentation for new endpoints --- docker-compose.yml | 2 +- docs/api.md | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index b4ff81aa6f..eaf8415311 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: - "${REDIS_PORT:-8701}:6379" mysql: - # platform: linux/x86_64 + platform: linux/x86_64 image: mysql:5.7 platform: linux/amd64 ports: diff --git a/docs/api.md b/docs/api.md index 643c6d4b18..f4ebaeaf6a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -312,10 +312,10 @@ Visibility: Private `DELETE /accounts/:id/oauth` -| Params | Type | Notes | -| ------------------- | ------- | --------------- | -| `id` | integer | User account Id | -| `oauth_providers` | integer | User account Id | +| Params | Type | Notes | +| ------------------- | -------------- | --------------- | +| `id` | integer | User account Id | +| `oauth_providers` | array[string] | Provider names | #### Success: @@ -655,7 +655,7 @@ Visibility: Public | Params | Type | Notes | | ------ | ---- | ----- | -| `providerName` | string | * google | +| `providerName` | string | google | | `redirect_uri` | URL | Return URL after OAuth. Must be in your application's domain. | Redirect a user to this URL when you want to authenticate them with OAuth, and include a `redirect_uri` where you want them to return when they're done. From here, a user will proceed to the OAuth provider and back to AuthN's [OAuth Return](#oauth-return) endpoint (as configured with the provider). @@ -729,11 +729,11 @@ Visibility: Public `DELETE /oauth/:providerName` -| Params | Type | Notes | -| ------ | ---- | ----- | -| `providerName` | string | * google | +| Params | Type | Notes | +| -------------- | ------ | ------ | +| `providerName` | string | google | -Delete an OAuth account from the current session. If the session was initiated via the OAuth flow, the account will be flagged to reset the password during the next sign-in attempt. +Delete an OAuth account from the current session. If the session was initiated via the OAuth flow, the endpoint will returns an error requesting user to reset password. #### Success: From ce5c47a49db781a62d87737f0a9d7250fdc96ee2 Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Mon, 1 Apr 2024 18:54:01 -0300 Subject: [PATCH 15/24] Updating test names --- server/handlers/delete_account_oauth_test.go | 6 +++--- server/handlers/delete_oauth_test.go | 6 +++--- server/handlers/get_account_test.go | 3 ++- server/handlers/get_oauth_info_test.go | 4 ++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/server/handlers/delete_account_oauth_test.go b/server/handlers/delete_account_oauth_test.go index 6ee9d03e80..539b646b9d 100644 --- a/server/handlers/delete_account_oauth_test.go +++ b/server/handlers/delete_account_oauth_test.go @@ -23,7 +23,7 @@ func TestDeleteAccountOauth(t *testing.T) { return http.ErrUseLastResponse } - t.Run("delete social account", func(t *testing.T) { + t.Run("success", func(t *testing.T) { account, err := app.AccountStore.Create("deleted-social-account@keratin.tech", []byte("password")) require.NoError(t, err) @@ -40,7 +40,7 @@ func TestDeleteAccountOauth(t *testing.T) { require.Equal(t, []byte{}, test.ReadBody(res)) }) - t.Run("return not found when user does not exists", func(t *testing.T) { + t.Run("user does not exist", func(t *testing.T) { payload := map[string]interface{}{"oauth_providers": []string{"test"}} res, err := client.DeleteJSON("/accounts/9999/oauth", payload) require.NoError(t, err) @@ -48,7 +48,7 @@ func TestDeleteAccountOauth(t *testing.T) { require.Equal(t, http.StatusNotFound, res.StatusCode) }) - t.Run("return unprocessable entity when user requires a new password", func(t *testing.T) { + t.Run("requires new password", func(t *testing.T) { expected := "{\"errors\":[{\"field\":\"password\",\"message\":\"NEW_PASSWORD_REQUIRED\"}]}" account, err := app.AccountStore.Create("deleted-unprocessable-entity@keratin.tech", []byte("password")) require.NoError(t, err) diff --git a/server/handlers/delete_oauth_test.go b/server/handlers/delete_oauth_test.go index b24c86208e..50867ab50f 100644 --- a/server/handlers/delete_oauth_test.go +++ b/server/handlers/delete_oauth_test.go @@ -32,7 +32,7 @@ func TestDeleteOauthAccount(t *testing.T) { return http.ErrUseLastResponse } - t.Run("unanthorized", func(t *testing.T) { + t.Run("unauthorized", func(t *testing.T) { res, err := client.Delete("/oauth/test") require.NoError(t, err) @@ -40,7 +40,7 @@ func TestDeleteOauthAccount(t *testing.T) { require.Equal(t, []byte{}, test.ReadBody(res)) }) - t.Run("delete social account", func(t *testing.T) { + t.Run("success", func(t *testing.T) { account, err := app.AccountStore.Create("deleted@keratin.tech", []byte("password")) require.NoError(t, err) @@ -58,7 +58,7 @@ func TestDeleteOauthAccount(t *testing.T) { require.Equal(t, []byte{}, test.ReadBody(res)) }) - t.Run("return unprocessable entity when user requires a new password", func(t *testing.T) { + t.Run("requires new password", func(t *testing.T) { expected := "{\"errors\":[{\"field\":\"password\",\"message\":\"NEW_PASSWORD_REQUIRED\"}]}" account, err := app.AccountStore.Create("deleted-unprocessable-entity@keratin.tech", []byte("password")) require.NoError(t, err) diff --git a/server/handlers/get_account_test.go b/server/handlers/get_account_test.go index 9ac388ba72..34bb9a62f5 100644 --- a/server/handlers/get_account_test.go +++ b/server/handlers/get_account_test.go @@ -5,6 +5,7 @@ import ( "net/http" "sort" "testing" + "time" "github.com/keratin/authn-server/app/models" "github.com/keratin/authn-server/lib/route" @@ -90,7 +91,7 @@ func assertGetAccountResponse(t *testing.T, res *http.Response, acc *models.Acco }, }, LastLoginAt: "", - PasswordChangedAt: acc.PasswordChangedAt.Format("2006-01-02T15:04:05Z07:00"), + PasswordChangedAt: acc.PasswordChangedAt.Format(time.RFC3339), Locked: false, Deleted: false, }) diff --git a/server/handlers/get_oauth_info_test.go b/server/handlers/get_oauth_info_test.go index 8a5766d345..f42a565b64 100644 --- a/server/handlers/get_oauth_info_test.go +++ b/server/handlers/get_oauth_info_test.go @@ -20,7 +20,7 @@ func TestGetOauthInfo(t *testing.T) { Value: "", }) - t.Run("unanthorized", func(t *testing.T) { + t.Run("unauthorized", func(t *testing.T) { res, err := client.Get("/oauth/info") require.NoError(t, err) @@ -28,7 +28,7 @@ func TestGetOauthInfo(t *testing.T) { require.Equal(t, []byte{}, test.ReadBody(res)) }) - t.Run("get oauth info", func(t *testing.T) { + t.Run("success", func(t *testing.T) { expected := "{\"result\":[{\"email\":\"email\",\"provider\":\"test\",\"provider_account_id\":\"ID\"}]}" account, err := app.AccountStore.Create("get-oauth-info@keratin.tech", []byte("password")) require.NoError(t, err) From 1d26562a32ff30c3083fc099beaa07818e5f9f90 Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Thu, 4 Apr 2024 22:43:42 -0300 Subject: [PATCH 16/24] Addressing comments --- app/models/account.go | 9 +-- app/services/account_getter.go | 42 +++++++++++++ app/services/account_getter_test.go | 53 +++++++++++++++++ app/services/account_oauth_getter.go | 28 --------- app/services/account_oauth_getter_test.go | 59 ------------------- docker-compose.yml | 1 - docs/api.md | 2 +- server/handlers/delete_account_oauth.go | 13 +--- server/handlers/delete_account_oauth_test.go | 11 ++-- server/handlers/get_account.go | 27 +-------- ...et_oauth_info.go => get_oauth_accounts.go} | 7 ++- ...nfo_test.go => get_oauth_accounts_test.go} | 4 +- server/private_routes.go | 2 +- server/public_routes.go | 4 +- 14 files changed, 118 insertions(+), 144 deletions(-) create mode 100644 app/services/account_getter_test.go delete mode 100644 app/services/account_oauth_getter.go delete mode 100644 app/services/account_oauth_getter_test.go rename server/handlers/{get_oauth_info.go => get_oauth_accounts.go} (63%) rename server/handlers/{get_oauth_info_test.go => get_oauth_accounts_test.go} (92%) diff --git a/app/models/account.go b/app/models/account.go index eaa31ea193..2b27cea244 100644 --- a/app/models/account.go +++ b/app/models/account.go @@ -13,10 +13,11 @@ type Account struct { RequireNewPassword bool `db:"require_new_password"` PasswordChangedAt time.Time `db:"password_changed_at"` TOTPSecret sql.NullString `db:"totp_secret"` - LastLoginAt *time.Time `db:"last_login_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - DeletedAt *time.Time `db:"deleted_at"` + OauthAccounts []*OauthAccount + LastLoginAt *time.Time `db:"last_login_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + DeletedAt *time.Time `db:"deleted_at"` } func (a Account) Archived() bool { diff --git a/app/services/account_getter.go b/app/services/account_getter.go index 0d502a0853..7e5b2b0fc4 100644 --- a/app/services/account_getter.go +++ b/app/services/account_getter.go @@ -1,11 +1,47 @@ package services import ( + "time" + "github.com/keratin/authn-server/app/data" "github.com/keratin/authn-server/app/models" "github.com/pkg/errors" ) +func AccountToJson(account *models.Account) map[string]interface{} { + formattedLastLogin := "" + if account.LastLoginAt != nil { + formattedLastLogin = account.LastLoginAt.Format(time.RFC3339) + } + + formattedPasswordChangedAt := "" + if !account.PasswordChangedAt.IsZero() { + formattedPasswordChangedAt = account.PasswordChangedAt.Format(time.RFC3339) + } + + oAccountsMapped := []map[string]interface{}{} + for _, oAccount := range account.OauthAccounts { + oAccountsMapped = append( + oAccountsMapped, + map[string]interface{}{ + "provider": oAccount.Provider, + "provider_account_id": oAccount.ProviderID, + "email": oAccount.Email, + }, + ) + } + + return map[string]interface{}{ + "id": account.ID, + "username": account.Username, + "oauth_accounts": oAccountsMapped, + "last_login_at": formattedLastLogin, + "password_changed_at": formattedPasswordChangedAt, + "locked": account.Locked, + "deleted": account.DeletedAt != nil, + } +} + func AccountGetter(store data.AccountStore, accountID int) (*models.Account, error) { account, err := store.Find(accountID) if err != nil { @@ -15,5 +51,11 @@ func AccountGetter(store data.AccountStore, accountID int) (*models.Account, err return nil, FieldErrors{{"account", ErrNotFound}} } + oauthAccounts, err := store.GetOauthAccounts(accountID) + if err != nil { + return nil, errors.Wrap(err, "GetOauthAccounts") + } + + account.OauthAccounts = oauthAccounts return account, nil } diff --git a/app/services/account_getter_test.go b/app/services/account_getter_test.go new file mode 100644 index 0000000000..e9e9438ac6 --- /dev/null +++ b/app/services/account_getter_test.go @@ -0,0 +1,53 @@ +package services_test + +import ( + "sort" + "testing" + + "github.com/keratin/authn-server/app/data/mock" + "github.com/keratin/authn-server/app/services" + "github.com/stretchr/testify/require" +) + +func TestAccountGetter(t *testing.T) { + t.Run("returns empty map when no oauth accounts", func(t *testing.T) { + accountStore := mock.NewAccountStore() + acc, err := accountStore.Create("user@keratin.tech", []byte("password")) + require.NoError(t, err) + + account, err := services.AccountGetter(accountStore, acc.ID) + require.NoError(t, err) + + require.Equal(t, 0, len(account.OauthAccounts)) + }) + + t.Run("returns oauth accounts for different providers", func(t *testing.T) { + accountStore := mock.NewAccountStore() + acc, err := accountStore.Create("user@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = accountStore.AddOauthAccount(acc.ID, "test", "ID1", "email1", "TOKEN1") + require.NoError(t, err) + + err = accountStore.AddOauthAccount(acc.ID, "trial", "ID2", "email2", "TOKEN2") + require.NoError(t, err) + + account, err := services.AccountGetter(accountStore, acc.ID) + require.NoError(t, err) + + oAccounts := account.OauthAccounts + + sort.Slice(oAccounts, func(i, j int) bool { + return oAccounts[i].ProviderID < oAccounts[j].ProviderID + }) + + require.Equal(t, 2, len(oAccounts)) + require.Equal(t, "test", oAccounts[0].Provider) + require.Equal(t, "ID1", oAccounts[0].ProviderID) + require.Equal(t, "email1", oAccounts[0].Email) + + require.Equal(t, "trial", oAccounts[1].Provider) + require.Equal(t, "ID2", oAccounts[1].ProviderID) + require.Equal(t, "email2", oAccounts[1].Email) + }) +} diff --git a/app/services/account_oauth_getter.go b/app/services/account_oauth_getter.go deleted file mode 100644 index 3c4cfa6d7a..0000000000 --- a/app/services/account_oauth_getter.go +++ /dev/null @@ -1,28 +0,0 @@ -package services - -import ( - "github.com/keratin/authn-server/app/data" - "github.com/pkg/errors" -) - -func AccountOauthGetter(accountStore data.AccountStore, accountID int) ([]map[string]interface{}, error) { - oAccountsMapped := []map[string]interface{}{} - - oauthAccounts, err := accountStore.GetOauthAccounts(accountID) - if err != nil { - return nil, errors.Wrap(err, "GetOauthAccounts") - } - - for _, oAccount := range oauthAccounts { - oAccountsMapped = append( - oAccountsMapped, - map[string]interface{}{ - "provider": oAccount.Provider, - "provider_account_id": oAccount.ProviderID, - "email": oAccount.Email, - }, - ) - } - - return oAccountsMapped, nil -} diff --git a/app/services/account_oauth_getter_test.go b/app/services/account_oauth_getter_test.go deleted file mode 100644 index 9980e832eb..0000000000 --- a/app/services/account_oauth_getter_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package services_test - -import ( - "sort" - "testing" - - "github.com/keratin/authn-server/app/data/mock" - "github.com/keratin/authn-server/app/services" - "github.com/stretchr/testify/require" -) - -func TestAccountOauthGetter(t *testing.T) { - t.Run("returns empty map when no oauth accounts", func(t *testing.T) { - accountStore := mock.NewAccountStore() - account, err := accountStore.Create("user@keratin.tech", []byte("password")) - require.NoError(t, err) - - accountOauth, err := services.AccountOauthGetter(accountStore, account.ID) - require.NoError(t, err) - - require.Equal(t, 0, len(accountOauth)) - }) - - t.Run("returns oauth accounts for different providers", func(t *testing.T) { - accountStore := mock.NewAccountStore() - account, err := accountStore.Create("user@keratin.tech", []byte("password")) - require.NoError(t, err) - - err = accountStore.AddOauthAccount(account.ID, "test", "ID1", "email1", "TOKEN1") - require.NoError(t, err) - - err = accountStore.AddOauthAccount(account.ID, "trial", "ID2", "email2", "TOKEN2") - require.NoError(t, err) - - accountOauth, err := services.AccountOauthGetter(accountStore, 1) - require.NoError(t, err) - - sort.Slice(accountOauth, func(i, j int) bool { - return accountOauth[i]["provider_account_id"].(string) < accountOauth[j]["provider_account_id"].(string) - }) - - require.Equal( - t, - []map[string]interface{}{ - { - "provider": "test", - "provider_account_id": "ID1", - "email": "email1", - }, - { - "provider": "trial", - "provider_account_id": "ID2", - "email": "email2", - }, - }, - accountOauth, - ) - }) -} diff --git a/docker-compose.yml b/docker-compose.yml index eaf8415311..b5f3617d71 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,6 @@ services: - "${REDIS_PORT:-8701}:6379" mysql: - platform: linux/x86_64 image: mysql:5.7 platform: linux/amd64 ports: diff --git a/docs/api.md b/docs/api.md index f4ebaeaf6a..10da7a447f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -701,7 +701,7 @@ If the OAuth process failed, the redirect will have `status=failed` appended to Visibility: Public -`GET /oauth/info` +`GET /oauth/accounts` Returns relevant oauth information for the current session. diff --git a/server/handlers/delete_account_oauth.go b/server/handlers/delete_account_oauth.go index 3146a36c30..0773ebbcbe 100644 --- a/server/handlers/delete_account_oauth.go +++ b/server/handlers/delete_account_oauth.go @@ -8,7 +8,6 @@ import ( "github.com/gorilla/mux" "github.com/keratin/authn-server/app" "github.com/keratin/authn-server/app/services" - "github.com/keratin/authn-server/lib/parse" ) func DeleteAccountOauth(app *app.App) http.HandlerFunc { @@ -27,22 +26,14 @@ func DeleteAccountOauth(app *app.App) http.HandlerFunc { return "", false } + provider := mux.Vars(r)["name"] accountID, err := strconv.Atoi(mux.Vars(r)["id"]) if err != nil { WriteNotFound(w, "account") return } - var payload struct { - OauthProviders []string `json:"oauth_providers"` - } - - if err := parse.Payload(r, &payload); err != nil { - WriteErrors(w, err) - return - } - - err = services.AccountOauthEnder(app.AccountStore, accountID, payload.OauthProviders) + err = services.AccountOauthEnder(app.AccountStore, accountID, []string{provider}) if err != nil { app.Logger.WithError(err).Error("AccountOauthEnder") diff --git a/server/handlers/delete_account_oauth_test.go b/server/handlers/delete_account_oauth_test.go index 539b646b9d..59515a777c 100644 --- a/server/handlers/delete_account_oauth_test.go +++ b/server/handlers/delete_account_oauth_test.go @@ -32,8 +32,8 @@ func TestDeleteAccountOauth(t *testing.T) { err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID", "email", "TOKEN") require.NoError(t, err) - payload := map[string]interface{}{"oauth_providers": []string{"test"}} - res, err := client.DeleteJSON("/accounts/1/oauth", payload) + url := fmt.Sprintf("/accounts/%d/oauth/%s", account.ID, "test") + res, err := client.Delete(url) require.NoError(t, err) require.Equal(t, http.StatusOK, res.StatusCode) @@ -41,8 +41,7 @@ func TestDeleteAccountOauth(t *testing.T) { }) t.Run("user does not exist", func(t *testing.T) { - payload := map[string]interface{}{"oauth_providers": []string{"test"}} - res, err := client.DeleteJSON("/accounts/9999/oauth", payload) + res, err := client.Delete("/accounts/9999/oauth/test") require.NoError(t, err) require.Equal(t, http.StatusNotFound, res.StatusCode) @@ -56,8 +55,8 @@ func TestDeleteAccountOauth(t *testing.T) { err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID3", "email", "TOKEN") require.NoError(t, err) - payload := map[string]interface{}{"oauth_providers": []string{"test"}} - res, err := client.DeleteJSON(fmt.Sprintf("/accounts/%d/oauth", account.ID), payload) + url := fmt.Sprintf("/accounts/%d/oauth/%s", account.ID, "test") + res, err := client.Delete(url) require.NoError(t, err) require.Equal(t, http.StatusUnprocessableEntity, res.StatusCode) diff --git a/server/handlers/get_account.go b/server/handlers/get_account.go index be8a6427fb..5c9f382dcb 100644 --- a/server/handlers/get_account.go +++ b/server/handlers/get_account.go @@ -3,7 +3,6 @@ package handlers import ( "net/http" "strconv" - "time" "github.com/gorilla/mux" "github.com/keratin/authn-server/app" @@ -28,30 +27,6 @@ func GetAccount(app *app.App) http.HandlerFunc { panic(err) } - oautAccounts, err := services.AccountOauthGetter(app.AccountStore, id) - if err != nil { - WriteErrors(w, err) - return - } - - formattedLastLogin := "" - if account.LastLoginAt != nil { - formattedLastLogin = account.LastLoginAt.Format(time.RFC3339) - } - - formattedPasswordChangedAt := "" - if !account.PasswordChangedAt.IsZero() { - formattedPasswordChangedAt = account.PasswordChangedAt.Format(time.RFC3339) - } - - WriteData(w, http.StatusOK, map[string]interface{}{ - "id": account.ID, - "username": account.Username, - "oauth_accounts": oautAccounts, - "last_login_at": formattedLastLogin, - "password_changed_at": formattedPasswordChangedAt, - "locked": account.Locked, - "deleted": account.DeletedAt != nil, - }) + WriteData(w, http.StatusOK, services.AccountToJson(account)) } } diff --git a/server/handlers/get_oauth_info.go b/server/handlers/get_oauth_accounts.go similarity index 63% rename from server/handlers/get_oauth_info.go rename to server/handlers/get_oauth_accounts.go index 2740e4deab..151494e184 100644 --- a/server/handlers/get_oauth_info.go +++ b/server/handlers/get_oauth_accounts.go @@ -8,7 +8,7 @@ import ( "github.com/keratin/authn-server/server/sessions" ) -func GetOauthInfo(app *app.App) http.HandlerFunc { +func GetOauthAccounts(app *app.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { accountID := sessions.GetAccountID(r) if accountID == 0 { @@ -16,12 +16,13 @@ func GetOauthInfo(app *app.App) http.HandlerFunc { return } - oAccounts, err := services.AccountOauthGetter(app.AccountStore, accountID) + account, err := services.AccountGetter(app.AccountStore, accountID) if err != nil { WriteErrors(w, err) return } - WriteData(w, http.StatusOK, oAccounts) + accountData := services.AccountToJson(account) + WriteData(w, http.StatusOK, accountData["oauth_accounts"]) } } diff --git a/server/handlers/get_oauth_info_test.go b/server/handlers/get_oauth_accounts_test.go similarity index 92% rename from server/handlers/get_oauth_info_test.go rename to server/handlers/get_oauth_accounts_test.go index f42a565b64..5530e11ace 100644 --- a/server/handlers/get_oauth_info_test.go +++ b/server/handlers/get_oauth_accounts_test.go @@ -21,7 +21,7 @@ func TestGetOauthInfo(t *testing.T) { }) t.Run("unauthorized", func(t *testing.T) { - res, err := client.Get("/oauth/info") + res, err := client.Get("/oauth/accounts") require.NoError(t, err) require.Equal(t, http.StatusUnauthorized, res.StatusCode) @@ -38,7 +38,7 @@ func TestGetOauthInfo(t *testing.T) { session := test.CreateSession(app.RefreshTokenStore, app.Config, account.ID) - res, err := client.WithCookie(session).Get("/oauth/info") + res, err := client.WithCookie(session).Get("/oauth/accounts") require.NoError(t, err) require.Equal(t, http.StatusOK, res.StatusCode) diff --git a/server/private_routes.go b/server/private_routes.go index ac922ceb2c..f394b359b7 100644 --- a/server/private_routes.go +++ b/server/private_routes.go @@ -72,7 +72,7 @@ func PrivateRoutes(app *app.App) []*route.HandledRoute { SecuredWith(authentication). Handle(handlers.DeleteAccount(app)), - route.Delete("/accounts/{id:[0-9]+}/oauth"). + route.Delete("/accounts/{id:[0-9]+}/oauth/{name}"). SecuredWith(authentication). Handle(handlers.DeleteAccountOauth(app)), ) diff --git a/server/public_routes.go b/server/public_routes.go index 00c5c67484..d8ef7cb5ba 100644 --- a/server/public_routes.go +++ b/server/public_routes.go @@ -57,9 +57,9 @@ func PublicRoutes(app *app.App) []*route.HandledRoute { SecuredWith(originSecurity). Handle(handlers.DeleteTOTP(app)), - route.Get("/oauth/info"). + route.Get("/oauth/accounts"). SecuredWith(originSecurity). - Handle(handlers.GetOauthInfo(app)), + Handle(handlers.GetOauthAccounts(app)), ) if app.Config.EnableSignup { From bbce75d3854ad9ff16b04ff6ac7545af2b10eb4f Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Mon, 8 Apr 2024 17:22:24 -0300 Subject: [PATCH 17/24] Updating test case assertion to dynamically build oAccounts --- server/handlers/get_account_test.go | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/server/handlers/get_account_test.go b/server/handlers/get_account_test.go index 34bb9a62f5..737dafc4b0 100644 --- a/server/handlers/get_account_test.go +++ b/server/handlers/get_account_test.go @@ -55,6 +55,8 @@ func TestGetAccount(t *testing.T) { } func assertGetAccountResponse(t *testing.T, res *http.Response, acc *models.Account, oAccs []*models.OauthAccount) { + oAccounts := []map[string]interface{}{} + // check that the response contains the expected json type response struct { ID int `json:"id"` @@ -74,22 +76,19 @@ func assertGetAccountResponse(t *testing.T, res *http.Response, acc *models.Acco return oAccs[i].Provider < oAccs[j].Provider }) + for _, oAcc := range oAccs { + oAccounts = append(oAccounts, map[string]interface{}{ + "provider": oAcc.Provider, + "provider_account_id": oAcc.ProviderID, + "email": oAcc.Email, + }) + } + assert.Equal(t, []string{"application/json"}, res.Header["Content-Type"]) assert.Equal(t, responseData, response{ - ID: acc.ID, - Username: acc.Username, - OauthAccounts: []map[string]interface{}{ - { - "provider": "test", - "provider_account_id": oAccs[0].ProviderID, - "email": oAccs[0].Email, - }, - { - "provider": "trial", - "provider_account_id": oAccs[1].ProviderID, - "email": oAccs[1].Email, - }, - }, + ID: acc.ID, + Username: acc.Username, + OauthAccounts: oAccounts, LastLoginAt: "", PasswordChangedAt: acc.PasswordChangedAt.Format(time.RFC3339), Locked: false, From 38b6fc2e114eece7646852051fba378ecd5cdb8b Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Mon, 8 Apr 2024 20:03:15 -0300 Subject: [PATCH 18/24] Removing password reset validation before delete oauth account --- app/services/account_oauth_ender.go | 22 -------------------- app/services/account_oauth_ender_test.go | 18 ---------------- app/services/validations.go | 19 ++++++++--------- docs/api.md | 16 -------------- server/handlers/delete_account_oauth_test.go | 19 ----------------- server/handlers/delete_oauth_test.go | 21 ------------------- 6 files changed, 9 insertions(+), 106 deletions(-) diff --git a/app/services/account_oauth_ender.go b/app/services/account_oauth_ender.go index 17f3ff8307..0ac9ba5bac 100644 --- a/app/services/account_oauth_ender.go +++ b/app/services/account_oauth_ender.go @@ -1,9 +1,6 @@ package services import ( - "math" - "strings" - "github.com/keratin/authn-server/app/data" ) @@ -17,25 +14,6 @@ func AccountOauthEnder(store data.AccountStore, accountId int, providers []strin return FieldErrors{{"account", ErrNotFound}} } - oauthAccounts, err := store.GetOauthAccounts(accountId) - if err != nil { - return err - } - - mappedProviders := map[string]uint8{} - for _, provider := range providers { - mappedProviders[strings.ToLower(provider)] = 1 - } - - for _, oAccount := range oauthAccounts { - _, isProviderMatched := mappedProviders[strings.ToLower(oAccount.Provider)] - hasRandomOauthPassword := math.Abs(oAccount.CreatedAt.Sub(account.PasswordChangedAt).Seconds()) < 5 - - if hasRandomOauthPassword && isProviderMatched { - return FieldErrors{{"password", ErrNewPasswordRequired}} - } - } - for _, provider := range providers { _, err = store.DeleteOauthAccount(accountId, provider) if err != nil { diff --git a/app/services/account_oauth_ender_test.go b/app/services/account_oauth_ender_test.go index 35a626396b..746cf5b2f0 100644 --- a/app/services/account_oauth_ender_test.go +++ b/app/services/account_oauth_ender_test.go @@ -2,7 +2,6 @@ package services_test import ( "testing" - "time" "github.com/keratin/authn-server/app/data/mock" "github.com/keratin/authn-server/app/services" @@ -10,19 +9,6 @@ import ( ) func TestAccountOauthEnder(t *testing.T) { - - t.Run("require password reset for an account registered with oauth flow", func(t *testing.T) { - accountStore := mock.NewAccountStore() - account, err := accountStore.Create("requirepasswordreset@keratin.tech", []byte("password")) - require.NoError(t, err) - - err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "email", "TOKEN") - require.NoError(t, err) - - err = services.AccountOauthEnder(accountStore, account.ID, []string{"test"}) - require.Equal(t, err, services.FieldErrors{{"password", services.ErrNewPasswordRequired}}) - }) - t.Run("delete non existing oauth accounts", func(t *testing.T) { accountStore := mock.NewAccountStore() account, err := accountStore.Create("deleted@keratin.tech", []byte("password")) @@ -42,8 +28,6 @@ func TestAccountOauthEnder(t *testing.T) { account, err := accountStore.Create("deleted@keratin.tech", []byte("password")) require.NoError(t, err) - time.Sleep(5 * time.Second) - err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "email", "TOKEN") require.NoError(t, err) @@ -61,8 +45,6 @@ func TestAccountOauthEnder(t *testing.T) { account, err := accountStore.Create("deleted@keratin.tech", []byte("password")) require.NoError(t, err) - time.Sleep(5 * time.Second) - err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "email", "TOKEN") require.NoError(t, err) diff --git a/app/services/validations.go b/app/services/validations.go index c6bae9a08d..479ac0852d 100644 --- a/app/services/validations.go +++ b/app/services/validations.go @@ -8,16 +8,15 @@ import ( ) var ( - ErrMissing = "MISSING" - ErrTaken = "TAKEN" - ErrFormatInvalid = "FORMAT_INVALID" - ErrInsecure = "INSECURE" - ErrFailed = "FAILED" - ErrLocked = "LOCKED" - ErrExpired = "EXPIRED" - ErrNotFound = "NOT_FOUND" - ErrInvalidOrExpired = "INVALID_OR_EXPIRED" - ErrNewPasswordRequired = "NEW_PASSWORD_REQUIRED" + ErrMissing = "MISSING" + ErrTaken = "TAKEN" + ErrFormatInvalid = "FORMAT_INVALID" + ErrInsecure = "INSECURE" + ErrFailed = "FAILED" + ErrLocked = "LOCKED" + ErrExpired = "EXPIRED" + ErrNotFound = "NOT_FOUND" + ErrInvalidOrExpired = "INVALID_OR_EXPIRED" ) type FieldError struct { diff --git a/docs/api.md b/docs/api.md index 10da7a447f..faa47e9a10 100644 --- a/docs/api.md +++ b/docs/api.md @@ -326,14 +326,6 @@ Visibility: Private #### Failure: 404 Not Found - 422 Unprocessable Entity - - { - "errors": [ - {"field": "password", "message": "NEW_PASSWORD_REQUIRED"} - ] - } - ### Import Account Visibility: Private @@ -745,14 +737,6 @@ Delete an OAuth account from the current session. If the session was initiated v 401 Unauthorized - 422 Unprocessable Entity - - { - "errors": [ - {"field": "password", "message": "NEW_PASSWORD_REQUIRED"} - ] - } - ### Multi-Factor Authentication (MFA) **NOTE** - AuthN MFA support is currently considered in beta. The API will not be considered stable until v2. diff --git a/server/handlers/delete_account_oauth_test.go b/server/handlers/delete_account_oauth_test.go index 59515a777c..c59bbab67c 100644 --- a/server/handlers/delete_account_oauth_test.go +++ b/server/handlers/delete_account_oauth_test.go @@ -4,7 +4,6 @@ import ( "fmt" "net/http" "testing" - "time" "github.com/keratin/authn-server/lib/route" "github.com/keratin/authn-server/server/test" @@ -27,8 +26,6 @@ func TestDeleteAccountOauth(t *testing.T) { account, err := app.AccountStore.Create("deleted-social-account@keratin.tech", []byte("password")) require.NoError(t, err) - time.Sleep(5 * time.Second) - err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID", "email", "TOKEN") require.NoError(t, err) @@ -46,20 +43,4 @@ func TestDeleteAccountOauth(t *testing.T) { require.Equal(t, http.StatusNotFound, res.StatusCode) }) - - t.Run("requires new password", func(t *testing.T) { - expected := "{\"errors\":[{\"field\":\"password\",\"message\":\"NEW_PASSWORD_REQUIRED\"}]}" - account, err := app.AccountStore.Create("deleted-unprocessable-entity@keratin.tech", []byte("password")) - require.NoError(t, err) - - err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID3", "email", "TOKEN") - require.NoError(t, err) - - url := fmt.Sprintf("/accounts/%d/oauth/%s", account.ID, "test") - res, err := client.Delete(url) - require.NoError(t, err) - - require.Equal(t, http.StatusUnprocessableEntity, res.StatusCode) - require.Equal(t, []byte(expected), test.ReadBody(res)) - }) } diff --git a/server/handlers/delete_oauth_test.go b/server/handlers/delete_oauth_test.go index 50867ab50f..3cf907f633 100644 --- a/server/handlers/delete_oauth_test.go +++ b/server/handlers/delete_oauth_test.go @@ -4,7 +4,6 @@ import ( "net/http" "net/http/httptest" "testing" - "time" "github.com/stretchr/testify/require" @@ -44,8 +43,6 @@ func TestDeleteOauthAccount(t *testing.T) { account, err := app.AccountStore.Create("deleted@keratin.tech", []byte("password")) require.NoError(t, err) - time.Sleep(5 * time.Second) - err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID", "email", "TOKEN") require.NoError(t, err) @@ -57,22 +54,4 @@ func TestDeleteOauthAccount(t *testing.T) { require.Equal(t, http.StatusOK, res.StatusCode) require.Equal(t, []byte{}, test.ReadBody(res)) }) - - t.Run("requires new password", func(t *testing.T) { - expected := "{\"errors\":[{\"field\":\"password\",\"message\":\"NEW_PASSWORD_REQUIRED\"}]}" - account, err := app.AccountStore.Create("deleted-unprocessable-entity@keratin.tech", []byte("password")) - require.NoError(t, err) - - err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID4", "email", "TOKEN") - require.NoError(t, err) - - session := test.CreateSession(app.RefreshTokenStore, app.Config, account.ID) - - payload := map[string]interface{}{"oauth_providers": []string{"test"}} - res, err := client.WithCookie(session).DeleteJSON("/oauth/test", payload) - require.NoError(t, err) - - require.Equal(t, http.StatusUnprocessableEntity, res.StatusCode) - require.Equal(t, []byte(expected), test.ReadBody(res)) - }) } From e9c71ca94a0c96af434fad84afa725b5f3870b28 Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Mon, 8 Apr 2024 20:06:20 -0300 Subject: [PATCH 19/24] Implementing marshaler interface to account and oauth account --- app/models/account.go | 23 +++++++++++++++++ app/models/oauth_account.go | 13 +++++++++- app/services/account_getter.go | 36 --------------------------- server/handlers/get_account.go | 2 +- server/handlers/get_oauth_accounts.go | 3 +-- 5 files changed, 37 insertions(+), 40 deletions(-) diff --git a/app/models/account.go b/app/models/account.go index 2b27cea244..3e9f012f93 100644 --- a/app/models/account.go +++ b/app/models/account.go @@ -2,6 +2,7 @@ package models import ( "database/sql" + "encoding/json" "time" ) @@ -31,3 +32,25 @@ func (a Account) TOTPEnabled() bool { } return false } + +func (a Account) MarshalJSON() ([]byte, error) { + formattedLastLogin := "" + if a.LastLoginAt != nil { + formattedLastLogin = a.LastLoginAt.Format(time.RFC3339) + } + + formattedPasswordChangedAt := "" + if !a.PasswordChangedAt.IsZero() { + formattedPasswordChangedAt = a.PasswordChangedAt.Format(time.RFC3339) + } + + return json.Marshal(map[string]interface{}{ + "id": a.ID, + "username": a.Username, + "oauth_accounts": a.OauthAccounts, + "last_login_at": formattedLastLogin, + "password_changed_at": formattedPasswordChangedAt, + "locked": a.Locked, + "deleted": a.DeletedAt != nil, + }) +} diff --git a/app/models/oauth_account.go b/app/models/oauth_account.go index 5a5a134447..6b4a03ff14 100644 --- a/app/models/oauth_account.go +++ b/app/models/oauth_account.go @@ -1,6 +1,9 @@ package models -import "time" +import ( + "encoding/json" + "time" +) type OauthAccount struct { ID int @@ -12,3 +15,11 @@ type OauthAccount struct { CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } + +func (o OauthAccount) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]interface{}{ + "provider": o.Provider, + "provider_account_id": o.ProviderID, + "email": o.Email, + }) +} diff --git a/app/services/account_getter.go b/app/services/account_getter.go index 7e5b2b0fc4..e5403ab777 100644 --- a/app/services/account_getter.go +++ b/app/services/account_getter.go @@ -1,47 +1,11 @@ package services import ( - "time" - "github.com/keratin/authn-server/app/data" "github.com/keratin/authn-server/app/models" "github.com/pkg/errors" ) -func AccountToJson(account *models.Account) map[string]interface{} { - formattedLastLogin := "" - if account.LastLoginAt != nil { - formattedLastLogin = account.LastLoginAt.Format(time.RFC3339) - } - - formattedPasswordChangedAt := "" - if !account.PasswordChangedAt.IsZero() { - formattedPasswordChangedAt = account.PasswordChangedAt.Format(time.RFC3339) - } - - oAccountsMapped := []map[string]interface{}{} - for _, oAccount := range account.OauthAccounts { - oAccountsMapped = append( - oAccountsMapped, - map[string]interface{}{ - "provider": oAccount.Provider, - "provider_account_id": oAccount.ProviderID, - "email": oAccount.Email, - }, - ) - } - - return map[string]interface{}{ - "id": account.ID, - "username": account.Username, - "oauth_accounts": oAccountsMapped, - "last_login_at": formattedLastLogin, - "password_changed_at": formattedPasswordChangedAt, - "locked": account.Locked, - "deleted": account.DeletedAt != nil, - } -} - func AccountGetter(store data.AccountStore, accountID int) (*models.Account, error) { account, err := store.Find(accountID) if err != nil { diff --git a/server/handlers/get_account.go b/server/handlers/get_account.go index 5c9f382dcb..a47c87786f 100644 --- a/server/handlers/get_account.go +++ b/server/handlers/get_account.go @@ -27,6 +27,6 @@ func GetAccount(app *app.App) http.HandlerFunc { panic(err) } - WriteData(w, http.StatusOK, services.AccountToJson(account)) + WriteData(w, http.StatusOK, account) } } diff --git a/server/handlers/get_oauth_accounts.go b/server/handlers/get_oauth_accounts.go index 151494e184..31eead8e8b 100644 --- a/server/handlers/get_oauth_accounts.go +++ b/server/handlers/get_oauth_accounts.go @@ -22,7 +22,6 @@ func GetOauthAccounts(app *app.App) http.HandlerFunc { return } - accountData := services.AccountToJson(account) - WriteData(w, http.StatusOK, accountData["oauth_accounts"]) + WriteData(w, http.StatusOK, account.OauthAccounts) } } From 50abc8f136fe06ed4b3ad5073164f8e6fc090556 Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Mon, 8 Apr 2024 20:08:41 -0300 Subject: [PATCH 20/24] Renaming AccountOauthEnder to IdentityRemover --- app/services/account_oauth_ender.go | 2 +- app/services/account_oauth_ender_test.go | 8 ++++---- server/handlers/delete_account_oauth.go | 4 ++-- server/handlers/delete_oauth.go | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/services/account_oauth_ender.go b/app/services/account_oauth_ender.go index 0ac9ba5bac..ba42e14a1b 100644 --- a/app/services/account_oauth_ender.go +++ b/app/services/account_oauth_ender.go @@ -4,7 +4,7 @@ import ( "github.com/keratin/authn-server/app/data" ) -func AccountOauthEnder(store data.AccountStore, accountId int, providers []string) error { +func IdentityRemover(store data.AccountStore, accountId int, providers []string) error { account, err := store.Find(accountId) if err != nil { return err diff --git a/app/services/account_oauth_ender_test.go b/app/services/account_oauth_ender_test.go index 746cf5b2f0..465e6ba6f1 100644 --- a/app/services/account_oauth_ender_test.go +++ b/app/services/account_oauth_ender_test.go @@ -8,13 +8,13 @@ import ( "github.com/stretchr/testify/require" ) -func TestAccountOauthEnder(t *testing.T) { +func TestIdentityRemover(t *testing.T) { t.Run("delete non existing oauth accounts", func(t *testing.T) { accountStore := mock.NewAccountStore() account, err := accountStore.Create("deleted@keratin.tech", []byte("password")) require.NoError(t, err) - err = services.AccountOauthEnder(accountStore, account.ID, []string{"test"}) + err = services.IdentityRemover(accountStore, account.ID, []string{"test"}) require.NoError(t, err) oAccount, err := accountStore.GetOauthAccounts(account.ID) @@ -31,7 +31,7 @@ func TestAccountOauthEnder(t *testing.T) { err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "email", "TOKEN") require.NoError(t, err) - err = services.AccountOauthEnder(accountStore, account.ID, []string{"test"}) + err = services.IdentityRemover(accountStore, account.ID, []string{"test"}) require.NoError(t, err) oAccount, err := accountStore.GetOauthAccounts(account.ID) @@ -51,7 +51,7 @@ func TestAccountOauthEnder(t *testing.T) { err = accountStore.AddOauthAccount(account.ID, "trial", "TESTID", "email", "TOKEN") require.NoError(t, err) - err = services.AccountOauthEnder(accountStore, account.ID, []string{"test", "trial"}) + err = services.IdentityRemover(accountStore, account.ID, []string{"test", "trial"}) require.NoError(t, err) oAccount, err := accountStore.GetOauthAccounts(account.ID) diff --git a/server/handlers/delete_account_oauth.go b/server/handlers/delete_account_oauth.go index 0773ebbcbe..ddec90174a 100644 --- a/server/handlers/delete_account_oauth.go +++ b/server/handlers/delete_account_oauth.go @@ -33,9 +33,9 @@ func DeleteAccountOauth(app *app.App) http.HandlerFunc { return } - err = services.AccountOauthEnder(app.AccountStore, accountID, []string{provider}) + err = services.IdentityRemover(app.AccountStore, accountID, []string{provider}) if err != nil { - app.Logger.WithError(err).Error("AccountOauthEnder") + app.Logger.WithError(err).Error("IdentityRemover") if resource, ok := isNotFoundErr(err); ok { WriteNotFound(w, resource) diff --git a/server/handlers/delete_oauth.go b/server/handlers/delete_oauth.go index 569649232c..b96738d4cc 100644 --- a/server/handlers/delete_oauth.go +++ b/server/handlers/delete_oauth.go @@ -16,9 +16,9 @@ func DeleteOauth(app *app.App, providerName string) http.HandlerFunc { return } - err := services.AccountOauthEnder(app.AccountStore, accountID, []string{providerName}) + err := services.IdentityRemover(app.AccountStore, accountID, []string{providerName}) if err != nil { - app.Logger.WithError(err).Error("AccountOauthEnder") + app.Logger.WithError(err).Error("IdentityRemover") WriteErrors(w, err) return } From 5277772ec134824f0227ab88ef103d08f53fcb7e Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Thu, 11 Apr 2024 17:11:30 -0300 Subject: [PATCH 21/24] Addressing PR comments --- app/models/account.go | 24 ++++++++++++------- app/models/oauth_account.go | 12 ++++++---- app/services/account_getter_test.go | 9 +++++++ ...h_ender.go => account_identity_remover.go} | 0 app/services/identity_reconciler.go | 4 ++-- app/services/identity_reconciler_test.go | 21 ++++++++++++++++ server/handlers/get_oauth_accounts_test.go | 19 +++++++++++++-- 7 files changed, 73 insertions(+), 16 deletions(-) rename app/services/{account_oauth_ender.go => account_identity_remover.go} (100%) diff --git a/app/models/account.go b/app/models/account.go index 3e9f012f93..4f689377f5 100644 --- a/app/models/account.go +++ b/app/models/account.go @@ -44,13 +44,21 @@ func (a Account) MarshalJSON() ([]byte, error) { formattedPasswordChangedAt = a.PasswordChangedAt.Format(time.RFC3339) } - return json.Marshal(map[string]interface{}{ - "id": a.ID, - "username": a.Username, - "oauth_accounts": a.OauthAccounts, - "last_login_at": formattedLastLogin, - "password_changed_at": formattedPasswordChangedAt, - "locked": a.Locked, - "deleted": a.DeletedAt != nil, + return json.Marshal(struct { + ID int `json:"id"` + Username string `json:"username"` + OauthAccounts []*OauthAccount `json:"oauth_accounts"` + LastLoginAt string `json:"last_login_at"` + PasswordChangedAt string `json:"password_changed_at"` + Locked bool `json:"locked"` + Deleted bool `json:"deleted"` + }{ + ID: a.ID, + Username: a.Username, + OauthAccounts: a.OauthAccounts, + LastLoginAt: formattedLastLogin, + PasswordChangedAt: formattedPasswordChangedAt, + Locked: a.Locked, + Deleted: a.DeletedAt != nil, }) } diff --git a/app/models/oauth_account.go b/app/models/oauth_account.go index 6b4a03ff14..1c366c640d 100644 --- a/app/models/oauth_account.go +++ b/app/models/oauth_account.go @@ -17,9 +17,13 @@ type OauthAccount struct { } func (o OauthAccount) MarshalJSON() ([]byte, error) { - return json.Marshal(map[string]interface{}{ - "provider": o.Provider, - "provider_account_id": o.ProviderID, - "email": o.Email, + return json.Marshal(struct { + Provider string `json:"provider"` + ProviderID string `json:"provider_account_id"` + Email string `json:"email"` + }{ + Provider: o.Provider, + ProviderID: o.ProviderID, + Email: o.Email, }) } diff --git a/app/services/account_getter_test.go b/app/services/account_getter_test.go index e9e9438ac6..5938e05072 100644 --- a/app/services/account_getter_test.go +++ b/app/services/account_getter_test.go @@ -10,6 +10,15 @@ import ( ) func TestAccountGetter(t *testing.T) { + + t.Run("get non existing account", func(t *testing.T) { + accountStore := mock.NewAccountStore() + account, err := services.AccountGetter(accountStore, 9999) + + require.NotNil(t, err) + require.Nil(t, account) + }) + t.Run("returns empty map when no oauth accounts", func(t *testing.T) { accountStore := mock.NewAccountStore() acc, err := accountStore.Create("user@keratin.tech", []byte("password")) diff --git a/app/services/account_oauth_ender.go b/app/services/account_identity_remover.go similarity index 100% rename from app/services/account_oauth_ender.go rename to app/services/account_identity_remover.go diff --git a/app/services/identity_reconciler.go b/app/services/identity_reconciler.go index 160b09f195..8aa457a691 100644 --- a/app/services/identity_reconciler.go +++ b/app/services/identity_reconciler.go @@ -97,10 +97,10 @@ func updateUserInfo(accountStore data.AccountStore, accountID int, providerName continue } - if oAccount.Email == "" { + if oAccount.Email != providerUser.Email { _, err = accountStore.UpdateOauthAccount(accountID, oAccount.Provider, providerUser.Email) if err != nil { - return errors.Wrap(err, "UpdateOauthAccountEmail") + return errors.Wrap(err, "UpdateOauthAccount") } } } diff --git a/app/services/identity_reconciler_test.go b/app/services/identity_reconciler_test.go index 6fe989b336..0b35e93776 100644 --- a/app/services/identity_reconciler_test.go +++ b/app/services/identity_reconciler_test.go @@ -104,4 +104,25 @@ func TestIdentityReconciler(t *testing.T) { assert.Equal(t, 1, len(oAccounts)) assert.Equal(t, email, oAccounts[0].Email) }) + + t.Run("update oauth email when is outdated", func(t *testing.T) { + provider := "testProvider" + providerAccountId := "777" + email := "update-outdate-oauth-email@test.com" + + account, err := store.Create(email, []byte("password")) + require.NoError(t, err) + + err = store.AddOauthAccount(account.ID, provider, providerAccountId, "email@email.com", "TOKEN") + require.NoError(t, err) + + found, err := services.IdentityReconciler(store, cfg, provider, &oauth.UserInfo{ID: providerAccountId, Email: email}, &oauth2.Token{}, 0) + assert.NoError(t, err) + assert.NotNil(t, found) + + oAccounts, err := store.GetOauthAccounts(account.ID) + assert.NoError(t, err) + assert.Equal(t, 1, len(oAccounts)) + assert.Equal(t, email, oAccounts[0].Email) + }) } diff --git a/server/handlers/get_oauth_accounts_test.go b/server/handlers/get_oauth_accounts_test.go index 5530e11ace..689725a9bc 100644 --- a/server/handlers/get_oauth_accounts_test.go +++ b/server/handlers/get_oauth_accounts_test.go @@ -1,6 +1,7 @@ package handlers_test import ( + "encoding/json" "net/http" "testing" @@ -29,7 +30,14 @@ func TestGetOauthInfo(t *testing.T) { }) t.Run("success", func(t *testing.T) { - expected := "{\"result\":[{\"email\":\"email\",\"provider\":\"test\",\"provider_account_id\":\"ID\"}]}" + var expected struct { + Result []struct { + Email string `json:"email"` + Provider string `json:"provider"` + ProviderAccountID string `json:"provider_account_id"` + } + } + account, err := app.AccountStore.Create("get-oauth-info@keratin.tech", []byte("password")) require.NoError(t, err) @@ -42,6 +50,13 @@ func TestGetOauthInfo(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusOK, res.StatusCode) - require.Equal(t, []byte(expected), test.ReadBody(res)) + + err = json.Unmarshal(test.ReadBody(res), &expected) + require.NoError(t, err) + + require.Equal(t, len(expected.Result), 1) + require.Equal(t, expected.Result[0].Email, "email") + require.Equal(t, expected.Result[0].Provider, "test") + require.Equal(t, expected.Result[0].ProviderAccountID, "ID") }) } From feb971ccd6327eb744a14fb798eb25610734c0e7 Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Fri, 12 Apr 2024 11:32:03 -0300 Subject: [PATCH 22/24] Updating delete_account_oauth accordingly --- docs/api.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/api.md b/docs/api.md index faa47e9a10..a858846516 100644 --- a/docs/api.md +++ b/docs/api.md @@ -310,12 +310,12 @@ Visibility: Private Visibility: Private -`DELETE /accounts/:id/oauth` +`DELETE /accounts/:id/oauth/:name` -| Params | Type | Notes | -| ------------------- | -------------- | --------------- | -| `id` | integer | User account Id | -| `oauth_providers` | array[string] | Provider names | +| Params | Type | Notes | +| -------- | --------- | --------------- | +| `id` | integer | User account Id | +| `name` | string | Provider names | #### Success: From d8e5c421867cc227e958e33b7c57f1887f96a122 Mon Sep 17 00:00:00 2001 From: Diego Peres Date: Sat, 13 Apr 2024 01:54:26 -0300 Subject: [PATCH 23/24] Renaming files correctly --- app/services/{account_identity_remover.go => identity_remover.go} | 0 .../{account_oauth_ender_test.go => identity_remover_test.go} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename app/services/{account_identity_remover.go => identity_remover.go} (100%) rename app/services/{account_oauth_ender_test.go => identity_remover_test.go} (100%) diff --git a/app/services/account_identity_remover.go b/app/services/identity_remover.go similarity index 100% rename from app/services/account_identity_remover.go rename to app/services/identity_remover.go diff --git a/app/services/account_oauth_ender_test.go b/app/services/identity_remover_test.go similarity index 100% rename from app/services/account_oauth_ender_test.go rename to app/services/identity_remover_test.go From 6afcfe8817c870ef9ba763a45729c232510b7f1d Mon Sep 17 00:00:00 2001 From: Alex Ullrich Date: Sat, 13 Apr 2024 11:02:14 -0400 Subject: [PATCH 24/24] update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a7db4def6..62cdb75cd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## HEAD +### Added + +* Public and private APIs for oauth account visibility and removal - requires migration to record user email on oauth accounts (#253) + ## 1.19.0 ### Added