Skip to content

Commit

Permalink
Merge pull request #5 from nayla-finance/inactive-account
Browse files Browse the repository at this point in the history
Add Inactive account endpoint
  • Loading branch information
aalkhodiry authored Nov 5, 2024
2 parents 51912d7 + 2c790a0 commit 98410f6
Show file tree
Hide file tree
Showing 9 changed files with 191 additions and 1 deletion.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,4 @@ test/e2e/kratos.*.yml
__debug_bin*
.debug.sqlite.db
.last-run.json
.env
17 changes: 17 additions & 0 deletions .schema/version.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@
"$id": "https://github.com/ory/kratos/.schema/versions.config.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"oneOf": [
{
"allOf": [
{
"properties": {
"version": {
"const": "v1.5.0"
}
},
"required": [
"version"
]
},
{
"$ref": "https://raw.githubusercontent.com/nayla-finance/kratos/v1.5.0/.schemastore/config.schema.json"
}
]
},
{
"allOf": [
{
Expand Down
14 changes: 14 additions & 0 deletions .schemastore/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1310,12 +1310,14 @@
"properties": {
"attempt_window": {
"type": "string",
"description": "The time window in which login attempts are considered. After this window, failed attempts are forgotten.",
"pattern": "^([0-9]+(s|m|h))+$",
"default": "5m",
"examples": ["1h", "1m", "30s"]
},
"max_attempts": {
"type": "number",
"description": "The maximum number of failed login attempts (within the attempt window) before a user is blocked.",
"default": "3",
"examples": ["3", "5"]
}
Expand Down Expand Up @@ -2060,6 +2062,9 @@
"properties": {
"email": {
"$ref": "#/definitions/emailCourierTemplate"
},
"sms": {
"$ref": "#/definitions/smsCourierTemplate"
}
},
"required": ["email"]
Expand Down Expand Up @@ -2644,6 +2649,15 @@
},
"required": ["id", "url"]
}
},
"inactivity_threshold_in_months": {
"title": "Identity Inactivity Threshold",
"description": "The period (in months) after which an identity is considered inactive if there are no active sessions for it.",
"type": "integer",
"minimum": 1,
"maximum": 12,
"default": 6,
"examples": [1, 6, 12]
}
},
"required": ["schemas"],
Expand Down
5 changes: 5 additions & 0 deletions driver/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ const (
ViperKeySelfServiceVerificationNotifyUnknownRecipients = "selfservice.flows.verification.notify_unknown_recipients"
ViperKeyDefaultIdentitySchemaID = "identity.default_schema_id"
ViperKeyIdentitySchemas = "identity.schemas"
ViperKeyIdentityInactivityThresholdInMonths = "identity.inactivity_threshold_in_months"
ViperKeyHasherAlgorithm = "hashers.algorithm"
ViperKeyHasherArgon2ConfigMemory = "hashers.argon2.memory"
ViperKeyHasherArgon2ConfigIterations = "hashers.argon2.iterations"
Expand Down Expand Up @@ -606,6 +607,10 @@ func (p *Config) DefaultIdentityTraitsSchemaID(ctx context.Context) string {
return p.GetProvider(ctx).String(ViperKeyDefaultIdentitySchemaID)
}

func (p *Config) IdentityInactivityThresholdInMonths(ctx context.Context) int {
return p.GetProvider(ctx).IntF(ViperKeyIdentityInactivityThresholdInMonths, 12)
}

func (p *Config) TOTPIssuer(ctx context.Context) string {
return p.GetProvider(ctx).StringF(ViperKeyTOTPIssuer, p.SelfPublicURL(ctx).Hostname())
}
Expand Down
11 changes: 11 additions & 0 deletions embedx/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1310,12 +1310,14 @@
"properties": {
"attempt_window": {
"type": "string",
"description": "The time window in which login attempts are considered. After this window, failed attempts are forgotten.",
"pattern": "^([0-9]+(s|m|h))+$",
"default": "5m",
"examples": ["1h", "1m", "1s"]
},
"max_attempts": {
"type": "number",
"description": "The maximum number of failed login attempts (within the attempt window) before a user is blocked.",
"default": "3",
"examples": ["3", "5"]
}
Expand Down Expand Up @@ -2647,6 +2649,15 @@
},
"required": ["id", "url"]
}
},
"inactivity_threshold_in_months": {
"title": "Identity Inactivity Threshold",
"description": "The period (in months) after which an identity is considered inactive if there are no active sessions for it.",
"type": "integer",
"minimum": 1,
"maximum": 12,
"default": 6,
"examples": [1, 6, 12]
}
},
"required": ["schemas"],
Expand Down
74 changes: 74 additions & 0 deletions identity/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -44,6 +45,8 @@ const (
RouteItem = RouteCollection + "/:id"
RouteCredentialItem = RouteItem + "/credentials/:type"

AdminRouteInactiveIdentities = "inactive-identities"

BatchPatchIdentitiesLimit = 2000
)

