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