- 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
102 lines
2.1 KiB
Go
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[:])
|
|
}
|