Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: always persist sessions server-side, config adjustments #1997

Merged
merged 3 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 14 additions & 18 deletions backend/cmd/jwt/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions backend/config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 6 additions & 5 deletions backend/config/config_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
23 changes: 11 additions & 12 deletions backend/config/config_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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 {
Expand Down Expand Up @@ -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"`
}
37 changes: 24 additions & 13 deletions backend/dto/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,38 @@ 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"`
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,
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 {
Expand Down
2 changes: 1 addition & 1 deletion backend/flow_api/flow/profile/action_session_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion backend/flow_api/flow/profile/hook_get_sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
55 changes: 30 additions & 25 deletions backend/flow_api/flow/shared/hook_issue_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
54 changes: 26 additions & 28 deletions backend/flow_api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 7 additions & 10 deletions backend/handler/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,23 @@ 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"
"strings"
)

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,
}
}

Expand Down
Loading
Loading