package session import ( "context" "encoding/base64" "fmt" "log/slog" "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" ) // nolint:revive // SessionParams is a standard fx naming convention type SessionParams struct { fx.In Config *config.Config Database *database.Database Logger *logger.Logger } // Session manages encrypted session storage type Session struct { store *sessions.CookieStore 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 SessionParams) (*Session, error) { s := &Session{ log: params.Logger.Get(), config: params.Config, } lc.Append(fx.Hook{ OnStart: func(_ context.Context) error { // nolint:revive // ctx unused but required by fx 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) != 32 { return fmt.Errorf("session key must be 32 bytes (got %d)", len(keyBytes)) } store := sessions.NewCookieStore(keyBytes) // Configure cookie options for security store.Options = &sessions.Options{ Path: "/", MaxAge: 86400 * 7, // 7 days HttpOnly: true, Secure: !params.Config.IsDev(), // HTTPS in production SameSite: http.SameSiteLaxMode, } 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) } // 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[interface{}]interface{}) for k, v := range oldSess.Values { oldValues[k] = v } // Destroy the old session oldSess.Options.MaxAge = -1 s.ClearUser(oldSess) if err := oldSess.Save(r, w); 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 for k, v := range oldValues { newSess.Values[k] = v } // 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: 86400 * 7, HttpOnly: true, Secure: !s.config.IsDev(), SameSite: http.SameSiteLaxMode, } return newSess, nil }