Files
Yggdrasil/internal/middleware/sessions.go
T
Klagarge fc1b45058c feat: shiroa book, ACL parser, and OIDC-gated Go server
- docs/: shiroa book with three example categories (thermo, security,
  homelab); pages declare access via '#let access-groups = (...)'
- cmd/parser: scans docs/ and generates acl.json (public by default,
  skips book.typ/template.typ/partials)
- internal/acl: loads acl.json, normalizes URLs to page keys so a page's
  renderer data (*.multi.sir.in) inherits its protection; fail closed on
  malformed input
- internal/oidc: authorization-code flow client, verified ID tokens,
  groups claim from Authentik
- internal/middleware: in-memory sessions, login/logout/callback handlers,
  CSRF states, access gate + security headers
- cmd/server + embed.go: single binary embedding dist/ and acl.json;
  /searchindex.json blocked; graceful shutdown
- Justfile (build/run/parse/check/clean/preview-docs), Gitea Actions
  workflow (shiroa v0.4.0-rc2), rootless Dockerfile, README
- legacy docs moved to legacy_docs/; .idea untracked
2026-10-10 18:42:40 +02:00

102 lines
2.1 KiB
Go

package middleware
import (
"context"
"crypto/rand"
"encoding/base64"
"sync"
"time"
"github.com/Klagarge/yggdrasil/internal/oidc"
)
// Session is an authenticated user.
type Session struct {
Subject string
Email string
Name string
Groups []string
Expires time.Time
}
// SessionStore keeps sessions in memory. Sessions do not survive server
// restarts; users simply log in again.
type SessionStore struct {
mu sync.RWMutex
sessions map[string]*Session
ttl time.Duration
}
// NewSessionStore creates a store with the given session lifetime.
func NewSessionStore(ttl time.Duration) *SessionStore {
return &SessionStore{
sessions: make(map[string]*Session),
ttl: ttl,
}
}
// TTL reports the session lifetime.
func (s *SessionStore) TTL() time.Duration { return s.ttl }
// Create registers a new session and returns its ID.
func (s *SessionStore) Create(p oidc.Profile) string {
id := randomID()
s.mu.Lock()
s.sessions[id] = &Session{
Subject: p.Subject,
Email: p.Email,
Name: p.Name,
Groups: p.Groups,
Expires: time.Now().Add(s.ttl),
}
s.mu.Unlock()
return id
}
// Get returns the session for id if it exists and has not expired.
func (s *SessionStore) Get(id string) (*Session, bool) {
s.mu.RLock()
sess, ok := s.sessions[id]
s.mu.RUnlock()
if !ok || time.Now().After(sess.Expires) {
return nil, false
}
return sess, true
}
// Delete removes a session.
func (s *SessionStore) Delete(id string) {
s.mu.Lock()
delete(s.sessions, id)
s.mu.Unlock()
}
// StartJanitor purges expired sessions periodically until ctx is done.
// Call it in a goroutine.
func (s *SessionStore) StartJanitor(ctx context.Context, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
s.mu.Lock()
for id, sess := range s.sessions {
if now.After(sess.Expires) {
delete(s.sessions, id)
}
}
s.mu.Unlock()
}
}
}
func randomID() string {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
panic(err) // crypto/rand does not fail on supported platforms
}
return base64.RawURLEncoding.EncodeToString(b[:])
}