diff --git a/backend/cmd/jwt/create.go b/backend/cmd/jwt/create.go index b170d5fb4..2c4edd06c 100644 --- a/backend/cmd/jwt/create.go +++ b/backend/cmd/jwt/create.go @@ -73,26 +73,22 @@ func NewCreateCommand() *cobra.Command { return } - if cfg.Session.ServerSide.Enabled { - sessionID, _ := rawToken.Get("session_id") + 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(), - } + expirationTime := rawToken.Expiration() + sessionModel := models.Session{ + ID: uuid.FromStringOrNil(sessionID.(string)), + UserID: userId, + 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 - } + 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.yaml b/backend/config/config.yaml index 9c824a493..ae7851d6c 100644 --- a/backend/config/config.yaml +++ b/backend/config/config.yaml @@ -90,16 +90,18 @@ server: service: name: Hanko Authentication Service session: + allow_revocation: true + acquire_ip_address: true + acquire_user_agent: true lifespan: 12h enable_auth_token_header: false - server_side: - enabled: false - limit: 100 + limit: 5 cookie: http_only: true retention: persistent same_site: strict secure: true + show_on_profile: true third_party: providers: apple: diff --git a/backend/config/config_default.go b/backend/config/config_default.go index 32aac92ab..2b2c99618 100644 --- a/backend/config/config_default.go +++ b/backend/config/config_default.go @@ -69,17 +69,18 @@ func DefaultConfig() *Config { Host: "localhost", }, Session: Session{ - Lifespan: "12h", + AllowRevocation: true, + AcquireIPAddress: true, + AcquireUserAgent: true, + Lifespan: "12h", Cookie: Cookie{ HttpOnly: true, Retention: "persistent", SameSite: "strict", Secure: true, }, - ServerSide: ServerSide{ - Enabled: false, - Limit: 100, - }, + Limit: 5, + ShowOnProfile: true, }, AuditLog: AuditLog{ ConsoleOutput: AuditLogConsole{ diff --git a/backend/config/config_session.go b/backend/config/config_session.go index cb53c9e0f..e9fbf757c 100644 --- a/backend/config/config_session.go +++ b/backend/config/config_session.go @@ -7,10 +7,16 @@ import ( ) type Session struct { + // `allow_revocation` allows users to revoke their own sessions. + AllowRevocation bool `yaml:"allow_revocation" json:"allow_revocation,omitempty" koanf:"allow_revocation" jsonschema:"default=true"` // `audience` is a list of strings that identifies the recipients that the JWT is intended for. // The audiences are placed in the `aud` claim of the JWT. // If not set, it defaults to the value of the`webauthn.relying_party.id` configuration parameter. Audience []string `yaml:"audience" json:"audience,omitempty" koanf:"audience"` + // `acquire_ip_address` stores the user's IP address in the database. + AcquireIPAddress bool `yaml:"acquire_ip_address" json:"acquire_ip_address,omitempty" koanf:"acquire_ip_address" jsonschema:"default=true"` + // `acquire_user_agent` stores the user's user agent in the database. + AcquireUserAgent bool `yaml:"acquire_user_agent" json:"acquire_user_agent,omitempty" koanf:"acquire_user_agent" jsonschema:"default=true"` // `cookie` contains configuration for the session cookie issued on successful registration or login. Cookie Cookie `yaml:"cookie" json:"cookie,omitempty" koanf:"cookie"` // `enable_auth_token_header` determines whether a session token (JWT) is returned in an `X-Auth-Token` @@ -24,8 +30,11 @@ type Session struct { // 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"` + // `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=5"` + // `show_on_profile` indicates that the sessions should be listed on the profile. + ShowOnProfile bool `yaml:"show_on_profile" json:"show_on_profile,omitempty" koanf:"show_on_profile" jsonschema:"default=true"` } func (s *Session) Validate() error { @@ -75,13 +84,3 @@ 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 index 60ed33e14..52859d090 100644 --- a/backend/dto/session.go +++ b/backend/dto/session.go @@ -10,9 +10,9 @@ import ( type SessionData struct { ID uuid.UUID `json:"id"` - UserAgentRaw string `json:"user_agent_raw"` - UserAgent string `json:"user_agent"` - IpAddress string `json:"ip_address"` + UserAgentRaw *string `json:"user_agent_raw,omitempty"` + UserAgent *string `json:"user_agent,omitempty"` + IpAddress *string `json:"ip_address,omitempty"` Current bool `json:"current"` CreatedAt time.Time `json:"created_at"` ExpiresAt *time.Time `json:"expires_at,omitempty"` @@ -20,17 +20,28 @@ type SessionData struct { } 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, + sessionData := SessionData{ + ID: model.ID, + Current: current, + CreatedAt: model.CreatedAt, + ExpiresAt: model.ExpiresAt, + LastUsed: model.LastUsed, } + + if model.UserAgent.Valid { + raw := model.UserAgent.String + sessionData.UserAgentRaw = &raw + ua := useragent.Parse(model.UserAgent.String) + parsed := fmt.Sprintf("%s (%s)", ua.OS, ua.Name) + sessionData.UserAgent = &parsed + } + + if model.IpAddress.Valid { + s := model.IpAddress.String + sessionData.IpAddress = &s + } + + return sessionData } type ValidateSessionResponse struct { diff --git a/backend/flow_api/flow/profile/action_session_delete.go b/backend/flow_api/flow/profile/action_session_delete.go index 21488be8f..2e941834b 100644 --- a/backend/flow_api/flow/profile/action_session_delete.go +++ b/backend/flow_api/flow/profile/action_session_delete.go @@ -22,7 +22,7 @@ func (a SessionDelete) GetDescription() string { func (a SessionDelete) Initialize(c flowpilot.InitializationContext) { deps := a.GetDeps(c) - if !deps.Cfg.Session.ServerSide.Enabled { + if !deps.Cfg.Session.AllowRevocation { c.SuspendAction() return } diff --git a/backend/flow_api/flow/profile/hook_get_sessions.go b/backend/flow_api/flow/profile/hook_get_sessions.go index 4b91ce97b..ca68f697e 100644 --- a/backend/flow_api/flow/profile/hook_get_sessions.go +++ b/backend/flow_api/flow/profile/hook_get_sessions.go @@ -17,7 +17,7 @@ type GetSessions struct { func (h GetSessions) Execute(c flowpilot.HookExecutionContext) error { deps := h.GetDeps(c) - if !deps.Cfg.Session.ServerSide.Enabled { + if !deps.Cfg.Session.ShowOnProfile { return nil } diff --git a/backend/flow_api/flow/shared/hook_issue_session.go b/backend/flow_api/flow/shared/hook_issue_session.go index 5efdc6660..e5486cf66 100644 --- a/backend/flow_api/flow/shared/hook_issue_session.go +++ b/backend/flow_api/flow/shared/hook_issue_session.go @@ -3,6 +3,7 @@ package shared import ( "errors" "fmt" + "github.com/gobuffalo/nulls" "github.com/gofrs/uuid" auditlog "github.com/teamhanko/hanko/backend/audit_log" "github.com/teamhanko/hanko/backend/dto" @@ -49,35 +50,39 @@ func (h IssueSession) Execute(c flowpilot.HookExecutionContext) error { 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) - } + // remove all server side sessions that exceed the limit + if len(activeSessions) >= deps.Cfg.Session.Limit { + for i := deps.Cfg.Session.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(), - } + sessionID, _ := rawToken.Get("session_id") - err = deps.Persister.GetSessionPersisterWithConnection(deps.Tx).Create(sessionModel) - if err != nil { - return fmt.Errorf("failed to store session: %w", err) - } + expirationTime := rawToken.Expiration() + sessionModel := models.Session{ + ID: uuid.FromStringOrNil(sessionID.(string)), + UserID: userId, + CreatedAt: rawToken.IssuedAt(), + UpdatedAt: rawToken.IssuedAt(), + ExpiresAt: &expirationTime, + LastUsed: rawToken.IssuedAt(), + } + + if deps.Cfg.Session.AcquireIPAddress { + sessionModel.IpAddress = nulls.NewString(deps.HttpContext.RealIP()) + } + + if deps.Cfg.Session.AcquireUserAgent { + sessionModel.UserAgent = nulls.NewString(deps.HttpContext.Request().UserAgent()) + } + + err = deps.Persister.GetSessionPersisterWithConnection(deps.Tx).Create(sessionModel) + if err != nil { + return fmt.Errorf("failed to store session: %w", err) } rememberMeSelected := c.Stash().Get(StashPathRememberMeSelected).Bool() diff --git a/backend/flow_api/handler.go b/backend/flow_api/handler.go index f5087b61c..809ddf604 100644 --- a/backend/flow_api/handler.go +++ b/backend/flow_api/handler.go @@ -85,34 +85,32 @@ 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) - } + // 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) diff --git a/backend/handler/email.go b/backend/handler/email.go index 7a2999dad..5e47437ea 100644 --- a/backend/handler/email.go +++ b/backend/handler/email.go @@ -12,7 +12,6 @@ import ( "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" "github.com/teamhanko/hanko/backend/webhooks/events" "github.com/teamhanko/hanko/backend/webhooks/utils" "net/http" @@ -20,18 +19,16 @@ import ( ) type EmailHandler struct { - persister persistence.Persister - cfg *config.Config - sessionManager session.Manager - auditLogger auditlog.Logger + persister persistence.Persister + cfg *config.Config + auditLogger auditlog.Logger } -func NewEmailHandler(cfg *config.Config, persister persistence.Persister, sessionManager session.Manager, auditLogger auditlog.Logger) *EmailHandler { +func NewEmailHandler(cfg *config.Config, persister persistence.Persister, auditLogger auditlog.Logger) *EmailHandler { return &EmailHandler{ - persister: persister, - cfg: cfg, - sessionManager: sessionManager, - auditLogger: auditLogger, + persister: persister, + cfg: cfg, + auditLogger: auditLogger, } } diff --git a/backend/handler/email_test.go b/backend/handler/email_test.go index db6dd83fa..ccb89c96a 100644 --- a/backend/handler/email_test.go +++ b/backend/handler/email_test.go @@ -7,9 +7,7 @@ import ( "github.com/gofrs/uuid" "github.com/stretchr/testify/suite" "github.com/teamhanko/hanko/backend/config" - "github.com/teamhanko/hanko/backend/crypto/jwk" "github.com/teamhanko/hanko/backend/dto" - "github.com/teamhanko/hanko/backend/session" "github.com/teamhanko/hanko/backend/test" "net/http" "net/http/httptest" @@ -26,7 +24,7 @@ type emailSuite struct { } func (s *emailSuite) TestEmailHandler_New() { - emailHandler := NewEmailHandler(&config.Config{}, s.Storage, sessionManager{}, test.NewAuditLogger()) + emailHandler := NewEmailHandler(&config.Config{}, s.Storage, test.NewAuditLogger()) s.NotEmpty(emailHandler) } @@ -40,11 +38,6 @@ func (s *emailSuite) TestEmailHandler_List() { e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil) - jwkManager, err := jwk.NewDefaultManager(test.DefaultConfig.Secrets.Keys, s.Storage.GetJwkPersister()) - s.Require().NoError(err) - sessionManager, err := session.NewManager(jwkManager, test.DefaultConfig) - s.Require().NoError(err) - tests := []struct { name string userId uuid.UUID @@ -64,9 +57,7 @@ func (s *emailSuite) TestEmailHandler_List() { for _, currentTest := range tests { s.Run(currentTest.name, func() { - token, _, err := sessionManager.GenerateJWT(currentTest.userId, nil) - s.Require().NoError(err) - cookie, err := sessionManager.GenerateCookie(token) + cookie, err := generateSessionCookie(s.Storage, currentTest.userId) s.Require().NoError(err) req := httptest.NewRequest(http.MethodGet, "/emails", nil) @@ -172,14 +163,7 @@ func (s *emailSuite) TestEmailHandler_Create() { cfg.Email.RequireVerification = currentTest.requiresVerification cfg.Email.Limit = currentTest.maxNumberOfAddresses e := NewPublicRouter(&cfg, s.Storage, nil, nil) - jwkManager, err := jwk.NewDefaultManager(cfg.Secrets.Keys, s.Storage.GetJwkPersister()) - s.Require().NoError(err) - sessionManager, err := session.NewManager(jwkManager, cfg) - s.Require().NoError(err) - - token, _, err := sessionManager.GenerateJWT(currentTest.userId, nil) - s.Require().NoError(err) - cookie, err := sessionManager.GenerateCookie(token) + cookie, err := generateSessionCookie(s.Storage, currentTest.userId) s.Require().NoError(err) body := dto.EmailCreateRequest{ @@ -235,19 +219,11 @@ func (s *emailSuite) TestEmailHandler_SetPrimaryEmail() { e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil) - jwkManager, err := jwk.NewDefaultManager(test.DefaultConfig.Secrets.Keys, s.Storage.GetJwkPersister()) - s.Require().NoError(err) - sessionManager, err := session.NewManager(jwkManager, test.DefaultConfig) - s.Require().NoError(err) - oldPrimaryEmailId := uuid.FromStringOrNil("51b7c175-ceb6-45ba-aae6-0092221c1b84") newPrimaryEmailId := uuid.FromStringOrNil("8bb4c8a7-a3e6-48bb-b54f-20e3b485ab33") userId := uuid.FromStringOrNil("b5dd5267-b462-48be-b70d-bcd6f1bbe7a5") - - token, _, err := sessionManager.GenerateJWT(userId, nil) - s.NoError(err) - cookie, err := sessionManager.GenerateCookie(token) - s.NoError(err) + cookie, err := generateSessionCookie(s.Storage, userId) + s.Require().NoError(err) req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/emails/%s/set_primary", newPrimaryEmailId), nil) req.AddCookie(cookie) @@ -280,16 +256,9 @@ func (s *emailSuite) TestEmailHandler_Delete() { e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil) userId := uuid.FromStringOrNil("b5dd5267-b462-48be-b70d-bcd6f1bbe7a5") - jwkManager, err := jwk.NewDefaultManager(test.DefaultConfig.Secrets.Keys, s.Storage.GetJwkPersister()) - s.Require().NoError(err) - sessionManager, err := session.NewManager(jwkManager, test.DefaultConfig) + cookie, err := generateSessionCookie(s.Storage, userId) s.Require().NoError(err) - token, _, err := sessionManager.GenerateJWT(userId, nil) - s.NoError(err) - cookie, err := sessionManager.GenerateCookie(token) - s.NoError(err) - tests := []struct { name string emailId uuid.UUID diff --git a/backend/handler/helpers_test.go b/backend/handler/helpers_test.go new file mode 100644 index 000000000..498558734 --- /dev/null +++ b/backend/handler/helpers_test.go @@ -0,0 +1,40 @@ +package handler + +import ( + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/crypto/jwk" + "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/persistence/models" + "github.com/teamhanko/hanko/backend/session" + "github.com/teamhanko/hanko/backend/test" + "net/http" + "time" +) + +func getDefaultSessionManager(storage persistence.Persister) session.Manager { + jwkManager, _ := jwk.NewDefaultManager(test.DefaultConfig.Secrets.Keys, storage.GetJwkPersister()) + sessionManager, _ := session.NewManager(jwkManager, test.DefaultConfig) + return sessionManager +} + +func generateSessionCookie(storage persistence.Persister, userId uuid.UUID) (*http.Cookie, error) { + manager := getDefaultSessionManager(storage) + token, rawToken, err := manager.GenerateJWT(userId, nil) + if err != nil { + return nil, err + } + sessionID, _ := rawToken.Get("session_id") + _ = storage.GetSessionPersister().Create(models.Session{ + ID: uuid.FromStringOrNil(sessionID.(string)), + UserID: userId, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + ExpiresAt: nil, + LastUsed: time.Now(), + }) + cookie, err := manager.GenerateCookie(token) + if err != nil { + return nil, err + } + return cookie, nil +} diff --git a/backend/handler/password_test.go b/backend/handler/password_test.go index 0deeb6d9b..d3805ef78 100644 --- a/backend/handler/password_test.go +++ b/backend/handler/password_test.go @@ -6,8 +6,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "github.com/teamhanko/hanko/backend/config" - "github.com/teamhanko/hanko/backend/crypto/jwk" - "github.com/teamhanko/hanko/backend/session" "github.com/teamhanko/hanko/backend/test" "golang.org/x/crypto/bcrypt" "net/http" @@ -90,10 +88,7 @@ func (s *passwordSuite) TestPasswordHandler_Set_Create() { err := s.LoadFixtures("../test/fixtures/password") s.Require().NoError(err) - sessionManager := s.GetDefaultSessionManager() - token, _, err := sessionManager.GenerateJWT(currentTest.userId, nil) - s.Require().NoError(err) - cookie, err := sessionManager.GenerateCookie(token) + cookie, err := generateSessionCookie(s.Storage, currentTest.userId) s.Require().NoError(err) req := httptest.NewRequest(http.MethodPut, "/password", strings.NewReader(currentTest.body)) @@ -196,15 +191,6 @@ func (s *passwordSuite) TestPasswordHandler_Login() { } } -func (s *passwordSuite) GetDefaultSessionManager() session.Manager { - jwkManager, err := jwk.NewDefaultManager(test.DefaultConfig.Secrets.Keys, s.Storage.GetJwkPersister()) - s.Require().NoError(err) - sessionManager, err := session.NewManager(jwkManager, test.DefaultConfig) - s.Require().NoError(err) - - return sessionManager -} - // TestMaxPasswordLength bcrypt since version 0.5.0 only accepts passwords at least 72 bytes long. This test documents this behaviour. func TestMaxPasswordLength(t *testing.T) { tests := []struct { diff --git a/backend/handler/public_router.go b/backend/handler/public_router.go index 0f0958837..cb2120ba5 100644 --- a/backend/handler/public_router.go +++ b/backend/handler/public_router.go @@ -169,7 +169,7 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister, promet wellKnown.GET("/jwks.json", wellKnownHandler.GetPublicKeys) wellKnown.GET("/config", wellKnownHandler.GetConfig) - emailHandler := NewEmailHandler(cfg, persister, sessionManager, auditLogger) + emailHandler := NewEmailHandler(cfg, persister, auditLogger) if cfg.Passkey.Enabled { webauthnHandler, err := NewWebauthnHandler(cfg, persister, sessionManager, auditLogger, authenticatorMetadata) diff --git a/backend/handler/session.go b/backend/handler/session.go index c27e41d47..81390b75f 100644 --- a/backend/handler/session.go +++ b/backend/handler/session.go @@ -48,31 +48,29 @@ func (h *SessionHandler) ValidateSession(c echo.Context) error { 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) - } + // 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 @@ -110,32 +108,30 @@ func (h *SessionHandler) ValidateSessionFromBody(c echo.Context) error { 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}) - } + // 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) - } + 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}) - } + 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) - } + // update lastUsed field + sessionModel.LastUsed = time.Now().UTC() + err = h.persister.GetSessionPersister().Update(*sessionModel) + if err != nil { + return dto.ToHttpError(err) } expirationTime := token.Expiration() diff --git a/backend/handler/session_admin.go b/backend/handler/session_admin.go index c9c7ec55f..40bbc4b35 100644 --- a/backend/handler/session_admin.go +++ b/backend/handler/session_admin.go @@ -2,6 +2,7 @@ package handler import ( "fmt" + "github.com/gobuffalo/nulls" "github.com/gofrs/uuid" "github.com/labstack/echo/v4" "github.com/pkg/errors" @@ -65,40 +66,44 @@ func (h *SessionAdminHandler) Generate(ctx echo.Context) error { return fmt.Errorf("failed to generate JWT: %w", err) } - if h.cfg.Session.ServerSide.Enabled { - activeSessions, err := h.persister.GetSessionPersister().ListActive(userID) - if err != nil { - return fmt.Errorf("failed to list active sessions: %w", err) - } + activeSessions, err := h.persister.GetSessionPersister().ListActive(userID) + if err != nil { + return fmt.Errorf("failed to list active sessions: %w", err) + } - // remove all server side sessions that exceed the limit - if len(activeSessions) >= h.cfg.Session.ServerSide.Limit { - for i := h.cfg.Session.ServerSide.Limit - 1; i < len(activeSessions); i++ { - err = h.persister.GetSessionPersister().Delete(activeSessions[i]) - if err != nil { - return fmt.Errorf("failed to remove latest session: %w", err) - } + // remove all server side sessions that exceed the limit + if len(activeSessions) >= h.cfg.Session.Limit { + for i := h.cfg.Session.Limit - 1; i < len(activeSessions); i++ { + err = h.persister.GetSessionPersister().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: body.UserAgent, - IpAddress: body.IpAddress, - CreatedAt: rawToken.IssuedAt(), - UpdatedAt: rawToken.IssuedAt(), - ExpiresAt: &expirationTime, - LastUsed: rawToken.IssuedAt(), - } + sessionID, _ := rawToken.Get("session_id") - err = h.persister.GetSessionPersister().Create(sessionModel) - if err != nil { - return fmt.Errorf("failed to store session: %w", err) - } + expirationTime := rawToken.Expiration() + sessionModel := models.Session{ + ID: uuid.FromStringOrNil(sessionID.(string)), + UserID: userID, + CreatedAt: rawToken.IssuedAt(), + UpdatedAt: rawToken.IssuedAt(), + ExpiresAt: &expirationTime, + LastUsed: rawToken.IssuedAt(), + } + + if len(body.UserAgent) > 0 { + sessionModel.UserAgent = nulls.NewString(body.UserAgent) + } + + if len(body.IpAddress) > 0 { + sessionModel.IpAddress = nulls.NewString(body.IpAddress) + } + + err = h.persister.GetSessionPersister().Create(sessionModel) + if err != nil { + return fmt.Errorf("failed to store session: %w", err) } response := admin.CreateSessionTokenResponse{ diff --git a/backend/handler/user_test.go b/backend/handler/user_test.go index 10ab070ab..d7ecbcf74 100644 --- a/backend/handler/user_test.go +++ b/backend/handler/user_test.go @@ -7,10 +7,8 @@ import ( "github.com/gofrs/uuid" _ "github.com/lib/pq" "github.com/stretchr/testify/suite" - "github.com/teamhanko/hanko/backend/crypto/jwk" "github.com/teamhanko/hanko/backend/dto" "github.com/teamhanko/hanko/backend/persistence/models" - "github.com/teamhanko/hanko/backend/session" "github.com/teamhanko/hanko/backend/test" "golang.org/x/exp/slices" "net/http" @@ -249,24 +247,14 @@ func (s *userSuite) TestUserHandler_Get() { err := s.LoadFixtures("../test/fixtures/user") s.Require().NoError(err) - userId := "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5" + userId := uuid.FromStringOrNil("b5dd5267-b462-48be-b70d-bcd6f1bbe7a5") e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil) - jwkManager, err := jwk.NewDefaultManager(test.DefaultConfig.Secrets.Keys, s.Storage.GetJwkPersister()) - if err != nil { - panic(fmt.Errorf("failed to create jwk manager: %w", err)) - } - sessionManager, err := session.NewManager(jwkManager, test.DefaultConfig) - if err != nil { - panic(fmt.Errorf("failed to create session generator: %w", err)) - } - token, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) - s.Require().NoError(err) - cookie, err := sessionManager.GenerateCookie(token) + cookie, err := generateSessionCookie(s.Storage, userId) s.Require().NoError(err) - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/users/%s", userId), nil) + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/users/%s", userId.String()), nil) req.AddCookie(cookie) rec := httptest.NewRecorder() @@ -277,7 +265,7 @@ func (s *userSuite) TestUserHandler_Get() { user := models.User{} err := json.Unmarshal(rec.Body.Bytes(), &user) s.NoError(err) - s.Equal(userId, user.ID.String()) + s.Equal(userId.String(), user.ID.String()) s.Equal(len(user.WebauthnCredentials), 0) } } @@ -289,24 +277,14 @@ func (s *userSuite) TestUserHandler_GetUserWithWebAuthnCredential() { err := s.LoadFixtures("../test/fixtures/user_with_webauthn_credential") s.Require().NoError(err) - userId := "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5" + userId := uuid.FromStringOrNil("b5dd5267-b462-48be-b70d-bcd6f1bbe7a5") e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil) - jwkManager, err := jwk.NewDefaultManager(test.DefaultConfig.Secrets.Keys, s.Storage.GetJwkPersister()) - if err != nil { - panic(fmt.Errorf("failed to create jwk manager: %w", err)) - } - sessionManager, err := session.NewManager(jwkManager, test.DefaultConfig) - if err != nil { - panic(fmt.Errorf("failed to create session generator: %w", err)) - } - token, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) - s.Require().NoError(err) - cookie, err := sessionManager.GenerateCookie(token) + cookie, err := generateSessionCookie(s.Storage, userId) s.Require().NoError(err) - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/users/%s", userId), nil) + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/users/%s", userId.String()), nil) rec := httptest.NewRecorder() req.AddCookie(cookie) @@ -317,7 +295,7 @@ func (s *userSuite) TestUserHandler_GetUserWithWebAuthnCredential() { user := models.User{} err := json.Unmarshal(rec.Body.Bytes(), &user) s.Require().NoError(err) - s.Equal(userId, user.ID.String()) + s.Equal(userId.String(), user.ID.String()) s.Equal(len(user.WebauthnCredentials), 1) } } @@ -328,19 +306,12 @@ func (s *userSuite) TestUserHandler_Get_InvalidUserId() { } e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil) - userId := "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5" - - jwkManager, err := jwk.NewDefaultManager(test.DefaultConfig.Secrets.Keys, s.Storage.GetJwkPersister()) - if err != nil { - panic(fmt.Errorf("failed to create jwk manager: %w", err)) - } - sessionManager, err := session.NewManager(jwkManager, test.DefaultConfig) - if err != nil { - panic(fmt.Errorf("failed to create session generator: %w", err)) - } - token, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) + err := s.LoadFixtures("../test/fixtures/user") s.Require().NoError(err) - cookie, err := sessionManager.GenerateCookie(token) + + userId := uuid.FromStringOrNil("b5dd5267-b462-48be-b70d-bcd6f1bbe7a5") + + cookie, err := generateSessionCookie(s.Storage, userId) s.Require().NoError(err) req := httptest.NewRequest(http.MethodGet, "/users/invalidUserId", nil) @@ -463,21 +434,11 @@ func (s *userSuite) TestUserHandler_Me() { err := s.LoadFixtures("../test/fixtures/user_with_webauthn_credential") s.Require().NoError(err) - userId := "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5" + userId := uuid.FromStringOrNil("b5dd5267-b462-48be-b70d-bcd6f1bbe7a5") e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil) - jwkManager, err := jwk.NewDefaultManager(test.DefaultConfig.Secrets.Keys, s.Storage.GetJwkPersister()) - if err != nil { - panic(fmt.Errorf("failed to create jwk manager: %w", err)) - } - sessionManager, err := session.NewManager(jwkManager, test.DefaultConfig) - if err != nil { - panic(fmt.Errorf("failed to create session generator: %w", err)) - } - token, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) - s.Require().NoError(err) - cookie, err := sessionManager.GenerateCookie(token) + cookie, err := generateSessionCookie(s.Storage, userId) s.Require().NoError(err) req := httptest.NewRequest(http.MethodGet, "/me", nil) @@ -493,7 +454,7 @@ func (s *userSuite) TestUserHandler_Me() { }{} err = json.Unmarshal(rec.Body.Bytes(), &response) s.NoError(err) - s.Equal(userId, response.UserId) + s.Equal(userId.String(), response.UserId) } } @@ -501,20 +462,14 @@ func (s *userSuite) TestUserHandler_Logout() { if testing.Short() { s.T().Skip("skipping test in short mode.") } - userId, _ := uuid.NewV4() - e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil) - jwkManager, err := jwk.NewDefaultManager(test.DefaultConfig.Secrets.Keys, s.Storage.GetJwkPersister()) - if err != nil { - panic(fmt.Errorf("failed to create jwk manager: %w", err)) - } - sessionManager, err := session.NewManager(jwkManager, test.DefaultConfig) - if err != nil { - panic(fmt.Errorf("failed to create session generator: %w", err)) - } - token, _, err := sessionManager.GenerateJWT(userId, nil) + err := s.LoadFixtures("../test/fixtures/user") s.Require().NoError(err) - cookie, err := sessionManager.GenerateCookie(token) + + userId := uuid.FromStringOrNil("b5dd5267-b462-48be-b70d-bcd6f1bbe7a5") + e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil) + + cookie, err := generateSessionCookie(s.Storage, userId) s.Require().NoError(err) req := httptest.NewRequest(http.MethodPost, "/logout", nil) @@ -545,17 +500,7 @@ func (s *userSuite) TestUserHandler_Delete() { cfg.Account.AllowDeletion = true e := NewPublicRouter(&cfg, s.Storage, nil, nil) - jwkManager, err := jwk.NewDefaultManager(test.DefaultConfig.Secrets.Keys, s.Storage.GetJwkPersister()) - if err != nil { - panic(fmt.Errorf("failed to create jwk manager: %w", err)) - } - sessionManager, err := session.NewManager(jwkManager, test.DefaultConfig) - if err != nil { - panic(fmt.Errorf("failed to create session generator: %w", err)) - } - token, _, err := sessionManager.GenerateJWT(userId, nil) - s.Require().NoError(err) - cookie, err := sessionManager.GenerateCookie(token) + cookie, err := generateSessionCookie(s.Storage, userId) s.Require().NoError(err) req := httptest.NewRequest(http.MethodDelete, "/user", nil) diff --git a/backend/handler/webauthn_test.go b/backend/handler/webauthn_test.go index 8c457dbc0..62a1d060d 100644 --- a/backend/handler/webauthn_test.go +++ b/backend/handler/webauthn_test.go @@ -6,12 +6,8 @@ import ( "github.com/go-webauthn/webauthn/protocol" "github.com/gofrs/uuid" "github.com/labstack/echo/v4" - "github.com/lestrrat-go/jwx/v2/jwt" "github.com/stretchr/testify/suite" - "github.com/teamhanko/hanko/backend/crypto/jwk" - "github.com/teamhanko/hanko/backend/dto" "github.com/teamhanko/hanko/backend/persistence/models" - "github.com/teamhanko/hanko/backend/session" "github.com/teamhanko/hanko/backend/test" "net/http" "net/http/httptest" @@ -32,7 +28,8 @@ func (s *webauthnSuite) TestWebauthnHandler_NewHandler() { if testing.Short() { s.T().Skip("skipping test in short mode") } - handler, err := NewWebauthnHandler(&test.DefaultConfig, s.Storage, s.GetDefaultSessionManager(), test.NewAuditLogger(), nil) + manager := getDefaultSessionManager(s.Storage) + handler, err := NewWebauthnHandler(&test.DefaultConfig, s.Storage, manager, test.NewAuditLogger(), nil) s.NoError(err) s.NotEmpty(handler) } @@ -45,14 +42,11 @@ func (s *webauthnSuite) TestWebauthnHandler_BeginRegistration() { err := s.LoadFixtures("../test/fixtures/webauthn") s.Require().NoError(err) - userId := "ec4ef049-5b88-4321-a173-21b0eff06a04" + userId := uuid.FromStringOrNil("ec4ef049-5b88-4321-a173-21b0eff06a04") e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil) - sessionManager := s.GetDefaultSessionManager() - token, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) - s.Require().NoError(err) - cookie, err := sessionManager.GenerateCookie(token) + cookie, err := generateSessionCookie(s.Storage, userId) s.Require().NoError(err) req := httptest.NewRequest(http.MethodPost, "/webauthn/registration/initialize", nil) @@ -70,7 +64,7 @@ func (s *webauthnSuite) TestWebauthnHandler_BeginRegistration() { s.Require().NoError(err) s.NotEmpty(creationOptions.Response.Challenge) - s.Equal(uuid.FromStringOrNil(userId).Bytes(), uId) + s.Equal(uuid.FromStringOrNil(userId.String()).Bytes(), uId) s.Equal(test.DefaultConfig.Webauthn.RelyingParty.Id, creationOptions.Response.RelyingParty.ID) s.Equal(protocol.ResidentKeyRequirementRequired, creationOptions.Response.AuthenticatorSelection.ResidentKey) s.Equal(protocol.VerificationPreferred, creationOptions.Response.AuthenticatorSelection.UserVerification) @@ -86,14 +80,11 @@ func (s *webauthnSuite) TestWebauthnHandler_FinalizeRegistration() { err := s.LoadFixtures("../test/fixtures/webauthn_registration") s.Require().NoError(err) - userId := "ec4ef049-5b88-4321-a173-21b0eff06a04" + userId := uuid.FromStringOrNil("ec4ef049-5b88-4321-a173-21b0eff06a04") e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil) - sessionManager := s.GetDefaultSessionManager() - token, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) - s.Require().NoError(err) - cookie, err := sessionManager.GenerateCookie(token) + cookie, err := generateSessionCookie(s.Storage, userId) s.Require().NoError(err) body := `{ @@ -132,14 +123,11 @@ func (s *webauthnSuite) TestWebauthnHandler_FinalizeRegistration_SessionDataExpi err := s.LoadFixtures("../test/fixtures/webauthn_registration") s.Require().NoError(err) - userId := "ec4ef049-5b88-4321-a173-21b0eff06a04" + userId := uuid.FromStringOrNil("ec4ef049-5b88-4321-a173-21b0eff06a04") e := NewPublicRouter(&test.DefaultConfig, s.Storage, nil, nil) - sessionManager := s.GetDefaultSessionManager() - token, _, err := sessionManager.GenerateJWT(uuid.FromStringOrNil(userId), nil) - s.Require().NoError(err) - cookie, err := sessionManager.GenerateCookie(token) + cookie, err := generateSessionCookie(s.Storage, userId) s.Require().NoError(err) body := `{ @@ -318,49 +306,8 @@ func (s *webauthnSuite) TestWebauthnHandler_FinalizeAuthentication_TokenInHeader } } -func (s *webauthnSuite) GetDefaultSessionManager() session.Manager { - jwkManager, err := jwk.NewDefaultManager(test.DefaultConfig.Secrets.Keys, s.Storage.GetJwkPersister()) - s.Require().NoError(err) - sessionManager, err := session.NewManager(jwkManager, test.DefaultConfig) - s.Require().NoError(err) - - return sessionManager -} - var userId = "ec4ef049-5b88-4321-a173-21b0eff06a04" -type sessionManager struct { -} - -func (s sessionManager) GenerateJWT(_ uuid.UUID, _ *dto.EmailJwt, _ ...session.JWTOptions) (string, jwt.Token, error) { - return userId, nil, nil -} - -func (s sessionManager) GenerateCookie(token string) (*http.Cookie, error) { - return &http.Cookie{ - Name: "hanko", - Value: token, - Secure: true, - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }, nil -} - -func (s sessionManager) DeleteCookie() (*http.Cookie, error) { - return &http.Cookie{ - Name: "hanko", - Value: "", - Secure: true, - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - MaxAge: -1, - }, nil -} - -func (s sessionManager) Verify(_ string) (jwt.Token, error) { - return nil, nil -} - var uId, _ = uuid.FromString(userId) var emails = []models.Email{ diff --git a/backend/json_schema/hanko.config.json b/backend/json_schema/hanko.config.json index aae4b9b20..644996896 100644 --- a/backend/json_schema/hanko.config.json +++ b/backend/json_schema/hanko.config.json @@ -1169,22 +1169,6 @@ "additionalProperties": false, "type": "object" }, - "ServerSide": { - "properties": { - "enabled": { - "type": "boolean", - "description": "`enabled` determines whether server-side sessions are enabled.\n\nNOTE: When enabled the session endpoint must be used in order to check if a session is still valid.", - "default": false - }, - "limit": { - "type": "integer", - "description": "`limit` determines the maximum number of server-side sessions a user can have. When the limit is exceeded,\nolder sessions are invalidated.", - "default": 100 - } - }, - "additionalProperties": false, - "type": "object" - }, "Service": { "properties": { "name": { @@ -1197,6 +1181,11 @@ }, "Session": { "properties": { + "allow_revocation": { + "type": "boolean", + "description": "`allow_revocation` allows users to revoke their own sessions.", + "default": true + }, "audience": { "items": { "type": "string" @@ -1204,6 +1193,16 @@ "type": "array", "description": "`audience` is a list of strings that identifies the recipients that the JWT is intended for.\nThe audiences are placed in the `aud` claim of the JWT.\nIf not set, it defaults to the value of the`webauthn.relying_party.id` configuration parameter." }, + "acquire_ip_address": { + "type": "boolean", + "description": "`acquire_ip_address` stores the user's IP address in the database.", + "default": true + }, + "acquire_user_agent": { + "type": "boolean", + "description": "`acquire_user_agent` stores the user's user agent in the database.", + "default": true + }, "cookie": { "$ref": "#/$defs/Cookie", "description": "`cookie` contains configuration for the session cookie issued on successful registration or login." @@ -1222,16 +1221,19 @@ "description": "`lifespan` determines the maximum duration for which a session token (JWT) is valid. It must be a (possibly signed) sequence of decimal\nnumbers, each with optional fraction and a unit suffix, such as \"300ms\", \"-1.5h\" or \"2h45m\".\nValid time units are \"ns\", \"us\" (or \"µs\"), \"ms\", \"s\", \"m\", \"h\".", "default": "12h" }, - "server_side": { - "$ref": "#/$defs/ServerSide", - "description": "`server_side` contains configuration for server-side sessions." + "limit": { + "type": "integer", + "description": "`limit` determines the maximum number of server-side sessions a user can have. When the limit is exceeded,\nolder sessions are invalidated.", + "default": 5 + }, + "show_on_profile": { + "type": "boolean", + "description": "`show_on_profile` indicates that the sessions should be listed on the profile.", + "default": true } }, "additionalProperties": false, - "type": "object", - "required": [ - "server_side" - ] + "type": "object" }, "TOTP": { "properties": { diff --git a/backend/middleware/session.go b/backend/middleware/session.go index ab2b361f5..9ac4cfcc9 100644 --- a/backend/middleware/session.go +++ b/backend/middleware/session.go @@ -35,31 +35,29 @@ func parseToken(cfg config.Session, persister persistence.Persister, generator s return nil, err } - if cfg.ServerSide.Enabled { - // check that the session id is stored in the database - sessionId, ok := token.Get("session_id") - if !ok { - return nil, errors.New("no session id found in token") - } - sessionID, err := uuid.FromString(sessionId.(string)) - if err != nil { - return nil, errors.New("session id has wrong format") - } + // check that the session id is stored in the database + sessionId, ok := token.Get("session_id") + if !ok { + return nil, errors.New("no session id found in token") + } + sessionID, err := uuid.FromString(sessionId.(string)) + if err != nil { + return nil, errors.New("session id has wrong format") + } - sessionModel, err := persister.GetSessionPersister().Get(sessionID) - if err != nil { - return nil, fmt.Errorf("failed to get session from database: %w", err) - } - if sessionModel == nil { - return nil, fmt.Errorf("session id not found in database") - } + sessionModel, err := persister.GetSessionPersister().Get(sessionID) + if err != nil { + return nil, fmt.Errorf("failed to get session from database: %w", err) + } + if sessionModel == nil { + return nil, fmt.Errorf("session id not found in database") + } - // Update lastUsed field - sessionModel.LastUsed = time.Now().UTC() - err = persister.GetSessionPersister().Update(*sessionModel) - if err != nil { - return nil, err - } + // Update lastUsed field + sessionModel.LastUsed = time.Now().UTC() + err = persister.GetSessionPersister().Update(*sessionModel) + if err != nil { + return nil, err } return token, nil diff --git a/backend/persistence/models/session.go b/backend/persistence/models/session.go index d0d6f6597..81278f71d 100644 --- a/backend/persistence/models/session.go +++ b/backend/persistence/models/session.go @@ -1,6 +1,7 @@ package models import ( + "github.com/gobuffalo/nulls" "github.com/gobuffalo/pop/v6" "github.com/gobuffalo/validate/v3" "github.com/gobuffalo/validate/v3/validators" @@ -9,14 +10,14 @@ import ( ) type Session struct { - ID uuid.UUID `db:"id" json:"id"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - UserAgent string `db:"user_agent" json:"user_agent"` - IpAddress string `db:"ip_address" json:"ip_address"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - ExpiresAt *time.Time `db:"expires_at" json:"expires_at"` - LastUsed time.Time `db:"last_used" json:"last_used"` + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + UserAgent nulls.String `db:"user_agent" json:"user_agent"` + IpAddress nulls.String `db:"ip_address" json:"ip_address"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ExpiresAt *time.Time `db:"expires_at" json:"expires_at"` + LastUsed time.Time `db:"last_used" json:"last_used"` } func (session *Session) Validate(tx *pop.Connection) (*validate.Errors, error) { diff --git a/backend/session/session.go b/backend/session/session.go index 0431773bf..b8cc5e8d0 100644 --- a/backend/session/session.go +++ b/backend/session/session.go @@ -21,12 +21,11 @@ type Manager interface { // Manager is used to create and verify session JWTs type manager struct { - jwtGenerator hankoJwt.Generator - sessionLength time.Duration - cookieConfig cookieConfig - issuer string - audience []string - serverSideSessionsEnabled bool + jwtGenerator hankoJwt.Generator + sessionLength time.Duration + cookieConfig cookieConfig + issuer string + audience []string } type cookieConfig struct { @@ -86,8 +85,7 @@ func NewManager(jwkManager hankoJwk.Manager, config config.Config) (Manager, err SameSite: sameSite, Secure: config.Session.Cookie.Secure, }, - audience: audience, - serverSideSessionsEnabled: config.Session.ServerSide.Enabled, + audience: audience, }, nil } @@ -102,13 +100,11 @@ func (m *manager) GenerateJWT(userId uuid.UUID, email *dto.EmailJwt, opts ...JWT _ = token.Set(jwt.ExpirationKey, expiration) _ = token.Set(jwt.AudienceKey, m.audience) - if m.serverSideSessionsEnabled { - sessionID, err := uuid.NewV4() - if err != nil { - return "", nil, err - } - _ = token.Set("session_id", sessionID.String()) + sessionID, err := uuid.NewV4() + if err != nil { + return "", nil, err } + _ = token.Set("session_id", sessionID.String()) if email != nil { _ = token.Set("email", &email) diff --git a/backend/session/session_test.go b/backend/session/session_test.go index 29009958b..50e3c019e 100644 --- a/backend/session/session_test.go +++ b/backend/session/session_test.go @@ -153,26 +153,9 @@ func Test_GenerateJWT_SessionID(t *testing.T) { tokenShouldHaveSessionID bool }{ { - name: "token has no session id claim if server side sessions are disabled", + name: "token has a session id claim", config: config.Config{ - Session: config.Session{ - Lifespan: "5m", - ServerSide: config.ServerSide{ - Enabled: false, - }, - }, - }, - tokenShouldHaveSessionID: false, - }, - { - name: "token has a session id claim if server side sessions are disabled", - config: config.Config{ - Session: config.Session{ - Lifespan: "5m", - ServerSide: config.ServerSide{ - Enabled: true, - }, - }, + Session: config.Session{Lifespan: "5m"}, }, tokenShouldHaveSessionID: true, }, diff --git a/frontend/elements/src/components/accordion/ListSessionsAccordion.tsx b/frontend/elements/src/components/accordion/ListSessionsAccordion.tsx index c6c180ae7..b71ca0d47 100644 --- a/frontend/elements/src/components/accordion/ListSessionsAccordion.tsx +++ b/frontend/elements/src/components/accordion/ListSessionsAccordion.tsx @@ -28,23 +28,19 @@ const ListSessionsAccordion = ({ const { t } = useContext(TranslateContext); const labels = (session: Session) => { - const description = ( + const headline = ( + {session.user_agent ? session.user_agent : session.id} + ); + const description = session.current ? ( - {session.current ? ( - - {" -"} {t("labels.currentSession")} - - ) : null} + + {" -"} {t("labels.currentSession")} + - ); - return session.current ? ( - - {session.user_agent} - {description} - - ) : ( + ) : null; + return ( - {session.user_agent} + {headline} {description} ); @@ -54,7 +50,7 @@ const ListSessionsAccordion = ({ const contents = (session: Session) => ( - + 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 412c08031..9e9eaaf82 100644 --- a/frontend/frontend-sdk/src/lib/flow-api/types/payload.ts +++ b/frontend/frontend-sdk/src/lib/flow-api/types/payload.ts @@ -77,9 +77,9 @@ export interface User { export interface Session { readonly id: string; - readonly user_agent: string; - readonly user_agent_raw: string; - readonly ip_address: 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;