// Package session manages HTTP session storage and authentication // state. package session import ( "context" "encoding/base64" "errors" "fmt" "log/slog" "maps" "net/http" "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" // 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 ) // 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 } // 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, } 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. func (s *Session) SetUser( sess *sessions.Session, userID, username string, ) { sess.Values[UserIDKey] = userID sess.Values[UsernameKey] = username sess.Values[AuthenticatedKey] = true } // ClearUser removes user information from the session. func (s *Session) ClearUser(sess *sessions.Session) { delete(sess.Values, UserIDKey) delete(sess.Values, UsernameKey) delete(sess.Values, AuthenticatedKey) } // IsAuthenticated checks if the session has an authenticated // user. func (s *Session) IsAuthenticated(sess *sessions.Session) bool { auth, ok := sess.Values[AuthenticatedKey].(bool) return ok && auth } // 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 }