Expand Down Expand Up @@ -114,6 +117,8 @@ func (h *Handler) RegisterAdminRoutes(admin *x.RouterAdmin) {
admin.PUT(RouteItem, h.update)

admin.DELETE(RouteCredentialItem, h.deleteIdentityCredentials)

admin.PUT(AdminRouteInactiveIdentities, h.listInactiveIdentities)
}

// Paginated Identity List Response
Expand Down Expand Up @@ -1023,3 +1028,72 @@ func (h *Handler) deleteIdentityCredentials(w http.ResponseWriter, r *http.Reque

w.WriteHeader(http.StatusNoContent)
}

// swagger:route PUT /admin/inactive-identities identity listInactiveIdentities
//
// # List Inactive Identities
//
// Lists and deactivates identities that have been inactive for a configurable period of time. An identity is considered
// inactive if it has no active sessions within the configured inactivity threshold period. The endpoint
// returns basic identity information including ID, state, and timestamps.
//
// This endpoint will run deactivation logic on all identities who have not been active for X months.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Security:
// oryAccessToken:
//
// Query Parameters:
// limit: The maximum number of identities to return.
//
// Responses:
// 200: listInactiveIdentitiesResponse
// 400: errorGeneric
// default: errorGeneric
func (h *Handler) listInactiveIdentities(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil {
limit = 50 // Default limit
}

if limit <= 0 {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Limit must be greater than 0.")))
return
}

inactiveIdentities, err := h.r.IdentityManager().ListIdentitiesForDeactivation(
r.Context(),
h.r.Config().IdentityInactivityThresholdInMonths(r.Context()),
limit,
)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}

identities := make([]map[string]interface{}, len(inactiveIdentities))

if len(inactiveIdentities) > 0 {
ids := make([]string, len(inactiveIdentities))
for i, id := range inactiveIdentities {
identities[i] = map[string]interface{}{
"id": id.ID,
"state": id.State,
"created_at": id.CreatedAt,
"updated_at": id.UpdatedAt,
}
ids[i] = id.ID.String()
}

if err := h.r.IdentityManager().DeactivateIdentities(r.Context(), ids); err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
}

h.r.Writer().Write(w, r, identities)
}
15 changes: 15 additions & 0 deletions identity/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ func (e *CreateIdentitiesError) Error() string {
e.init()
return fmt.Sprintf("create identities error: %d identities failed", len(e.failedIdentities))
}

