package services import ( "context" "crypto/rand" "encoding/hex" "errors" "net/http" "os" "strconv" "sync" "time" "github.com/rs/zerolog/log" ) const defaultAPISessionExpirationDuration = 30 * time.Second var APISessionExpirationDuration = sync.OnceValue[time.Duration](func() time.Duration { expirationDuration, err := strconv.Atoi(os.Getenv("API_SESSION_EXPIRATION_DURATION")) if err != nil { log.Debug().Err(err).Msg("unable to load API_SESSION_EXPIRATION_DURATION") return defaultAPISessionExpirationDuration } return time.Duration(expirationDuration * int(time.Second)) }) var APISecure = sync.OnceValue[bool](func() bool { return os.Getenv("API_SECURE") == "true" }) var ( ErrSessionIdCollision = errors.New("sessionId collision") ErrUnauthorized = errors.New("unauthorized") ) func generateSessionID() (string, error) { sessionID := make([]byte, 32) if _, err := rand.Read(sessionID); err != nil { return "", err } return hex.EncodeToString(sessionID), nil } type Session struct { l sync.RWMutex sessionId string expirationTime time.Time } func (s *Session) GenerateCookie() *http.Cookie { s.l.RLock() defer s.l.RUnlock() return &http.Cookie{ Name: "session_id", Value: s.sessionId, HttpOnly: true, Secure: APISecure(), Expires: s.expirationTime, } } type SessionStore struct { l sync.RWMutex ctx context.Context fnCancel context.CancelFunc sessions map[string]*Session } func NewSessionStore(ctx context.Context) *SessionStore { ctxChild, fnCancel := context.WithCancel(ctx) s := &SessionStore{ ctx: ctxChild, fnCancel: fnCancel, sessions: map[string]*Session{}, } s.purgeWorker() return s } func (s *SessionStore) purge() { s.l.Lock() defer s.l.Unlock() now := time.Now() toDelete := []*Session{} for _, session := range s.sessions { if now.After(session.expirationTime) { toDelete = append(toDelete, session) } } for _, session := range toDelete { log.Debug().Str("sessionId", session.sessionId).Msg("purge expired session") delete(s.sessions, session.sessionId) } } func (s *SessionStore) purgeWorker() { ticker := time.NewTicker(10 * time.Second) go func() { for { select { case <-ticker.C: s.purge() case <-s.ctx.Done(): log.Info().Msg("purge worker stopped") ticker.Stop() return } } }() } func (s *SessionStore) Stop() { s.fnCancel() } func (s *SessionStore) Done() <-chan struct{} { return s.ctx.Done() } func (s *SessionStore) NewSession() (*Session, error) { sessionId, err := generateSessionID() if err != nil { log.Err(err).Msg("unable to generate sessionId") return nil, err } s.l.Lock() defer s.l.Unlock() if _, ok := s.sessions[sessionId]; ok { log.Error().Str("sessionId", sessionId).Msg("sessionId collision") return nil, ErrSessionIdCollision } now := time.Now().Add(APISessionExpirationDuration()) session := Session{expirationTime: now, sessionId: sessionId} s.sessions[sessionId] = &session return &session, nil } func (s *SessionStore) IsLogged(r *http.Request) bool { cookie, err := r.Cookie("session_id") if err != nil { return false } s.l.RLock() defer s.l.RUnlock() _, ok := s.sessions[cookie.Value] return ok }