// Package session manages HTTP session storage and authentication // state. package session import ( "context" "encoding/base64" "errors" "fmt" "log/slog" "maps" "net/http" "time" "github.com/gorilla/sessions" "go.uber.org/fx" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/logger" ) const ( // SessionName is the name of the session cookie. SessionName = "webhooker_session" // UserIDKey is the session key for user ID. UserIDKey = "user_id" // UsernameKey is the session key for username. UsernameKey = "username" // AuthenticatedKey is the session key for authentication // status. AuthenticatedKey = "authenticated" // CreatedAtKey is the session key holding the Unix timestamp at // which the session was authenticated. It anchors the ABSOLUTE // expiry clock and is written exactly once, by SetUser. Nothing // refreshes it: an absolute deadline that moved with activity // would not be a cap at all. CreatedAtKey = "created_at" // LastSeenKey is the session key holding the Unix timestamp of // the most recent authenticated request. It anchors the IDLE // expiry clock and is pushed forward by Touch. LastSeenKey = "last_seen" // sessionKeyLength is the required length in bytes for the // session authentication key. sessionKeyLength = 32 // sessionMaxAgeDays is the session cookie lifetime in days. sessionMaxAgeDays = 7 // secondsPerDay is the number of seconds in a day. secondsPerDay = 86400 // sessionAbsoluteMaxAge is the hard upper bound on how long a // session may live, measured from CreatedAtKey. Activity never // extends it, so even a continuously used session ends here and // the user has to authenticate again. sessionAbsoluteMaxAge = sessionMaxAgeDays * secondsPerDay * time.Second // idleRefreshDivisor rate-limits idle-deadline refreshes. Touch // only rewrites LastSeenKey once the stored value is older than // idleTimeout/idleRefreshDivisor, so an active session is // re-saved at most this many times per idle window instead of // once per request. See Touch for the tradeoff this buys. idleRefreshDivisor = 10 ) // ErrSessionKeyLength is returned when the decoded session key // does not have the expected length. var ErrSessionKeyLength = errors.New("session key length mismatch") // Params holds dependencies injected by fx. type Params struct { fx.In Config *config.Config Database *database.Database Logger *logger.Logger } // Session manages encrypted session storage. type Session struct { store *sessions.CookieStore key []byte // raw 32-byte auth key, also used for CSRF cookie signing log *slog.Logger config *config.Config // idleTimeout is the sliding inactivity window. A session that // sees no authenticated request within this window expires, // independently of the absolute cap. Non-positive disables idle // expiry and leaves sessionAbsoluteMaxAge as the only bound. idleTimeout time.Duration // now reads the current time. Injected so expiry can be tested // without sleeping. now func() time.Time } // New creates a new session manager. The cookie store is // initialized during the fx OnStart phase after the database is // connected, using a session key that is auto-generated and stored // in the database. func New( lc fx.Lifecycle, params Params, ) (*Session, error) { s := &Session{ log: params.Logger.Get(), config: params.Config, idleTimeout: params.Config.SessionIdleTimeout, now: time.Now, } lc.Append(fx.Hook{ OnStart: func(_ context.Context) error { sessionKey, err := params.Database.GetOrCreateSessionKey() if err != nil { return fmt.Errorf( "failed to get session key: %w", err, ) } keyBytes, err := base64.StdEncoding.DecodeString( sessionKey, ) if err != nil { return fmt.Errorf( "invalid session key format: %w", err, ) } if len(keyBytes) != sessionKeyLength { return fmt.Errorf( "%w: want %d, got %d", ErrSessionKeyLength, sessionKeyLength, len(keyBytes), ) } store := sessions.NewCookieStore(keyBytes) // Configure cookie options for security store.Options = &sessions.Options{ Path: "/", MaxAge: secondsPerDay * sessionMaxAgeDays, HttpOnly: true, Secure: !params.Config.IsDev(), SameSite: http.SameSiteLaxMode, } s.key = keyBytes s.store = store s.log.Info("session manager initialized") return nil }, }) return s, nil } // Get retrieves a session for the request. func (s *Session) Get( r *http.Request, ) (*sessions.Session, error) { return s.store.Get(r, SessionName) } // GetKey returns the raw 32-byte authentication key used for // session encryption. This key is also suitable for CSRF cookie // signing. func (s *Session) GetKey() []byte { return s.key } // Save saves the session. func (s *Session) Save( r *http.Request, w http.ResponseWriter, sess *sessions.Session, ) error { return sess.Save(r, w) } // SetUser sets the user information in the session. It starts both // expiry clocks: CreatedAtKey (absolute, never refreshed again) and // LastSeenKey (idle, refreshed by Touch). func (s *Session) SetUser( sess *sessions.Session, userID, username string, ) { now := s.now().Unix() sess.Values[UserIDKey] = userID sess.Values[UsernameKey] = username sess.Values[AuthenticatedKey] = true sess.Values[CreatedAtKey] = now sess.Values[LastSeenKey] = now } // ClearUser removes user information from the session, including // both expiry timestamps. func (s *Session) ClearUser(sess *sessions.Session) { delete(sess.Values, UserIDKey) delete(sess.Values, UsernameKey) delete(sess.Values, AuthenticatedKey) delete(sess.Values, CreatedAtKey) delete(sess.Values, LastSeenKey) } // sessionTime reads a Unix-second timestamp stored under key. func sessionTime( sess *sessions.Session, key string, ) (time.Time, bool) { secs, ok := sess.Values[key].(int64) if !ok { return time.Time{}, false } return time.Unix(secs, 0), true } // IsAuthenticated checks if the session has an authenticated user // whose session has not passed either expiry deadline. Every // authentication decision goes through here, so neither clock can // be bypassed by a caller that forgets to check it. func (s *Session) IsAuthenticated(sess *sessions.Session) bool { auth, ok := sess.Values[AuthenticatedKey].(bool) if !ok || !auth { return false } return !s.expired(sess) } // Touch records authenticated activity by pushing the IDLE deadline // forward. It writes LastSeenKey only; CreatedAtKey is left alone so // the absolute cap keeps counting down even for a user who never // stops clicking. // // Callers must only invoke Touch for a request that authenticated // with this session. Refreshing on an unauthenticated request would // let anyone holding a stolen or abandoned cookie keep the session // alive by polling a public endpoint. Touch enforces that itself by // returning false for any session that is not currently // authenticated and unexpired. // // To avoid re-encrypting and re-emitting the session cookie on every // single request, the timestamp is advanced only once it is older // than idleTimeout/idleRefreshDivisor. The tradeoff is that // LastSeenKey lags real activity by up to that much, so a session // can expire slightly early relative to the user's true last // request -- never late. // // Touch reports whether it changed the session; only then does the // caller need to save it. func (s *Session) Touch(sess *sessions.Session) bool { if s.idleTimeout <= 0 { return false } if !s.IsAuthenticated(sess) { return false } now := s.now() lastSeen, ok := sessionTime(sess, LastSeenKey) if ok && now.Sub(lastSeen) < s.idleTimeout/idleRefreshDivisor { return false } sess.Values[LastSeenKey] = now.Unix() return true } // GetUserID retrieves the user ID from the session. func (s *Session) GetUserID( sess *sessions.Session, ) (string, bool) { userID, ok := sess.Values[UserIDKey].(string) return userID, ok } // GetUsername retrieves the username from the session. func (s *Session) GetUsername( sess *sessions.Session, ) (string, bool) { username, ok := sess.Values[UsernameKey].(string) return username, ok } // Destroy invalidates the session. func (s *Session) Destroy(sess *sessions.Session) { sess.Options.MaxAge = -1 s.ClearUser(sess) } // Regenerate creates a new session with the same values but a // fresh ID. The old session is destroyed (MaxAge = -1) and saved, // then a new session is created. This prevents session fixation // attacks by ensuring the session ID changes after privilege // escalation (e.g. login). func (s *Session) Regenerate( r *http.Request, w http.ResponseWriter, oldSess *sessions.Session, ) (*sessions.Session, error) { // Copy the values from the old session oldValues := make(map[any]any) maps.Copy(oldValues, oldSess.Values) // Destroy the old session oldSess.Options.MaxAge = -1 s.ClearUser(oldSess) err := oldSess.Save(r, w) if err != nil { return nil, fmt.Errorf( "failed to destroy old session: %w", err, ) } // Create a new session (gorilla/sessions generates a new ID) newSess, err := s.store.New(r, SessionName) if err != nil { // store.New may return an error alongside a new empty // session if the old cookie is now invalid. That is // expected after we destroyed it above. Only fail on a // nil session. if newSess == nil { return nil, fmt.Errorf( "failed to create new session: %w", err, ) } } // Restore the copied values into the new session maps.Copy(newSess.Values, oldValues) // Apply the standard session options (the destroyed old // session had MaxAge = -1, which store.New might inherit // from the cookie). newSess.Options = &sessions.Options{ Path: "/", MaxAge: secondsPerDay * sessionMaxAgeDays, HttpOnly: true, Secure: !s.config.IsDev(), SameSite: http.SameSiteLaxMode, } return newSess, nil } // expired reports whether the session has passed either of its two // independent deadlines. They are deliberately kept apart: // // - the ABSOLUTE deadline is CreatedAtKey + sessionAbsoluteMaxAge. // It is fixed at login and no amount of activity moves it. // - the IDLE deadline is LastSeenKey + idleTimeout. Activity moves // it forward via Touch. // // Whichever comes first ends the session. // // A session that claims to be authenticated but carries no // timestamps predates this check; it is treated as expired so the // user re-authenticates rather than being granted an unbounded // session. func (s *Session) expired(sess *sessions.Session) bool { now := s.now() createdAt, ok := sessionTime(sess, CreatedAtKey) if !ok { return true } if !now.Before(createdAt.Add(sessionAbsoluteMaxAge)) { return true } if s.idleTimeout <= 0 { return false } lastSeen, ok := sessionTime(sess, LastSeenKey) if !ok { return true } return !now.Before(lastSeen.Add(s.idleTimeout)) }