func (e *CreateIdentitiesError) Unwrap() []error {
e.init()
var errs []error
Expand All @@ -350,17 +351,20 @@ func (e *CreateIdentitiesError) AddFailedIdentity(ident *Identity, err *herodot.
e.init()
e.failedIdentities[ident] = err
}

func (e *CreateIdentitiesError) Merge(other *CreateIdentitiesError) {
e.init()
for k, v := range other.failedIdentities {
e.failedIdentities[k] = v
}
}

func (e *CreateIdentitiesError) Contains(ident *Identity) bool {
e.init()
_, found := e.failedIdentities[ident]
return found
}

func (e *CreateIdentitiesError) Find(ident *Identity) *FailedIdentity {
e.init()
if err, found := e.failedIdentities[ident]; found {
Expand All @@ -369,12 +373,14 @@ func (e *CreateIdentitiesError) Find(ident *Identity) *FailedIdentity {

return nil
}

func (e *CreateIdentitiesError) ErrOrNil() error {
if e.failedIdentities == nil || len(e.failedIdentities) == 0 {
return nil
}
return e
}

func (e *CreateIdentitiesError) init() {
if e.failedIdentities == nil {
e.failedIdentities = map[*Identity]*herodot.DefaultError{}
Expand Down Expand Up @@ -584,3 +590,12 @@ func (m *Manager) CountActiveMultiFactorCredentials(ctx context.Context, i *Iden
}
return count, nil
}

func (m *Manager) ListIdentitiesForDeactivation(ctx context.Context, period int, limit int) (identities []*Identity, err error) {
m.r.Logger().Println("Listing identities to deactivate")
return m.r.PrivilegedIdentityPool().ListIdentitiesForDeactivation(ctx, period, limit)
}

func (m *Manager) DeactivateIdentities(ctx context.Context, ids []string) (err error) {
return m.r.PrivilegedIdentityPool().DeactivateIdentities(ctx, ids)
}
6 changes: 6 additions & 0 deletions identity/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ type (

// FindIdentityByWebauthnUserHandle returns an identity matching a webauthn user handle.
FindIdentityByWebauthnUserHandle(ctx context.Context, userHandle []byte) (*Identity, error)

// ListIdentitiesForDeactivation lists identities that have not been active for a given period.
ListIdentitiesForDeactivation(ctx context.Context, period int, limit int) ([]*Identity, error)

// DeactivateIdentities deactivates identities.
DeactivateIdentities(ctx context.Context, ids []string) error
}
)

Expand Down
49 changes: 48 additions & 1 deletion persistence/sql/identity/persister_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,6 @@ func (p *IdentityPersister) CreateIdentities(ctx context.Context, identities ...
for _, k := range paritalErr.Failed {
failedIdentityIDs[k.IdentityID] = struct{}{}
}

} else if paritalErr := new(batch.PartialConflictError[identity.CredentialIdentifier]); errors.As(err, &paritalErr) {
for _, k := range paritalErr.Failed {
credID := k.IdentityCredentialsID
Expand Down Expand Up @@ -1335,3 +1334,51 @@ func (p *IdentityPersister) InjectTraitsSchemaURL(ctx context.Context, i *identi
i.SchemaURL = s.SchemaURL(p.r.Config().SelfPublicURL(ctx)).String()
return nil
}

func (p *IdentityPersister) ListIdentitiesForDeactivation(ctx context.Context, period int, limit int) (identities []*identity.Identity, err error) {
p.r.Logger().Println("Listing identities to deactivate")

q := p.GetConnection(ctx).RawQuery(`
WITH last_active_sessions AS (
SELECT
identity_id,
MAX(expires_at) as last_session_expiry
FROM
sessions
GROUP BY
identity_id
)
SELECT
i.id,
i.state,
i.created_at,
i.updated_at
FROM
identities i
INNER JOIN last_active_sessions las ON i.id = las.identity_id
WHERE
las.last_session_expiry < NOW() - (? || ' months')::INTERVAL
and i.state = 'active'
LIMIT ?
`, fmt.Sprintf("%d", period), limit)

if err := q.All(&identities); err != nil {
return nil, err
}

return identities, nil
}

func (p *IdentityPersister) DeactivateIdentities(ctx context.Context, ids []string) (err error) {
p.r.Logger().Println("Deactivating identities...")

q := p.GetConnection(ctx).RawQuery(`
UPDATE identities SET state = ? WHERE id IN (?)
`, identity.StateInactive, ids)

if err := q.Exec(); err != nil {
return err
}

return nil
}

0 comments on commit 98410f6

Please sign in to comment.