diff --git a/driver/registry_default.go b/driver/registry_default.go index a5c85232d445..d9ceac08c1eb 100644 --- a/driver/registry_default.go +++ b/driver/registry_default.go @@ -166,6 +166,7 @@ func (m *RegistryDefault) RegisterAdminRoutes(ctx context.Context, router *x.Rou m.RecoveryHandler().RegisterAdminRoutes(router) m.AllRecoveryStrategies().RegisterAdminRoutes(router) + m.SessionHandler().RegisterAdminRoutes(router) m.VerificationHandler().RegisterAdminRoutes(router) m.AllVerificationStrategies().RegisterAdminRoutes(router) diff --git a/internal/httpclient/README.md b/internal/httpclient/README.md index b90b9763cba0..bc7693d6ee67 100644 --- a/internal/httpclient/README.md +++ b/internal/httpclient/README.md @@ -90,6 +90,7 @@ Class | Method | HTTP request | Description *V0alpha1Api* | [**AdminCreateIdentity**](docs/V0alpha1Api.md#admincreateidentity) | **Post** /identities | Create an Identity *V0alpha1Api* | [**AdminCreateSelfServiceRecoveryLink**](docs/V0alpha1Api.md#admincreateselfservicerecoverylink) | **Post** /recovery/link | Create a Recovery Link *V0alpha1Api* | [**AdminDeleteIdentity**](docs/V0alpha1Api.md#admindeleteidentity) | **Delete** /identities/{id} | Delete an Identity +*V0alpha1Api* | [**AdminDeleteIdentitySessions**](docs/V0alpha1Api.md#admindeleteidentitysessions) | **Delete** /identity/{id}/sessions | Calling this endpoint irrecoverably and permanently deletes and invalidates all sessions that belong to the given Identity. *V0alpha1Api* | [**AdminGetIdentity**](docs/V0alpha1Api.md#admingetidentity) | **Get** /identities/{id} | Get an Identity *V0alpha1Api* | [**AdminListIdentities**](docs/V0alpha1Api.md#adminlistidentities) | **Get** /identities | List Identities *V0alpha1Api* | [**AdminUpdateIdentity**](docs/V0alpha1Api.md#adminupdateidentity) | **Put** /identities/{id} | Update an Identity diff --git a/internal/httpclient/api/openapi.yaml b/internal/httpclient/api/openapi.yaml index 796943e8eea6..694f89bdbecc 100644 --- a/internal/httpclient/api/openapi.yaml +++ b/internal/httpclient/api/openapi.yaml @@ -301,6 +301,56 @@ paths: summary: Update an Identity tags: - v0alpha1 + /identity/{id}/sessions: + delete: + description: |- + This endpoint is useful for: + + To forcefully logout Identity from all devices and sessions + operationId: adminDeleteIdentitySessions + parameters: + - description: ID is the identity's ID. + explode: false + in: path + name: id + required: true + schema: + type: string + style: simple + responses: + "204": + description: Empty responses are sent when, for example, resources are deleted. + The HTTP status code for empty responses is typically 201. + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/jsonError' + description: jsonError + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/jsonError' + description: jsonError + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/jsonError' + description: jsonError + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/jsonError' + description: jsonError + security: + - oryAccessToken: [] + summary: Calling this endpoint irrecoverably and permanently deletes and invalidates + all sessions that belong to the given Identity. + tags: + - v0alpha1 /metrics/prometheus: get: description: |- diff --git a/internal/httpclient/api_v0alpha1.go b/internal/httpclient/api_v0alpha1.go index 3390512cfd66..b3fcb787af79 100644 --- a/internal/httpclient/api_v0alpha1.go +++ b/internal/httpclient/api_v0alpha1.go @@ -77,6 +77,22 @@ type V0alpha1Api interface { */ AdminDeleteIdentityExecute(r V0alpha1ApiApiAdminDeleteIdentityRequest) (*http.Response, error) + /* + * AdminDeleteIdentitySessions Calling this endpoint irrecoverably and permanently deletes and invalidates all sessions that belong to the given Identity. + * This endpoint is useful for: + + To forcefully logout Identity from all devices and sessions + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @param id ID is the identity's ID. + * @return V0alpha1ApiApiAdminDeleteIdentitySessionsRequest + */ + AdminDeleteIdentitySessions(ctx context.Context, id string) V0alpha1ApiApiAdminDeleteIdentitySessionsRequest + + /* + * AdminDeleteIdentitySessionsExecute executes the request + */ + AdminDeleteIdentitySessionsExecute(r V0alpha1ApiApiAdminDeleteIdentitySessionsRequest) (*http.Response, error) + /* * AdminGetIdentity Get an Identity * Learn how identities work in [Ory Kratos' User And Identity Model Documentation](https://www.ory.sh/docs/next/kratos/concepts/identity-user-model). @@ -1275,6 +1291,155 @@ func (a *V0alpha1ApiService) AdminDeleteIdentityExecute(r V0alpha1ApiApiAdminDel return localVarHTTPResponse, nil } +type V0alpha1ApiApiAdminDeleteIdentitySessionsRequest struct { + ctx context.Context + ApiService V0alpha1Api + id string +} + +func (r V0alpha1ApiApiAdminDeleteIdentitySessionsRequest) Execute() (*http.Response, error) { + return r.ApiService.AdminDeleteIdentitySessionsExecute(r) +} + +/* + * AdminDeleteIdentitySessions Calling this endpoint irrecoverably and permanently deletes and invalidates all sessions that belong to the given Identity. + * This endpoint is useful for: + +To forcefully logout Identity from all devices and sessions + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @param id ID is the identity's ID. + * @return V0alpha1ApiApiAdminDeleteIdentitySessionsRequest +*/ +func (a *V0alpha1ApiService) AdminDeleteIdentitySessions(ctx context.Context, id string) V0alpha1ApiApiAdminDeleteIdentitySessionsRequest { + return V0alpha1ApiApiAdminDeleteIdentitySessionsRequest{ + ApiService: a, + ctx: ctx, + id: id, + } +} + +/* + * Execute executes the request + */ +func (a *V0alpha1ApiService) AdminDeleteIdentitySessionsExecute(r V0alpha1ApiApiAdminDeleteIdentitySessionsRequest) (*http.Response, error) { + var ( + localVarHTTPMethod = http.MethodDelete + localVarPostBody interface{} + localVarFormFileName string + localVarFileName string + localVarFileBytes []byte + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "V0alpha1ApiService.AdminDeleteIdentitySessions") + if err != nil { + return nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/identity/{id}/sessions" + localVarPath = strings.Replace(localVarPath, "{"+"id"+"}", url.PathEscape(parameterToString(r.id, "")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + if r.ctx != nil { + // API Key Authentication + if auth, ok := r.ctx.Value(ContextAPIKeys).(map[string]APIKey); ok { + if apiKey, ok := auth["oryAccessToken"]; ok { + var key string + if apiKey.Prefix != "" { + key = apiKey.Prefix + " " + apiKey.Key + } else { + key = apiKey.Key + } + localVarHeaderParams["Authorization"] = key + } + } + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes) + if err != nil { + return nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarHTTPResponse, err + } + + localVarBody, err := ioutil.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = ioutil.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v JsonError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.model = v + return localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 401 { + var v JsonError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.model = v + return localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 404 { + var v JsonError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.model = v + return localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v JsonError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.model = v + } + return localVarHTTPResponse, newErr + } + + return localVarHTTPResponse, nil +} + type V0alpha1ApiApiAdminGetIdentityRequest struct { ctx context.Context ApiService V0alpha1Api diff --git a/internal/httpclient/docs/V0alpha1Api.md b/internal/httpclient/docs/V0alpha1Api.md index 08800dd91dd1..4b9c7e0743dd 100644 --- a/internal/httpclient/docs/V0alpha1Api.md +++ b/internal/httpclient/docs/V0alpha1Api.md @@ -7,6 +7,7 @@ Method | HTTP request | Description [**AdminCreateIdentity**](V0alpha1Api.md#AdminCreateIdentity) | **Post** /identities | Create an Identity [**AdminCreateSelfServiceRecoveryLink**](V0alpha1Api.md#AdminCreateSelfServiceRecoveryLink) | **Post** /recovery/link | Create a Recovery Link [**AdminDeleteIdentity**](V0alpha1Api.md#AdminDeleteIdentity) | **Delete** /identities/{id} | Delete an Identity +[**AdminDeleteIdentitySessions**](V0alpha1Api.md#AdminDeleteIdentitySessions) | **Delete** /identity/{id}/sessions | Calling this endpoint irrecoverably and permanently deletes and invalidates all sessions that belong to the given Identity. [**AdminGetIdentity**](V0alpha1Api.md#AdminGetIdentity) | **Get** /identities/{id} | Get an Identity [**AdminListIdentities**](V0alpha1Api.md#AdminListIdentities) | **Get** /identities | List Identities [**AdminUpdateIdentity**](V0alpha1Api.md#AdminUpdateIdentity) | **Put** /identities/{id} | Update an Identity @@ -239,6 +240,74 @@ Name | Type | Description | Notes [[Back to README]](../README.md) +## AdminDeleteIdentitySessions + +> AdminDeleteIdentitySessions(ctx, id).Execute() + +Calling this endpoint irrecoverably and permanently deletes and invalidates all sessions that belong to the given Identity. + + + +### Example + +```go +package main + +import ( + "context" + "fmt" + "os" + openapiclient "./openapi" +) + +func main() { + id := "id_example" // string | ID is the identity's ID. + + configuration := openapiclient.NewConfiguration() + apiClient := openapiclient.NewAPIClient(configuration) + resp, r, err := apiClient.V0alpha1Api.AdminDeleteIdentitySessions(context.Background(), id).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Error when calling `V0alpha1Api.AdminDeleteIdentitySessions``: %v\n", err) + fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) + } +} +``` + +### Path Parameters + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- +**ctx** | **context.Context** | context for authentication, logging, cancellation, deadlines, tracing, etc. +**id** | **string** | ID is the identity's ID. | + +### Other Parameters + +Other parameters are passed through a pointer to a apiAdminDeleteIdentitySessionsRequest struct via the builder pattern + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + + +### Return type + + (empty response body) + +### Authorization + +[oryAccessToken](../README.md#oryAccessToken) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) +[[Back to Model list]](../README.md#documentation-for-models) +[[Back to README]](../README.md) + + ## AdminGetIdentity > Identity AdminGetIdentity(ctx, id).Execute() diff --git a/session/handler.go b/session/handler.go index e04a137bff4a..c34b16f86d7c 100644 --- a/session/handler.go +++ b/session/handler.go @@ -3,6 +3,7 @@ package session import ( "net/http" + "github.com/gofrs/uuid" "github.com/julienschmidt/httprouter" "github.com/pkg/errors" @@ -44,7 +45,10 @@ func NewHandler( } const ( - RouteWhoami = "/sessions/whoami" + RouteCollection = "/sessions" + RouteWhoami = RouteCollection + "/whoami" + RouteIdentity = "/identities" + RouteDeleteSession = RouteIdentity + "/:id/sessions" ) func (h *Handler) RegisterAdminRoutes(admin *x.RouterAdmin) { @@ -53,17 +57,21 @@ func (h *Handler) RegisterAdminRoutes(admin *x.RouterAdmin) { // Redirect to public endpoint admin.Handle(m, RouteWhoami, x.RedirectToPublicRoute(h.r)) } + + admin.DELETE(RouteDeleteSession, h.deleteIdentitySessions) } func (h *Handler) RegisterPublicRoutes(public *x.RouterPublic) { - // We need to completely ignore the whoami path so that we do not accidentally set + // We need to completely ignore the whoami/logout path so that we do not accidentally set // some cookie. h.r.CSRFHandler().IgnorePath(RouteWhoami) + h.r.CSRFHandler().IgnoreGlob(RouteIdentity + "/*/sessions") for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodConnect, http.MethodOptions, http.MethodTrace} { public.Handle(m, RouteWhoami, h.whoami) } + public.DELETE(RouteDeleteSession, x.RedirectToAdminRoute(h.r)) } // nolint:deadcode,unused @@ -153,6 +161,49 @@ func (h *Handler) whoami(w http.ResponseWriter, r *http.Request, ps httprouter.P h.r.Writer().Write(w, r, s) } +// swagger:parameters adminDeleteIdentitySessions +// nolint:deadcode,unused +type adminDeleteIdentitySessions struct { + // ID is the identity's ID. + // + // required: true + // in: path + ID string `json:"id"` +} + +// swagger:route DELETE /identity/{id}/sessions v0alpha1 adminDeleteIdentitySessions +// +// Calling this endpoint irrecoverably and permanently deletes and invalidates all sessions that belong to the given Identity. +// +// This endpoint is useful for: +// +// - To forcefully logout Identity from all devices and sessions +// +// Schemes: http, https +// +// Security: +// oryAccessToken: +// +// Responses: +// 204: emptyResponse +// 400: jsonError +// 401: jsonError +// 404: jsonError +// 500: jsonError +func (h *Handler) deleteIdentitySessions(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + iID, err := uuid.FromString(ps.ByName("id")) + if err != nil { + h.r.Writer().WriteError(w, r, herodot.ErrBadRequest.WithError(err.Error()).WithDebug("could not parse UUID")) + return + } + if err := h.r.SessionPersister().DeleteSessionsByIdentity(r.Context(), iID); err != nil { + h.r.Writer().WriteError(w, r, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) IsAuthenticated(wrap httprouter.Handle, onUnauthenticated httprouter.Handle) httprouter.Handle { return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { if _, err := h.r.SessionManager().FetchFromRequest(r.Context(), r); err != nil { diff --git a/session/handler_test.go b/session/handler_test.go index 4e3bdd79438e..024b4ddcc8d3 100644 --- a/session/handler_test.go +++ b/session/handler_test.go @@ -1,13 +1,19 @@ package session_test import ( + "context" "fmt" "net/http" "net/http/httptest" "testing" "time" + "github.com/gofrs/uuid" + "github.com/pkg/errors" + "github.com/ory/kratos/corpx" + "github.com/ory/kratos/identity" + "github.com/ory/x/sqlcon" "github.com/julienschmidt/httprouter" "github.com/stretchr/testify/assert" @@ -213,3 +219,46 @@ func TestIsAuthenticated(t *testing.T) { }) } } + +func TestHandlerDeleteSessionByIdentityID(t *testing.T) { + conf, reg := internal.NewFastRegistryWithMocks(t) + _, ts, _, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg) + + // set this intermediate because kratos needs some valid url for CRUDE operations + conf.MustSet(config.ViperKeyPublicBaseURL, "http://example.com") + testhelpers.SetDefaultIdentitySchema(t, conf, "file://./stub/identity.schema.json") + conf.MustSet(config.ViperKeyPublicBaseURL, ts.URL) + + t.Run("case=should return 202 after invalidating all sessions", func(t *testing.T) { + client := testhelpers.NewClientWithCookies(t) + i := identity.NewIdentity("") + require.NoError(t, reg.IdentityManager().Create(context.Background(), i)) + s := &Session{Identity: i} + require.NoError(t, reg.SessionPersister().CreateSession(context.Background(), s)) + + req, _ := http.NewRequest("DELETE", ts.URL + "/identities/"+i.ID.String()+"/sessions", nil) + res, err := client.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, res.StatusCode) + + _, err = reg.SessionPersister().GetSession(context.Background(), s.ID) + require.True(t, errors.Is(err, sqlcon.ErrNoRows)) + }) + + t.Run("case=should return 400 when bad UUID is sent", func(t *testing.T) { + client := testhelpers.NewClientWithCookies(t) + req, _ := http.NewRequest("DELETE", ts.URL + "/identities/BADUUID/sessions", nil) + res, err := client.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, res.StatusCode) + }) + + t.Run("case=should return 404 when calling with missing UUID", func(t *testing.T) { + client := testhelpers.NewClientWithCookies(t) + someID, _ := uuid.NewV4() + req, _ := http.NewRequest("DELETE", ts.URL + "/identities/"+someID.String()+"/sessions", nil) + res, err := client.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) +} diff --git a/spec/api.json b/spec/api.json index 0d013dc75a14..181e1794f32a 100755 --- a/spec/api.json +++ b/spec/api.json @@ -1750,6 +1750,77 @@ ] } }, + "/identity/{id}/sessions": { + "delete": { + "description": "This endpoint is useful for:\n\nTo forcefully logout Identity from all devices and sessions", + "operationId": "adminDeleteIdentitySessions", + "parameters": [ + { + "description": "ID is the identity's ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/emptyResponse" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/jsonError" + } + } + }, + "description": "jsonError" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/jsonError" + } + } + }, + "description": "jsonError" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/jsonError" + } + } + }, + "description": "jsonError" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/jsonError" + } + } + }, + "description": "jsonError" + } + }, + "security": [ + { + "oryAccessToken": [] + } + ], + "summary": "Calling this endpoint irrecoverably and permanently deletes and invalidates all sessions that belong to the given Identity.", + "tags": [ + "v0alpha1" + ] + } + }, "/metrics/prometheus": { "get": { "description": "```\nmetadata:\nannotations:\nprometheus.io/port: \"4434\"\nprometheus.io/path: \"/metrics/prometheus\"\n```", diff --git a/spec/swagger.json b/spec/swagger.json index 38ff916ce51b..fc1be8b229e3 100755 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -358,6 +358,63 @@ } } }, + "/identity/{id}/sessions": { + "delete": { + "security": [ + { + "oryAccessToken": [] + } + ], + "description": "This endpoint is useful for:\n\nTo forcefully logout Identity from all devices and sessions", + "schemes": [ + "http", + "https" + ], + "tags": [ + "v0alpha1" + ], + "summary": "Calling this endpoint irrecoverably and permanently deletes and invalidates all sessions that belong to the given Identity.", + "operationId": "adminDeleteIdentitySessions", + "parameters": [ + { + "type": "string", + "description": "ID is the identity's ID.", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/emptyResponse" + }, + "400": { + "description": "jsonError", + "schema": { + "$ref": "#/definitions/jsonError" + } + }, + "401": { + "description": "jsonError", + "schema": { + "$ref": "#/definitions/jsonError" + } + }, + "404": { + "description": "jsonError", + "schema": { + "$ref": "#/definitions/jsonError" + } + }, + "500": { + "description": "jsonError", + "schema": { + "$ref": "#/definitions/jsonError" + } + } + } + } + }, "/metrics/prometheus": { "get": { "description": "```\nmetadata:\nannotations:\nprometheus.io/port: \"4434\"\nprometheus.io/path: \"/metrics/prometheus\"\n```",