diff --git a/backend/cmd/jwt/create.go b/backend/cmd/jwt/create.go index 4a2c55bd7..b170d5fb4 100644 --- a/backend/cmd/jwt/create.go +++ b/backend/cmd/jwt/create.go @@ -9,6 +9,7 @@ import ( "github.com/teamhanko/hanko/backend/crypto/jwk" "github.com/teamhanko/hanko/backend/dto" "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/persistence/models" "github.com/teamhanko/hanko/backend/session" "log" ) @@ -66,12 +67,34 @@ func NewCreateCommand() *cobra.Command { emailJwt = dto.JwtFromEmailModel(e) } - token, err := sessionManager.GenerateJWT(userId, emailJwt) + token, rawToken, err := sessionManager.GenerateJWT(userId, emailJwt) if err != nil { fmt.Printf("failed to generate token: %s", err) return } + if cfg.Session.ServerSide.Enabled { + sessionID, _ := rawToken.Get("session_id") + + expirationTime := rawToken.Expiration() + sessionModel := models.Session{ + ID: uuid.FromStringOrNil(sessionID.(string)), + UserID: userId, + UserAgent: "", + IpAddress: "", + CreatedAt: rawToken.IssuedAt(), + UpdatedAt: rawToken.IssuedAt(), + ExpiresAt: &expirationTime, + LastUsed: rawToken.IssuedAt(), + } + + err = persister.GetSessionPersister().Create(sessionModel) + if err != nil { + fmt.Printf("failed to store session: %s", err) + return + } + } + fmt.Printf("token: %s", token) }, } diff --git a/backend/config/config_default.go b/backend/config/config_default.go index 1a093099e..2f16652d1 100644 --- a/backend/config/config_default.go +++ b/backend/config/config_default.go @@ -75,6 +75,10 @@ func DefaultConfig() *Config { SameSite: "strict", Secure: true, }, + ServerSide: ServerSide{ + Enabled: false, + Limit: 100, + }, }, AuditLog: AuditLog{ ConsoleOutput: AuditLogConsole{ diff --git a/backend/config/config_session.go b/backend/config/config_session.go index 8f6c931f8..1539cee72 100644 --- a/backend/config/config_session.go +++ b/backend/config/config_session.go @@ -19,10 +19,12 @@ type Session struct { // `issuer` is a string that identifies the principal (human user, an organization, or a service) // that issued the JWT. Its value is set in the `iss` claim of a JWT. Issuer string `yaml:"issuer" json:"issuer,omitempty" koanf:"issuer"` - // `lifespan` determines how long a session token (JWT) is valid. It must be a (possibly signed) sequence of decimal + // `lifespan` determines the maximum duration for which a session token (JWT) is valid. It must be a (possibly signed) sequence of decimal // numbers, each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45m". // Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". Lifespan string `yaml:"lifespan" json:"lifespan,omitempty" koanf:"lifespan" jsonschema:"default=12h"` + // `server_side` contains configuration for server-side sessions. + ServerSide ServerSide `yaml:"server_side" json:"server_side" koanf:"server_side"` } func (s *Session) Validate() error { @@ -61,3 +63,13 @@ func (c *Cookie) GetName() string { return "hanko" } + +type ServerSide struct { + // `enabled` determines whether server-side sessions are enabled. + // + // NOTE: When enabled the session endpoint must be used in order to check if a session is still valid. + Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=false"` + // `limit` determines the maximum number of server-side sessions a user can have. When the limit is exceeded, + // older sessions are invalidated. + Limit int `yaml:"limit" json:"limit,omitempty" koanf:"limit" jsonschema:"default=100"` +} diff --git a/backend/dto/session.go b/backend/dto/session.go new file mode 100644 index 000000000..60ed33e14 --- /dev/null +++ b/backend/dto/session.go @@ -0,0 +1,44 @@ +package dto + +import ( + "fmt" + "github.com/gofrs/uuid" + "github.com/mileusna/useragent" + "github.com/teamhanko/hanko/backend/persistence/models" + "time" +) + +type SessionData struct { + ID uuid.UUID `json:"id"` + UserAgentRaw string `json:"user_agent_raw"` + UserAgent string `json:"user_agent"` + IpAddress string `json:"ip_address"` + Current bool `json:"current"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + LastUsed time.Time `json:"last_used"` +} + +func FromSessionModel(model models.Session, current bool) SessionData { + ua := useragent.Parse(model.UserAgent) + return SessionData{ + ID: model.ID, + UserAgentRaw: model.UserAgent, + UserAgent: fmt.Sprintf("%s (%s)", ua.OS, ua.Name), + IpAddress: model.IpAddress, + Current: current, + CreatedAt: model.CreatedAt, + ExpiresAt: model.ExpiresAt, + LastUsed: model.LastUsed, + } +} + +type ValidateSessionResponse struct { + IsValid bool `json:"is_valid"` + ExpirationTime *time.Time `json:"expiration_time,omitempty"` + UserID *uuid.UUID `json:"user_id,omitempty"` +} + +type ValidateSessionRequest struct { + SessionToken string `json:"session_token" validate:"required"` +} diff --git a/backend/flow_api/flow/flows.go b/backend/flow_api/flow/flows.go index 928ef9c0a..96d839627 100644 --- a/backend/flow_api/flow/flows.go +++ b/backend/flow_api/flow/flows.go @@ -147,6 +147,7 @@ func NewProfileFlow(debug bool) flowpilot.Flow { profile.WebauthnCredentialRename{}, profile.WebauthnCredentialCreate{}, profile.WebauthnCredentialDelete{}, + profile.SessionDelete{}, ). State(shared.StateProfileWebauthnCredentialVerification, profile.WebauthnVerifyAttestationResponse{}, @@ -155,7 +156,7 @@ func NewProfileFlow(debug bool) flowpilot.Flow { InitialState(shared.StatePreflight, shared.StateProfileInit). ErrorState(shared.StateError). BeforeEachAction(profile.RefreshSessionUser{}). - BeforeState(shared.StateProfileInit, profile.GetProfileData{}). + BeforeState(shared.StateProfileInit, profile.GetProfileData{}, profile.GetSessions{}). AfterState(shared.StateProfileWebauthnCredentialVerification, shared.WebauthnCredentialSave{}). AfterState(shared.StatePasscodeConfirmation, shared.EmailPersistVerifiedStatus{}). SubFlows( diff --git a/backend/flow_api/flow/profile/action_session_delete.go b/backend/flow_api/flow/profile/action_session_delete.go new file mode 100644 index 000000000..c41f88401 --- /dev/null +++ b/backend/flow_api/flow/profile/action_session_delete.go @@ -0,0 +1,67 @@ +package profile + +import ( + "fmt" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type SessionDelete struct { + shared.Action +} + +func (a SessionDelete) GetName() flowpilot.ActionName { + return shared.ActionSessionDelete +} + +func (a SessionDelete) GetDescription() string { + return "Delete a session." +} + +func (a SessionDelete) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + if !deps.Cfg.Session.ServerSide.Enabled { + c.SuspendAction() + } + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + c.SuspendAction() + return + } + + input := flowpilot.StringInput("session_id").Required(true).Hidden(true) + + currentSessionID := uuid.FromStringOrNil(c.Get("session_id").(string)) + sessions, err := deps.Persister.GetSessionPersisterWithConnection(deps.Tx).ListActive(userModel.ID) + if err != nil { + c.SuspendAction() + return + } + + for _, session := range sessions { + if session.ID != currentSessionID { + input.AllowedValue(session.ID.String(), session.ID.String()) + } + } + + c.AddInputs(input) +} + +func (a SessionDelete) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + sessionToBeDeleted := uuid.FromStringOrNil(c.Input().Get("session_id").String()) + + session, err := deps.Persister.GetSessionPersisterWithConnection(deps.Tx).Get(sessionToBeDeleted) + if err != nil { + return fmt.Errorf("failed to get session from db: %w", err) + } + + if session != nil { + err = deps.Persister.GetSessionPersisterWithConnection(deps.Tx).Delete(*session) + } + + return c.Continue(shared.StateProfileInit) +} diff --git a/backend/flow_api/flow/profile/hook_get_sessions.go b/backend/flow_api/flow/profile/hook_get_sessions.go new file mode 100644 index 000000000..4b91ce97b --- /dev/null +++ b/backend/flow_api/flow/profile/hook_get_sessions.go @@ -0,0 +1,47 @@ +package profile + +import ( + "errors" + "fmt" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/dto" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type GetSessions struct { + shared.Action +} + +func (h GetSessions) Execute(c flowpilot.HookExecutionContext) error { + deps := h.GetDeps(c) + + if !deps.Cfg.Session.ServerSide.Enabled { + return nil + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return errors.New("no valid session") + } + + activeSessions, err := deps.Persister.GetSessionPersisterWithConnection(deps.Tx).ListActive(userModel.ID) + if err != nil { + return fmt.Errorf("failed to get sessions from db: %w", err) + } + + currentSessionID := uuid.FromStringOrNil(c.Get("session_id").(string)) + + sessionsDto := make([]dto.SessionData, len(activeSessions)) + for i := range activeSessions { + sessionsDto[i] = dto.FromSessionModel(activeSessions[i], activeSessions[i].ID == currentSessionID) + } + + err = c.Payload().Set("sessions", sessionsDto) + if err != nil { + return fmt.Errorf("failed to set sessions payload: %w", err) + } + + return nil +} diff --git a/backend/flow_api/flow/profile/hook_refresh_session_user.go b/backend/flow_api/flow/profile/hook_refresh_session_user.go index de5a557cb..1afaee479 100644 --- a/backend/flow_api/flow/profile/hook_refresh_session_user.go +++ b/backend/flow_api/flow/profile/hook_refresh_session_user.go @@ -35,5 +35,10 @@ func (h RefreshSessionUser) Execute(c flowpilot.HookExecutionContext) error { c.Set("session_user", userModel) } + sessionId, found := sessionToken.Get("session_id") + if found { + c.Set("session_id", sessionId) + } + return nil } diff --git a/backend/flow_api/flow/shared/const_action_names.go b/backend/flow_api/flow/shared/const_action_names.go index 8b9ba163c..64c7bad31 100644 --- a/backend/flow_api/flow/shared/const_action_names.go +++ b/backend/flow_api/flow/shared/const_action_names.go @@ -39,4 +39,5 @@ const ( ActionWebauthnGenerateRequestOptions flowpilot.ActionName = "webauthn_generate_request_options" ActionWebauthnVerifyAssertionResponse flowpilot.ActionName = "webauthn_verify_assertion_response" ActionWebauthnVerifyAttestationResponse flowpilot.ActionName = "webauthn_verify_attestation_response" + ActionSessionDelete flowpilot.ActionName = "session_delete" ) diff --git a/backend/flow_api/flow/shared/hook_issue_session.go b/backend/flow_api/flow/shared/hook_issue_session.go index 0e6aa7b62..468810e07 100644 --- a/backend/flow_api/flow/shared/hook_issue_session.go +++ b/backend/flow_api/flow/shared/hook_issue_session.go @@ -39,12 +39,48 @@ func (h IssueSession) Execute(c flowpilot.HookExecutionContext) error { emailDTO = dto.JwtFromEmailModel(email) } - sessionToken, err := deps.SessionManager.GenerateJWT(userId, emailDTO) + signedSessionToken, rawToken, err := deps.SessionManager.GenerateJWT(userId, emailDTO) if err != nil { return fmt.Errorf("failed to generate JWT: %w", err) } - cookie, err := deps.SessionManager.GenerateCookie(sessionToken) + activeSessions, err := deps.Persister.GetSessionPersisterWithConnection(deps.Tx).ListActive(userId) + if err != nil { + return fmt.Errorf("failed to list active sessions: %w", err) + } + + if deps.Cfg.Session.ServerSide.Enabled { + // remove all server side sessions that exceed the limit + if len(activeSessions) >= deps.Cfg.Session.ServerSide.Limit { + for i := deps.Cfg.Session.ServerSide.Limit - 1; i < len(activeSessions); i++ { + err = deps.Persister.GetSessionPersisterWithConnection(deps.Tx).Delete(activeSessions[i]) + if err != nil { + return fmt.Errorf("failed to remove latest session: %w", err) + } + } + } + + sessionID, _ := rawToken.Get("session_id") + + expirationTime := rawToken.Expiration() + sessionModel := models.Session{ + ID: uuid.FromStringOrNil(sessionID.(string)), + UserID: userId, + UserAgent: deps.HttpContext.Request().UserAgent(), + IpAddress: deps.HttpContext.RealIP(), + CreatedAt: rawToken.IssuedAt(), + UpdatedAt: rawToken.IssuedAt(), + ExpiresAt: &expirationTime, + LastUsed: rawToken.IssuedAt(), + } + + err = deps.Persister.GetSessionPersisterWithConnection(deps.Tx).Create(sessionModel) + if err != nil { + return fmt.Errorf("failed to store session: %w", err) + } + } + + cookie, err := deps.SessionManager.GenerateCookie(signedSessionToken) if err != nil { return fmt.Errorf("failed to generate auth cookie, %w", err) } @@ -52,7 +88,7 @@ func (h IssueSession) Execute(c flowpilot.HookExecutionContext) error { deps.HttpContext.Response().Header().Set("X-Session-Lifetime", fmt.Sprintf("%d", cookie.MaxAge)) if deps.Cfg.Session.EnableAuthTokenHeader { - deps.HttpContext.Response().Header().Set("X-Auth-Token", sessionToken) + deps.HttpContext.Response().Header().Set("X-Auth-Token", signedSessionToken) } else { deps.HttpContext.SetCookie(cookie) } diff --git a/backend/flow_api/handler.go b/backend/flow_api/handler.go index bb98321d7..552ed45d3 100644 --- a/backend/flow_api/handler.go +++ b/backend/flow_api/handler.go @@ -1,8 +1,10 @@ package flow_api import ( + "errors" "fmt" "github.com/gobuffalo/pop/v6" + "github.com/gofrs/uuid" echojwt "github.com/labstack/echo-jwt/v4" "github.com/labstack/echo/v4" "github.com/rs/zerolog" @@ -10,6 +12,7 @@ import ( "github.com/sethvargo/go-limiter" auditlog "github.com/teamhanko/hanko/backend/audit_log" "github.com/teamhanko/hanko/backend/config" + "github.com/teamhanko/hanko/backend/dto" "github.com/teamhanko/hanko/backend/ee/saml" "github.com/teamhanko/hanko/backend/flow_api/flow" "github.com/teamhanko/hanko/backend/flow_api/flow/shared" @@ -81,6 +84,36 @@ func (h *FlowPilotHandler) validateSession(c echo.Context) error { continue } + if h.Cfg.Session.ServerSide.Enabled { + // check that the session id is stored in the database + sessionId, ok := token.Get("session_id") + if !ok { + lastTokenErr = errors.New("no session id found in token") + continue + } + sessionID, err := uuid.FromString(sessionId.(string)) + if err != nil { + lastTokenErr = errors.New("session id has wrong format") + continue + } + + sessionModel, err := h.Persister.GetSessionPersister().Get(sessionID) + if err != nil { + return fmt.Errorf("failed to get session from database: %w", err) + } + if sessionModel == nil { + lastTokenErr = fmt.Errorf("session id not found in database") + continue + } + + // Update lastUsed field + sessionModel.LastUsed = time.Now().UTC() + err = h.Persister.GetSessionPersister().Update(*sessionModel) + if err != nil { + return dto.ToHttpError(err) + } + } + c.Set("session", token) return nil diff --git a/backend/go.mod b/backend/go.mod index 22472c0e9..e9dad0459 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -28,6 +28,7 @@ require ( github.com/labstack/gommon v0.4.2 github.com/lestrrat-go/jwx/v2 v2.1.0 github.com/lib/pq v1.10.9 + github.com/mileusna/useragent v1.3.5 github.com/mitchellh/mapstructure v1.5.0 github.com/nicksnyder/go-i18n/v2 v2.4.0 github.com/ory/dockertest/v3 v3.10.0 diff --git a/backend/go.sum b/backend/go.sum index 4dac7e308..ddc264653 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,6 +6,7 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/ClickHouse/ch-go v0.55.0 h1:jw4Tpx887YXrkyL5DfgUome/po8MLz92nz2heOQ6RjQ= github.com/ClickHouse/ch-go v0.55.0/go.mod h1:kQT2f+yp2p+sagQA/7kS6G3ukym+GQ5KAu1kuFAFDiU= github.com/ClickHouse/clickhouse-go/v2 v2.9.1 h1:IeE2bwVvAba7Yw5ZKu98bKI4NpDmykEy6jUaQdJJCk8= @@ -78,12 +79,14 @@ github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw= +github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo= github.com/docker/cli v23.0.1+incompatible h1:LRyWITpGzl2C9e9uGxzisptnxAn1zfZKXy13Ul2Q5oM= github.com/docker/cli v23.0.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0= @@ -126,6 +129,7 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -188,7 +192,9 @@ github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzq github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -222,6 +228,7 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk= github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -372,6 +379,7 @@ github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -441,6 +449,8 @@ github.com/microcosm-cc/bluemonday v1.0.20 h1:flpzsq4KU3QIYAYGV/szUat7H+GPOXR0B2 github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws= +github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= @@ -883,6 +893,7 @@ gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.3.0 h1:MfDY1b1/0xN1CyMlQDac0ziEy9zJQd9CXBRRDHw2jJo= +gotest.tools/v3 v3.3.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/backend/handler/email_test.go b/backend/handler/email_test.go index 4fc4e3173..db6dd83fa 100644 --- a/backend/handler/email_test.go +++ b/backend/handler/email_test.go @@ -64,7 +64,7 @@ func (s *emailSuite) TestEmailHandler_List() { for _, currentTest := range tests { s.Run(currentTest.name, func() { - token, err := sessionManager.GenerateJWT(currentTest.userId, nil) + token, _, err := sessionManager.GenerateJWT(currentTest.userId, nil) s.Require().NoError(err) cookie, err := sessionManager.GenerateCookie(token) s.Require().NoError(err) @@ -177,7 +177,7 @@ func (s *emailSuite) TestEmailHandler_Create() { sessionManager, err := session.NewManager(jwkManager, cfg) s.Require().NoError(err) - token, err := sessionManager.GenerateJWT(currentTest.userId, nil) + token, _, err := sessionManager.GenerateJWT(currentTest.userId, nil) s.Require().NoError(err) cookie, err := sessionManager.GenerateCookie(token) s.Require().NoError(err) @@ -244,7 +244,7 @@ func (s *emailSuite) TestEmailHandler_SetPrimaryEmail() { newPrimaryEmailId := uuid.FromStringOrNil("8bb4c8a7-a3e6-48bb-b54f-20e3b485ab33") userId := uuid.FromStringOrNil("b5dd5267-b462-48be-b70d-bcd6f1bbe7a5") - token, err := sessionManager.GenerateJWT(userId, nil) + token, _, err := sessionManager.GenerateJWT(userId, nil) s.NoError(err) cookie, err := sessionManager.GenerateCookie(token) s.NoError(err) @@ -285,7 +285,7 @@ func (s *emailSuite) TestEmailHandler_Delete() { sessionManager, err := session.NewManager(jwkManager, test.DefaultConfig) s.Require().NoError(err) - token, err := sessionManager.GenerateJWT(userId, nil) + token, _, err := sessionManager.GenerateJWT(userId, nil) s.NoError(err) cookie, err := sessionManager.GenerateCookie(token) s.NoError(err) diff --git a/backend/handler/passcode.go b/backend/handler/passcode.go index e5cd8d5bf..7de5b2c68 100644 --- a/backend/handler/passcode.go +++ b/backend/handler/passcode.go @@ -401,7 +401,7 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { emailJwt = dto.JwtFromEmailModel(e) } - token, err := h.sessionManager.GenerateJWT(*passcode.UserId, emailJwt) + token, _, err := h.sessionManager.GenerateJWT(*passcode.UserId, emailJwt) if err != nil { return fmt.Errorf("failed to generate jwt: %w", err) } diff --git a/backend/handler/passcode_test.go b/backend/handler/passcode_test.go index a094ba921..ebde26e48 100644 --- a/backend/handler/passcode_test.go +++ b/backend/handler/passcode_test.go @@ -301,13 +301,13 @@ func (s *passcodeSuite) TestPasscodeHandler_Finish() { req := httptest.NewRequest(http.MethodPost, "/passcode/login/finalize", bytes.NewReader(bodyJson)) req.Header.Set("Content-Type", "application/json") if currentTest.sendSessionTokenInAuthHeader { - sessionToken, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(currentTest.userId), nil) + sessionToken, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(currentTest.userId), nil) s.Require().NoError(err) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", sessionToken)) } if currentTest.sendSessionTokenInCookie { - sessionToken, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(currentTest.userId), nil) + sessionToken, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(currentTest.userId), nil) s.Require().NoError(err) sessionCookie, err := sessionManager.GenerateCookie(sessionToken) diff --git a/backend/handler/password.go b/backend/handler/password.go index 593f75d46..ce8dbe2cc 100644 --- a/backend/handler/password.go +++ b/backend/handler/password.go @@ -223,7 +223,7 @@ func (h *PasswordHandler) Login(c echo.Context) error { emailJwt = dto.JwtFromEmailModel(e) } - token, err := h.sessionManager.GenerateJWT(pw.UserId, emailJwt) + token, _, err := h.sessionManager.GenerateJWT(pw.UserId, emailJwt) if err != nil { return fmt.Errorf("failed to generate jwt: %w", err) } diff --git a/backend/handler/password_test.go b/backend/handler/password_test.go index 1d934a993..0deeb6d9b 100644 --- a/backend/handler/password_test.go +++ b/backend/handler/password_test.go @@ -91,7 +91,7 @@ func (s *passwordSuite) TestPasswordHandler_Set_Create() { s.Require().NoError(err) sessionManager := s.GetDefaultSessionManager() - token, err := sessionManager.GenerateJWT(currentTest.userId, nil) + token, _, err := sessionManager.GenerateJWT(currentTest.userId, nil) s.Require().NoError(err) cookie, err := sessionManager.GenerateCookie(token) s.Require().NoError(err) diff --git a/backend/handler/public_router.go b/backend/handler/public_router.go index 4639acfe3..eee082fa2 100644 --- a/backend/handler/public_router.go +++ b/backend/handler/public_router.go @@ -213,5 +213,10 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister, promet tokenHandler := NewTokenHandler(cfg, persister, sessionManager, auditLogger) g.POST("/token", tokenHandler.Validate) + sessionHandler := NewSessionHandler(persister, sessionManager, *cfg) + sessions := g.Group("sessions") + sessions.GET("/validate", sessionHandler.ValidateSession) + sessions.POST("/validate", sessionHandler.ValidateSessionFromBody) + return e } diff --git a/backend/handler/session.go b/backend/handler/session.go new file mode 100644 index 000000000..c27e41d47 --- /dev/null +++ b/backend/handler/session.go @@ -0,0 +1,148 @@ +package handler + +import ( + "fmt" + "github.com/gofrs/uuid" + echojwt "github.com/labstack/echo-jwt/v4" + "github.com/labstack/echo/v4" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/teamhanko/hanko/backend/config" + "github.com/teamhanko/hanko/backend/dto" + "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/session" + "net/http" + "time" +) + +type SessionHandler struct { + persister persistence.Persister + sessionManager session.Manager + cfg config.Config +} + +func NewSessionHandler(persister persistence.Persister, sessionManager session.Manager, cfg config.Config) *SessionHandler { + return &SessionHandler{ + persister: persister, + sessionManager: sessionManager, + cfg: cfg, + } +} + +func (h *SessionHandler) ValidateSession(c echo.Context) error { + lookup := fmt.Sprintf("header:Authorization:Bearer,cookie:%s", h.cfg.Session.Cookie.GetName()) + extractors, err := echojwt.CreateExtractors(lookup) + + if err != nil { + return c.JSON(http.StatusOK, dto.ValidateSessionResponse{IsValid: false}) + } + + var token jwt.Token + for _, extractor := range extractors { + auths, extractorErr := extractor(c) + if extractorErr != nil { + continue + } + for _, auth := range auths { + t, tokenErr := h.sessionManager.Verify(auth) + if tokenErr != nil { + continue + } + + if h.cfg.Session.ServerSide.Enabled { + // check that the session id is stored in the database + sessionId, ok := t.Get("session_id") + if !ok { + continue + } + sessionID, err := uuid.FromString(sessionId.(string)) + if err != nil { + continue + } + + sessionModel, err := h.persister.GetSessionPersister().Get(sessionID) + if err != nil { + return fmt.Errorf("failed to get session from database: %w", err) + } + if sessionModel == nil { + continue + } + + // Update lastUsed field + sessionModel.LastUsed = time.Now().UTC() + err = h.persister.GetSessionPersister().Update(*sessionModel) + if err != nil { + return dto.ToHttpError(err) + } + } + + token = t + break + } + } + + if token != nil { + expirationTime := token.Expiration() + userID := uuid.FromStringOrNil(token.Subject()) + return c.JSON(http.StatusOK, dto.ValidateSessionResponse{ + IsValid: true, + ExpirationTime: &expirationTime, + UserID: &userID, + }) + } else { + return c.JSON(http.StatusOK, dto.ValidateSessionResponse{IsValid: false}) + } +} + +func (h *SessionHandler) ValidateSessionFromBody(c echo.Context) error { + var request dto.ValidateSessionRequest + err := (&echo.DefaultBinder{}).BindBody(c, &request) + if err != nil { + return dto.ToHttpError(err) + } + + err = c.Validate(request) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err) + } + + token, err := h.sessionManager.Verify(request.SessionToken) + if err != nil { + return c.JSON(http.StatusOK, dto.ValidateSessionResponse{IsValid: false}) + } + + if h.cfg.Session.ServerSide.Enabled { + // check that the session id is stored in the database + sessionId, ok := token.Get("session_id") + if !ok { + return c.JSON(http.StatusOK, dto.ValidateSessionResponse{IsValid: false}) + } + sessionID, err := uuid.FromString(sessionId.(string)) + if err != nil { + return c.JSON(http.StatusOK, dto.ValidateSessionResponse{IsValid: false}) + } + + sessionModel, err := h.persister.GetSessionPersister().Get(sessionID) + if err != nil { + return dto.ToHttpError(err) + } + + if sessionModel == nil { + return c.JSON(http.StatusOK, dto.ValidateSessionResponse{IsValid: false}) + } + + // update lastUsed field + sessionModel.LastUsed = time.Now().UTC() + err = h.persister.GetSessionPersister().Update(*sessionModel) + if err != nil { + return dto.ToHttpError(err) + } + } + + expirationTime := token.Expiration() + userID := uuid.FromStringOrNil(token.Subject()) + return c.JSON(http.StatusOK, dto.ValidateSessionResponse{ + IsValid: true, + ExpirationTime: &expirationTime, + UserID: &userID, + }) +} diff --git a/backend/handler/token.go b/backend/handler/token.go index ba2b631d1..4de2daec2 100644 --- a/backend/handler/token.go +++ b/backend/handler/token.go @@ -92,7 +92,7 @@ func (h TokenHandler) Validate(c echo.Context) error { emailJwt = dto.JwtFromEmailModel(e) } - jwtToken, err := h.sessionManager.GenerateJWT(token.UserID, emailJwt) + jwtToken, _, err := h.sessionManager.GenerateJWT(token.UserID, emailJwt) if err != nil { return fmt.Errorf("failed to generate jwt: %w", err) } diff --git a/backend/handler/user.go b/backend/handler/user.go index 7304ded6b..89144d4e0 100644 --- a/backend/handler/user.go +++ b/backend/handler/user.go @@ -115,7 +115,7 @@ func (h *UserHandler) Create(c echo.Context) error { emailJwt = dto.JwtFromEmailModel(e) } - token, err := h.sessionManager.GenerateJWT(newUser.ID, emailJwt) + token, _, err := h.sessionManager.GenerateJWT(newUser.ID, emailJwt) if err != nil { return fmt.Errorf("failed to generate jwt: %w", err) @@ -310,6 +310,25 @@ func (h *UserHandler) Logout(c echo.Context) error { return fmt.Errorf("failed to get user: %w", err) } + sID, ok := sessionToken.Get("session_id") + if ok { + sessionIDString := sID.(string) + sessionID, err := uuid.FromString(sessionIDString) + if err != nil { + return fmt.Errorf("failed to convert session id to uuid: %w", err) + } + sessionModel, err := h.persister.GetSessionPersister().Get(sessionID) + if err != nil { + return fmt.Errorf("failed to get session from database: %w", err) + } + if sessionModel != nil { + err = h.persister.GetSessionPersister().Delete(*sessionModel) + if err != nil { + return fmt.Errorf("failed to delete session from database: %w", err) + } + } + } + err = h.auditLogger.Create(c, models.AuditLogUserLoggedOut, user, nil) if err != nil { return fmt.Errorf("failed to write audit log: %w", err) diff --git a/backend/handler/user_test.go b/backend/handler/user_test.go index c0305943d..10ab070ab 100644 --- a/backend/handler/user_test.go +++ b/backend/handler/user_test.go @@ -261,7 +261,7 @@ func (s *userSuite) TestUserHandler_Get() { if err != nil { panic(fmt.Errorf("failed to create session generator: %w", err)) } - token, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) + token, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) s.Require().NoError(err) cookie, err := sessionManager.GenerateCookie(token) s.Require().NoError(err) @@ -301,7 +301,7 @@ func (s *userSuite) TestUserHandler_GetUserWithWebAuthnCredential() { if err != nil { panic(fmt.Errorf("failed to create session generator: %w", err)) } - token, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) + token, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) s.Require().NoError(err) cookie, err := sessionManager.GenerateCookie(token) s.Require().NoError(err) @@ -338,7 +338,7 @@ func (s *userSuite) TestUserHandler_Get_InvalidUserId() { if err != nil { panic(fmt.Errorf("failed to create session generator: %w", err)) } - token, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) + token, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) s.Require().NoError(err) cookie, err := sessionManager.GenerateCookie(token) s.Require().NoError(err) @@ -475,7 +475,7 @@ func (s *userSuite) TestUserHandler_Me() { if err != nil { panic(fmt.Errorf("failed to create session generator: %w", err)) } - token, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) + token, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) s.Require().NoError(err) cookie, err := sessionManager.GenerateCookie(token) s.Require().NoError(err) @@ -512,7 +512,7 @@ func (s *userSuite) TestUserHandler_Logout() { if err != nil { panic(fmt.Errorf("failed to create session generator: %w", err)) } - token, err := sessionManager.GenerateJWT(userId, nil) + token, _, err := sessionManager.GenerateJWT(userId, nil) s.Require().NoError(err) cookie, err := sessionManager.GenerateCookie(token) s.Require().NoError(err) @@ -553,7 +553,7 @@ func (s *userSuite) TestUserHandler_Delete() { if err != nil { panic(fmt.Errorf("failed to create session generator: %w", err)) } - token, err := sessionManager.GenerateJWT(userId, nil) + token, _, err := sessionManager.GenerateJWT(userId, nil) s.Require().NoError(err) cookie, err := sessionManager.GenerateCookie(token) s.Require().NoError(err) diff --git a/backend/handler/webauthn.go b/backend/handler/webauthn.go index 5c9bb9f0c..4f719f8ab 100644 --- a/backend/handler/webauthn.go +++ b/backend/handler/webauthn.go @@ -423,7 +423,7 @@ func (h *WebauthnHandler) FinishAuthentication(c echo.Context) error { emailJwt = dto.JwtFromEmailModel(e) } - token, err := h.sessionManager.GenerateJWT(webauthnUser.UserId, emailJwt) + token, _, err := h.sessionManager.GenerateJWT(webauthnUser.UserId, emailJwt) if err != nil { return fmt.Errorf("failed to generate jwt: %w", err) } diff --git a/backend/handler/webauthn_test.go b/backend/handler/webauthn_test.go index 0cd11edc0..d7104d365 100644 --- a/backend/handler/webauthn_test.go +++ b/backend/handler/webauthn_test.go @@ -50,7 +50,7 @@ func (s *webauthnSuite) TestWebauthnHandler_BeginRegistration() { e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil) sessionManager := s.GetDefaultSessionManager() - token, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) + token, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) s.Require().NoError(err) cookie, err := sessionManager.GenerateCookie(token) s.Require().NoError(err) @@ -91,7 +91,7 @@ func (s *webauthnSuite) TestWebauthnHandler_FinalizeRegistration() { e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil) sessionManager := s.GetDefaultSessionManager() - token, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) + token, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) s.Require().NoError(err) cookie, err := sessionManager.GenerateCookie(token) s.Require().NoError(err) @@ -137,7 +137,7 @@ func (s *webauthnSuite) TestWebauthnHandler_FinalizeRegistration_SessionDataExpi e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil) sessionManager := s.GetDefaultSessionManager() - token, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) + token, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) s.Require().NoError(err) cookie, err := sessionManager.GenerateCookie(token) s.Require().NoError(err) @@ -332,8 +332,8 @@ var userId = "ec4ef049-5b88-4321-a173-21b0eff06a04" type sessionManager struct { } -func (s sessionManager) GenerateJWT(_ uuid.UUID, _ *dto.EmailJwt) (string, error) { - return userId, nil +func (s sessionManager) GenerateJWT(_ uuid.UUID, _ *dto.EmailJwt) (string, jwt.Token, error) { + return userId, nil, nil } func (s sessionManager) GenerateCookie(token string) (*http.Cookie, error) { diff --git a/backend/persistence/migrations/20241002113000_create_sessions.down.fizz b/backend/persistence/migrations/20241002113000_create_sessions.down.fizz new file mode 100644 index 000000000..dc5c982c8 --- /dev/null +++ b/backend/persistence/migrations/20241002113000_create_sessions.down.fizz @@ -0,0 +1 @@ +drop_table("sessions") diff --git a/backend/persistence/migrations/20241002113000_create_sessions.up.fizz b/backend/persistence/migrations/20241002113000_create_sessions.up.fizz new file mode 100644 index 000000000..b608373f1 --- /dev/null +++ b/backend/persistence/migrations/20241002113000_create_sessions.up.fizz @@ -0,0 +1,10 @@ +create_table("sessions") { + t.Column("id", "uuid", {primary: true}) + t.Column("user_id", "uuid", { "null": false }) + t.Column("user_agent", "string", { "null": false }) + t.Column("ip_address", "string", { "null": false }) + t.Column("expires_at", "timestamp", { "null": true }) + t.Column("last_used", "timestamp", { "null": false }) + t.Timestamps() + t.ForeignKey("user_id", {"users": ["id"]}, {"on_delete": "cascade", "on_update": "cascade"}) +} diff --git a/backend/persistence/models/session.go b/backend/persistence/models/session.go new file mode 100644 index 000000000..6dfe7df2c --- /dev/null +++ b/backend/persistence/models/session.go @@ -0,0 +1,30 @@ +package models + +import ( + "github.com/gobuffalo/pop/v6" + "github.com/gobuffalo/validate/v3" + "github.com/gobuffalo/validate/v3/validators" + "github.com/gofrs/uuid" + "time" +) + +type Session struct { + ID uuid.UUID `db:"id"` + UserID uuid.UUID `db:"user_id"` + UserAgent string `db:"user_agent"` + IpAddress string `db:"ip_address"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + ExpiresAt *time.Time `db:"expires_at"` + LastUsed time.Time `db:"last_used"` +} + +func (session *Session) Validate(tx *pop.Connection) (*validate.Errors, error) { + return validate.Validate( + &validators.UUIDIsPresent{Name: "ID", Field: session.ID}, + &validators.UUIDIsPresent{Name: "UserID", Field: session.UserID}, + &validators.TimeIsPresent{Name: "LastUsed", Field: session.UpdatedAt}, + &validators.TimeIsPresent{Name: "UpdatedAt", Field: session.UpdatedAt}, + &validators.TimeIsPresent{Name: "CreatedAt", Field: session.CreatedAt}, + ), nil +} diff --git a/backend/persistence/persister.go b/backend/persistence/persister.go index 07d2f5494..157d6e733 100644 --- a/backend/persistence/persister.go +++ b/backend/persistence/persister.go @@ -47,6 +47,8 @@ type Persister interface { GetWebhookPersister(tx *pop.Connection) WebhookPersister GetUsernamePersister() UsernamePersister GetUsernamePersisterWithConnection(tx *pop.Connection) UsernamePersister + GetSessionPersister() SessionPersister + GetSessionPersisterWithConnection(tx *pop.Connection) SessionPersister } type Migrator interface { @@ -246,3 +248,11 @@ func (p *persister) GetWebhookPersister(tx *pop.Connection) WebhookPersister { return NewWebhookPersister(p.DB) } + +func (p *persister) GetSessionPersister() SessionPersister { + return NewSessionPersister(p.DB) +} + +func (p *persister) GetSessionPersisterWithConnection(tx *pop.Connection) SessionPersister { + return NewSessionPersister(tx) +} diff --git a/backend/persistence/session_persister.go b/backend/persistence/session_persister.go new file mode 100644 index 000000000..7e631b924 --- /dev/null +++ b/backend/persistence/session_persister.go @@ -0,0 +1,105 @@ +package persistence + +import ( + "database/sql" + "errors" + "fmt" + "github.com/gobuffalo/pop/v6" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/persistence/models" + "time" +) + +type SessionPersister interface { + Create(session models.Session) error + Get(id uuid.UUID) (*models.Session, error) + Update(session models.Session) error + List(userID uuid.UUID) ([]models.Session, error) + ListActive(userID uuid.UUID) ([]models.Session, error) + Delete(session models.Session) error +} + +type sessionPersister struct { + db *pop.Connection +} + +func NewSessionPersister(db *pop.Connection) SessionPersister { + return &sessionPersister{db: db} +} + +func (p *sessionPersister) Create(session models.Session) error { + vErr, err := p.db.ValidateAndCreate(&session) + if err != nil { + return fmt.Errorf("failed to store session: %w", err) + } + if vErr != nil && vErr.HasAny() { + return fmt.Errorf("session object validation failed: %w", vErr) + } + + return nil +} + +func (p *sessionPersister) Get(id uuid.UUID) (*models.Session, error) { + session := models.Session{} + err := p.db.Eager().Find(&session, id) + if err != nil && errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get session: %w", err) + } + + return &session, nil +} + +func (p *sessionPersister) Update(session models.Session) error { + vErr, err := p.db.ValidateAndUpdate(&session) + if err != nil { + return err + } + + if vErr != nil && vErr.HasAny() { + return fmt.Errorf("session object validation failed: %w", vErr) + } + + return nil +} + +func (p *sessionPersister) List(userID uuid.UUID) ([]models.Session, error) { + sessions := []models.Session{} + + err := p.db.Q().Where("user_id = ?", userID).All(&sessions) + + if err != nil && errors.Is(err, sql.ErrNoRows) { + return sessions, nil + } + if err != nil { + return nil, fmt.Errorf("failed to fetch sessions: %w", err) + } + + return sessions, nil +} + +func (p *sessionPersister) ListActive(userID uuid.UUID) ([]models.Session, error) { + sessions := []models.Session{} + + err := p.db.Q().Where("user_id = ?", userID).Where("expires_at > ?", time.Now()).Order("created_at desc").All(&sessions) + + if err != nil && errors.Is(err, sql.ErrNoRows) { + return sessions, nil + } + if err != nil { + return nil, fmt.Errorf("failed to fetch sessions: %w", err) + } + + return sessions, nil +} + +func (p *sessionPersister) Delete(session models.Session) error { + err := p.db.Eager().Destroy(&session) + if err != nil { + return fmt.Errorf("failed to delete session: %w", err) + } + + return nil +} diff --git a/backend/session/session.go b/backend/session/session.go index e56bfaaa0..45dfc5a73 100644 --- a/backend/session/session.go +++ b/backend/session/session.go @@ -13,7 +13,7 @@ import ( ) type Manager interface { - GenerateJWT(userId uuid.UUID, userDto *dto.EmailJwt) (string, error) + GenerateJWT(userId uuid.UUID, userDto *dto.EmailJwt) (string, jwt.Token, error) Verify(string) (jwt.Token, error) GenerateCookie(token string) (*http.Cookie, error) DeleteCookie() (*http.Cookie, error) @@ -90,7 +90,11 @@ func NewManager(jwkManager hankoJwk.Manager, config config.Config) (Manager, err } // GenerateJWT creates a new session JWT for the given user -func (m *manager) GenerateJWT(userId uuid.UUID, email *dto.EmailJwt) (string, error) { +func (m *manager) GenerateJWT(userId uuid.UUID, email *dto.EmailJwt) (string, jwt.Token, error) { + sessionID, err := uuid.NewV4() + if err != nil { + return "", nil, err + } issuedAt := time.Now() expiration := issuedAt.Add(m.sessionLength) @@ -99,6 +103,7 @@ func (m *manager) GenerateJWT(userId uuid.UUID, email *dto.EmailJwt) (string, er _ = token.Set(jwt.IssuedAtKey, issuedAt) _ = token.Set(jwt.ExpirationKey, expiration) _ = token.Set(jwt.AudienceKey, m.audience) + _ = token.Set("session_id", sessionID.String()) if email != nil { _ = token.Set("email", &email) @@ -110,10 +115,10 @@ func (m *manager) GenerateJWT(userId uuid.UUID, email *dto.EmailJwt) (string, er signed, err := m.jwtGenerator.Sign(token) if err != nil { - return "", err + return "", nil, err } - return string(signed), nil + return string(signed), token, nil } // Verify verifies the given JWT and returns a parsed one if verification was successful diff --git a/backend/session/session_test.go b/backend/session/session_test.go index 5acb0484e..084f9c713 100644 --- a/backend/session/session_test.go +++ b/backend/session/session_test.go @@ -31,7 +31,7 @@ func TestGenerator_Generate(t *testing.T) { userId, err := uuid.NewV4() assert.NoError(t, err) - session, err := sessionGenerator.GenerateJWT(userId, nil) + session, _, err := sessionGenerator.GenerateJWT(userId, nil) assert.NoError(t, err) require.NotEmpty(t, session) } @@ -57,7 +57,7 @@ func TestGenerator_Verify(t *testing.T) { IsVerified: false, } - session, err := sessionGenerator.GenerateJWT(userId, emailDto) + session, _, err := sessionGenerator.GenerateJWT(userId, emailDto) assert.NoError(t, err) require.NotEmpty(t, session) @@ -103,7 +103,7 @@ func TestManager_GenerateJWT_IssAndAud(t *testing.T) { require.NotEmpty(t, sessionGenerator) userId, _ := uuid.NewV4() - j, err := sessionGenerator.GenerateJWT(userId, nil) + j, _, err := sessionGenerator.GenerateJWT(userId, nil) assert.NoError(t, err) token, err := jwt.ParseString(j, jwt.WithVerify(false)) @@ -134,7 +134,7 @@ func TestManager_GenerateJWT_AdditionalAudiences(t *testing.T) { require.NotEmpty(t, sessionGenerator) userId, _ := uuid.NewV4() - j, err := sessionGenerator.GenerateJWT(userId, nil) + j, _, err := sessionGenerator.GenerateJWT(userId, nil) assert.NoError(t, err) token, err := jwt.ParseString(j, jwt.WithVerify(false)) diff --git a/backend/test/SessionPersister.go b/backend/test/SessionPersister.go new file mode 100644 index 000000000..d6076cb5e --- /dev/null +++ b/backend/test/SessionPersister.go @@ -0,0 +1,45 @@ +package test + +import ( + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +func NewSessionPersister(init []models.Session) persistence.SessionPersister { + return &sessionPersister{sessions: init} +} + +type sessionPersister struct { + sessions []models.Session +} + +func (s sessionPersister) Create(session models.Session) error { + //TODO implement me + panic("implement me") +} + +func (s sessionPersister) Get(id uuid.UUID) (*models.Session, error) { + //TODO implement me + panic("implement me") +} + +func (s sessionPersister) Update(session models.Session) error { + //TODO implement me + panic("implement me") +} + +func (s sessionPersister) List(userID uuid.UUID) ([]models.Session, error) { + //TODO implement me + panic("implement me") +} + +func (s sessionPersister) ListActive(userID uuid.UUID) ([]models.Session, error) { + //TODO implement me + panic("implement me") +} + +func (s sessionPersister) Delete(session models.Session) error { + //TODO implement me + panic("implement me") +} diff --git a/backend/test/persister.go b/backend/test/persister.go index 43aede38d..26dec25d5 100644 --- a/backend/test/persister.go +++ b/backend/test/persister.go @@ -23,6 +23,7 @@ func NewPersister( samlCertificates []*models.SamlCertificate, webhooks models.Webhooks, webhookEvents models.WebhookEvents, + sessions []models.Session, ) persistence.Persister { return &persister{ userPersister: NewUserPersister(user), @@ -40,6 +41,7 @@ func NewPersister( samlStatePersister: NewSamlStatePersister(samlStates), samlCertificatePersister: NewSamlCertificatePersister(samlCertificates), webhookPersister: NewWebhookPersister(webhooks, webhookEvents), + sessionPersister: NewSessionPersister(sessions), } } @@ -59,6 +61,7 @@ type persister struct { samlStatePersister persistence.SamlStatePersister samlCertificatePersister persistence.SamlCertificatePersister webhookPersister persistence.WebhookPersister + sessionPersister persistence.SessionPersister } func (p *persister) GetPasswordCredentialPersister() persistence.PasswordCredentialPersister { @@ -185,3 +188,11 @@ func (p *persister) GetSamlCertificatePersisterWithConnection(tx *pop.Connection func (p *persister) GetWebhookPersister(_ *pop.Connection) persistence.WebhookPersister { return p.webhookPersister } + +func (p *persister) GetSessionPersister() persistence.SessionPersister { + return p.sessionPersister +} + +func (p *persister) GetSessionPersisterWithConnection(_ *pop.Connection) persistence.SessionPersister { + return p.sessionPersister +} diff --git a/frontend/elements/src/components/accordion/ListSessionsAccordion.tsx b/frontend/elements/src/components/accordion/ListSessionsAccordion.tsx new file mode 100644 index 000000000..c6c180ae7 --- /dev/null +++ b/frontend/elements/src/components/accordion/ListSessionsAccordion.tsx @@ -0,0 +1,97 @@ +import { Session } from "@teamhanko/hanko-frontend-sdk/dist/lib/flow-api/types/payload"; +import { HankoError } from "@teamhanko/hanko-frontend-sdk"; +import { StateUpdater, useContext } from "preact/compat"; +import Accordion from "./Accordion"; +import { Fragment } from "preact"; +import Paragraph from "../paragraph/Paragraph"; +import Headline2 from "../headline/Headline2"; +import { TranslateContext } from "@denysvuika/preact-translate"; +import Link from "../link/Link"; +import styles from "./styles.sass"; + +interface Props { + sessions: Session[]; + setError: (e: HankoError) => void; + checkedItemID?: string; + setCheckedItemID: StateUpdater; + onSessionDelete: (event: Event, id: string) => Promise; + deletableSessionIDs?: string[]; +} + +const ListSessionsAccordion = ({ + sessions = [], + checkedItemID, + setCheckedItemID, + onSessionDelete, + deletableSessionIDs, +}: Props) => { + const { t } = useContext(TranslateContext); + + const labels = (session: Session) => { + const description = ( + + {session.current ? ( + + {" -"} {t("labels.currentSession")} + + ) : null} + + ); + return session.current ? ( + + {session.user_agent} + {description} + + ) : ( + + {session.user_agent} + {description} + + ); + }; + + const convertTime = (t: string) => new Date(t).toLocaleString(); + + const contents = (session: Session) => ( + + + {t("headlines.ipAddress")} + {session.ip_address} + + + {t("headlines.lastUsed")} + {convertTime(session.last_used)} + + + {t("headlines.createdAt")} + {convertTime(session.created_at)} + + {deletableSessionIDs?.includes(session.id) ? ( + + {t("headlines.revokeSession")} + onSessionDelete(event, session.id)} + loadingSpinnerPosition={"right"} + > + {t("labels.revoke")} + + + ) : null} + + ); + + return ( + + ); +}; + +export default ListSessionsAccordion; diff --git a/frontend/elements/src/contexts/AppProvider.tsx b/frontend/elements/src/contexts/AppProvider.tsx index b3fb04b44..2feb9846f 100644 --- a/frontend/elements/src/contexts/AppProvider.tsx +++ b/frontend/elements/src/contexts/AppProvider.tsx @@ -98,7 +98,8 @@ export type UIAction = | "back" | "account_delete" | "retry" - | "thirdparty-submit"; + | "thirdparty-submit" + | "session-delete"; interface UIState { username?: string; diff --git a/frontend/elements/src/i18n/bn.ts b/frontend/elements/src/i18n/bn.ts index f5fbd80d5..85931a706 100644 --- a/frontend/elements/src/i18n/bn.ts +++ b/frontend/elements/src/i18n/bn.ts @@ -32,6 +32,10 @@ export const bn: Translation = { signUp: "নিবন্ধন করুন", selectLoginMethod: "লগইন পদ্ধতি নির্বাচন করুন", setupLoginMethod: "লগইন পদ্ধতি সেটআপ করুন", + lastUsed: "সর্বশেষ দেখা হয়েছে", + ipAddress: "আইপি ঠিকানা", + revokeSession: "সেশন বাতিল করুন", + profileSessions: "সেশন", }, texts: { enterPasscode: 'যে পাসকোডটি পাঠানো হয়েছিল "{emailAddress}" এ তা লিখুন.', @@ -104,6 +108,8 @@ export const bn: Translation = { setUsername: "ব্যবহারকারীর নাম সেট করুন", changePassword: "পাসওয়ার্ড পরিবর্তন করুন", setPassword: "পাসওয়ার্ড সেট করুন", + revoke: "বাতিল করুন", + currentSession: "বর্তমান সেশন", }, errors: { somethingWentWrong: diff --git a/frontend/elements/src/i18n/de.ts b/frontend/elements/src/i18n/de.ts index f2e87b2ce..3c3c107a9 100644 --- a/frontend/elements/src/i18n/de.ts +++ b/frontend/elements/src/i18n/de.ts @@ -32,6 +32,10 @@ export const de: Translation = { signUp: "Registrieren", selectLoginMethod: "Wähle die Anmelde-Methode", setupLoginMethod: "Anmelde-Methode einrichten", + lastUsed: "Zuletzt gesehen", + ipAddress: "IP Adresse", + revokeSession: "Sitzung beenden", + profileSessions: "Sitzungen", }, texts: { enterPasscode: @@ -107,6 +111,8 @@ export const de: Translation = { setUsername: "Benutzernamen setzen", changePassword: "Passwort ändern", setPassword: "Passwort setzen", + revoke: "Beenden", + currentSession: "Aktuelle Sitzung", }, errors: { somethingWentWrong: diff --git a/frontend/elements/src/i18n/en.ts b/frontend/elements/src/i18n/en.ts index afbde12df..28943c677 100644 --- a/frontend/elements/src/i18n/en.ts +++ b/frontend/elements/src/i18n/en.ts @@ -32,6 +32,10 @@ export const en: Translation = { signUp: "Create account", selectLoginMethod: "Select login method", setupLoginMethod: "Set up login method", + lastUsed: "Last seen", + ipAddress: "IP address", + revokeSession: "Revoke session", + profileSessions: "Sessions" }, texts: { enterPasscode: 'Enter the passcode that was sent to "{emailAddress}".', @@ -104,6 +108,8 @@ export const en: Translation = { setUsername: "Set username", changePassword: "Change password", setPassword: "Set password", + revoke: "Revoke", + currentSession: "Current session", }, errors: { somethingWentWrong: diff --git a/frontend/elements/src/i18n/fr.ts b/frontend/elements/src/i18n/fr.ts index a90a17fcd..fb61c5ea4 100644 --- a/frontend/elements/src/i18n/fr.ts +++ b/frontend/elements/src/i18n/fr.ts @@ -32,6 +32,10 @@ export const fr: Translation = { signUp: "S'inscrire", selectLoginMethod: "Sélectionner la méthode de connexion", setupLoginMethod: "Configurer la méthode de connexion", + lastUsed: "Dernière vue", + ipAddress: "Adresse IP", + revokeSession: "Révoquer la session", + profileSessions: "Sessions" }, texts: { enterPasscode: @@ -108,6 +112,8 @@ export const fr: Translation = { setUsername: "Définir le nom d'utilisateur", changePassword: "Changer le mot de passe", setPassword: "Définir le mot de passe", + revoke: "Révoquer", + currentSession: "Session en cours", }, errors: { somethingWentWrong: diff --git a/frontend/elements/src/i18n/it.ts b/frontend/elements/src/i18n/it.ts index 0b36afd00..ef78b6bab 100644 --- a/frontend/elements/src/i18n/it.ts +++ b/frontend/elements/src/i18n/it.ts @@ -32,6 +32,10 @@ export const it: Translation = { signUp: "Registrati", selectLoginMethod: "Seleziona il metodo di accesso", setupLoginMethod: "Imposta il metodo di accesso", + lastUsed: "Ultima visualizzazione", + ipAddress: "Indirizzo IP", + revokeSession: "Revoca sessione", + profileSessions: "Sessioni", }, texts: { enterPasscode: 'Inserisci il codice di accesso inviato a "{emailAddress}".', @@ -105,6 +109,8 @@ export const it: Translation = { setUsername: "Imposta nome utente", changePassword: "Cambia password", setPassword: "Imposta password", + revoke: "Revoca", + currentSession: "Sessione corrente", }, errors: { somethingWentWrong: "Si è verificato un errore tecnico. Riprova più tardi.", diff --git a/frontend/elements/src/i18n/pt-BR.ts b/frontend/elements/src/i18n/pt-BR.ts index 1dbce63bf..36d743fee 100644 --- a/frontend/elements/src/i18n/pt-BR.ts +++ b/frontend/elements/src/i18n/pt-BR.ts @@ -32,6 +32,10 @@ export const ptBR: Translation = { signUp: "Registrar", selectLoginMethod: "Selecionar método de login", setupLoginMethod: "Configurar método de login", + lastUsed: "Última vez visto", + ipAddress: "Endereço IP", + revokeSession: "Revogar sessão", + profileSessions: "Sessões", }, texts: { enterPasscode: @@ -107,6 +111,8 @@ export const ptBR: Translation = { setUsername: "Definir nome de usuário", changePassword: "Alterar senha", setPassword: "Definir senha", + revoke: "Revogar", + currentSession: "Sessão atual", }, errors: { somethingWentWrong: diff --git a/frontend/elements/src/i18n/translations.ts b/frontend/elements/src/i18n/translations.ts index c384bd11c..8df4c0273 100644 --- a/frontend/elements/src/i18n/translations.ts +++ b/frontend/elements/src/i18n/translations.ts @@ -36,6 +36,10 @@ export interface Translation { signUp: string; selectLoginMethod: string; setupLoginMethod: string; + lastUsed: string; + ipAddress: string; + revokeSession: string; + profileSessions: string }; texts: { enterPasscode: string; @@ -100,6 +104,8 @@ export interface Translation { setPassword: string; changeUsername: string; setUsername: string; + revoke: string; + currentSession: string; }; errors: { somethingWentWrong: string; diff --git a/frontend/elements/src/i18n/zh.ts b/frontend/elements/src/i18n/zh.ts index efc53d603..65291ac80 100644 --- a/frontend/elements/src/i18n/zh.ts +++ b/frontend/elements/src/i18n/zh.ts @@ -32,6 +32,10 @@ export const zh: Translation = { signUp: "注册", selectLoginMethod: "选择登录方法", setupLoginMethod: "设置登录方法", + lastUsed: "最后一次查看", + ipAddress: "IP 地址", + revokeSession: "撤销会话", + profileSessions: "会话", }, texts: { enterPasscode: "输入发送到“{emailAddress}”的验证码。", @@ -100,6 +104,8 @@ export const zh: Translation = { setUsername: "设置用户名", changePassword: "更改密码", setPassword: "设置密码", + revoke: "撤销", + currentSession: "当前会话", }, errors: { somethingWentWrong: "发生技术错误。请稍后再试。", diff --git a/frontend/elements/src/pages/ProfilePage.tsx b/frontend/elements/src/pages/ProfilePage.tsx index f19b1192b..8bbcd5f1b 100644 --- a/frontend/elements/src/pages/ProfilePage.tsx +++ b/frontend/elements/src/pages/ProfilePage.tsx @@ -21,6 +21,7 @@ import Spacer from "../components/spacer/Spacer"; import ChangeUsernameDropdown from "../components/accordion/ChangeUsernameDropdown"; import DeleteAccountPage from "./DeleteAccountPage"; import ErrorBox from "../components/error/ErrorBox"; +import ListSessionsAccordion from "../components/accordion/ListSessionsAccordion"; interface Props { state: State<"profile_init">; @@ -154,6 +155,13 @@ const ProfilePage = (props: Props) => { flowState.actions.webauthn_credential_create(null).run, ); + const onSessionDelete = async (event: Event, id: string) => + onAction( + event, + "session-delete", + flowState.actions.session_delete({ session_id: id }).run, + ); + const onAccountDelete = async (event: Event) => onAction( event, @@ -312,6 +320,23 @@ const ProfilePage = (props: Props) => { ) : null} + {flowState.payload.sessions ? ( + + {t("headlines.profileSessions")} + + e.value)} + /> + + + ) : null} {flowState.actions.account_delete?.(null) ? ( diff --git a/frontend/frontend-sdk/src/Hanko.ts b/frontend/frontend-sdk/src/Hanko.ts index 53f4397e6..5b04a0b24 100644 --- a/frontend/frontend-sdk/src/Hanko.ts +++ b/frontend/frontend-sdk/src/Hanko.ts @@ -8,6 +8,7 @@ import { Relay } from "./lib/events/Relay"; import { Session } from "./lib/Session"; import { CookieSameSite } from "./lib/Cookie"; import { Flow } from "./lib/flow-api/Flow"; +import { SessionClient } from "./lib/client/SessionClient"; /** * The options for the Hanko class @@ -41,6 +42,7 @@ class Hanko extends Listener { thirdParty: ThirdPartyClient; enterprise: EnterpriseClient; token: TokenClient; + sessionClient: SessionClient; relay: Relay; session: Session; flow: Flow; @@ -95,6 +97,11 @@ class Hanko extends Listener { * @type {TokenClient} */ this.token = new TokenClient(api, opts); + /** + * @public + * @type {SessionClient} + */ + this.sessionClient = new SessionClient(api, opts); /** * @public * @type {Relay} diff --git a/frontend/frontend-sdk/src/index.ts b/frontend/frontend-sdk/src/index.ts index 0d5b69900..3bc0522a5 100644 --- a/frontend/frontend-sdk/src/index.ts +++ b/frontend/frontend-sdk/src/index.ts @@ -11,6 +11,7 @@ import { EmailClient } from "./lib/client/EmailClient"; import { ThirdPartyClient } from "./lib/client/ThirdPartyClient"; import { TokenClient } from "./lib/client/TokenClient"; import { EnterpriseClient } from "./lib/client/EnterpriseClient"; +import { SessionClient } from "./lib/client/SessionClient"; export { UserClient, @@ -18,6 +19,7 @@ export { ThirdPartyClient, TokenClient, EnterpriseClient, + SessionClient, }; // Utils @@ -48,6 +50,7 @@ import { WebauthnCredential, WebauthnCredentials, Identity, + SessionCheckResponse, } from "./lib/Dto"; export type { @@ -70,6 +73,7 @@ export type { WebauthnCredential, WebauthnCredentials, Identity, + SessionCheckResponse, }; // Errors diff --git a/frontend/frontend-sdk/src/lib/Dto.ts b/frontend/frontend-sdk/src/lib/Dto.ts index e7aa73c26..73369fdc6 100644 --- a/frontend/frontend-sdk/src/lib/Dto.ts +++ b/frontend/frontend-sdk/src/lib/Dto.ts @@ -243,3 +243,8 @@ export interface Identity { id: string; provider: string; } + +export interface SessionCheckResponse { + is_valid: boolean; + expiration_time?: string; +} diff --git a/frontend/frontend-sdk/src/lib/client/SessionClient.ts b/frontend/frontend-sdk/src/lib/client/SessionClient.ts new file mode 100644 index 000000000..931af8bfa --- /dev/null +++ b/frontend/frontend-sdk/src/lib/client/SessionClient.ts @@ -0,0 +1,30 @@ +import { Client } from "./Client"; +import { SessionCheckResponse } from "../Dto"; +import { TechnicalError } from "../Errors"; + +/** + * A class that handles communication with the Hanko API for the purposes + * of sessions. + * + * @constructor + * @category SDK + * @subcategory Clients + * @extends {Client} + */ +export class SessionClient extends Client { + /** + * Checks if the current session is still valid. + * + * @return {Promise} + * @throws {TechnicalError} + */ + async isValid(): Promise { + const response = await this.client.get("/sessions/validate"); + + if (!response.ok) { + throw new TechnicalError(); + } + + return response.json(); + } +} diff --git a/frontend/frontend-sdk/src/lib/flow-api/types/action.ts b/frontend/frontend-sdk/src/lib/flow-api/types/action.ts index 84b39bc5f..ae15d095c 100644 --- a/frontend/frontend-sdk/src/lib/flow-api/types/action.ts +++ b/frontend/frontend-sdk/src/lib/flow-api/types/action.ts @@ -16,7 +16,7 @@ import { UsernameSetInputs, VerifyPasscodeInputs, WebauthnVerifyAssertionResponseInputs, - WebauthnVerifyAttestationResponseInputs, + WebauthnVerifyAttestationResponseInputs, SessionDeleteInputs, } from "./input"; interface Action { @@ -53,6 +53,7 @@ interface ProfileInitActions { readonly webauthn_credential_rename?: Action; readonly webauthn_credential_delete?: Action; readonly webauthn_verify_attestation_response?: Action; + readonly session_delete?: Action; } interface LoginMethodChooserActions { diff --git a/frontend/frontend-sdk/src/lib/flow-api/types/input.ts b/frontend/frontend-sdk/src/lib/flow-api/types/input.ts index 0a5743a47..4d94cd805 100644 --- a/frontend/frontend-sdk/src/lib/flow-api/types/input.ts +++ b/frontend/frontend-sdk/src/lib/flow-api/types/input.ts @@ -98,3 +98,7 @@ export interface ThirdpartyOauthInputs { readonly provider: Input; readonly redirect_to: Input; } + +export interface SessionDeleteInputs { + readonly session_id: Input; +} diff --git a/frontend/frontend-sdk/src/lib/flow-api/types/payload.ts b/frontend/frontend-sdk/src/lib/flow-api/types/payload.ts index dc79efef4..991e94d45 100644 --- a/frontend/frontend-sdk/src/lib/flow-api/types/payload.ts +++ b/frontend/frontend-sdk/src/lib/flow-api/types/payload.ts @@ -62,8 +62,19 @@ export interface User { readonly updated_at: string; } +export interface Session { + readonly id: string; + readonly user_agent: string; + readonly user_agent_raw: string; + readonly ip_address: string; + readonly created_at: string; + readonly last_used: string; + readonly current: boolean; +} + export interface ProfilePayload { readonly user: User; + readonly sessions?: Session[]; } export interface SuccessPayload {