Phase 13 : sogoms-cron
- Jobs planifiés avec schedule cron standard
- Types: query_email, http, service
- Actions: list, trigger, status
Phase 16 : Réorganisation config/apps/{app}/
- Tous les fichiers d'une app dans un seul dossier
- Migration prokov vers nouvelle structure
Phase 17 : sogoms-admin
- Interface web d'administration (Go templates + htmx)
- Auth sessions cookies signées HMAC-SHA256
- Rôles super_admin / app_admin avec permissions
Phase 19 : Création d'app via Admin UI
- Formulaire création app avec config DB/auth
- Bouton "Scanner la base" : introspection + schema.yaml
- Rechargement automatique sogoway via SIGHUP
Infrastructure :
- sogoctl : socket de contrôle /run/sogoctl.sock
- sogoway : reload config sur SIGHUP sans restart
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
215 lines
4.8 KiB
Go
215 lines
4.8 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"sogoms.com/internal/admin"
|
|
)
|
|
|
|
// Session représente une session utilisateur.
|
|
type Session struct {
|
|
ID string
|
|
Username string
|
|
Role string
|
|
CSRFToken string
|
|
CreatedAt time.Time
|
|
ExpiresAt time.Time
|
|
IP string
|
|
UserAgent string
|
|
}
|
|
|
|
// IsExpired vérifie si la session a expiré.
|
|
func (s *Session) IsExpired() bool {
|
|
return time.Now().After(s.ExpiresAt)
|
|
}
|
|
|
|
// SessionStore gère les sessions en mémoire.
|
|
type SessionStore struct {
|
|
sessions map[string]*Session
|
|
config *admin.SessionConfig
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewSessionStore crée un nouveau store de sessions.
|
|
func NewSessionStore(config *admin.SessionConfig) *SessionStore {
|
|
return &SessionStore{
|
|
sessions: make(map[string]*Session),
|
|
config: config,
|
|
}
|
|
}
|
|
|
|
// Create crée une nouvelle session.
|
|
func (s *SessionStore) Create(username, role, ip, userAgent string) (*Session, error) {
|
|
sessionID, err := generateSecureToken(32)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
csrfToken, err := generateSecureToken(32)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now()
|
|
session := &Session{
|
|
ID: sessionID,
|
|
Username: username,
|
|
Role: role,
|
|
CSRFToken: csrfToken,
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(time.Duration(s.config.MaxAge) * time.Second),
|
|
IP: ip,
|
|
UserAgent: userAgent,
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.sessions[sessionID] = session
|
|
s.mu.Unlock()
|
|
|
|
return session, nil
|
|
}
|
|
|
|
// Get récupère une session par son ID.
|
|
func (s *SessionStore) Get(sessionID string) (*Session, bool) {
|
|
s.mu.RLock()
|
|
session, ok := s.sessions[sessionID]
|
|
s.mu.RUnlock()
|
|
|
|
if !ok || session.IsExpired() {
|
|
return nil, false
|
|
}
|
|
|
|
return session, true
|
|
}
|
|
|
|
// Delete supprime une session.
|
|
func (s *SessionStore) Delete(sessionID string) {
|
|
s.mu.Lock()
|
|
delete(s.sessions, sessionID)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// Refresh prolonge la durée de vie d'une session (sliding expiration).
|
|
func (s *SessionStore) Refresh(sessionID string) {
|
|
s.mu.Lock()
|
|
if session, ok := s.sessions[sessionID]; ok {
|
|
session.ExpiresAt = time.Now().Add(time.Duration(s.config.MaxAge) * time.Second)
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// Cleanup supprime les sessions expirées.
|
|
func (s *SessionStore) Cleanup() {
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
defer ticker.Stop()
|
|
|
|
for range ticker.C {
|
|
s.mu.Lock()
|
|
now := time.Now()
|
|
for id, session := range s.sessions {
|
|
if now.After(session.ExpiresAt) {
|
|
delete(s.sessions, id)
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// Count retourne le nombre de sessions actives.
|
|
func (s *SessionStore) Count() int {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return len(s.sessions)
|
|
}
|
|
|
|
// SetCookie définit le cookie de session dans la réponse.
|
|
func (s *SessionStore) SetCookie(w http.ResponseWriter, session *Session) {
|
|
// Signer le session ID
|
|
signature := s.sign(session.ID)
|
|
value := session.ID + "." + signature
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: s.config.CookieName,
|
|
Value: value,
|
|
Path: "/admin",
|
|
MaxAge: s.config.MaxAge,
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|
|
|
|
// ClearCookie supprime le cookie de session.
|
|
func (s *SessionStore) ClearCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: s.config.CookieName,
|
|
Value: "",
|
|
Path: "/admin",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|
|
|
|
// GetSessionFromRequest extrait et valide la session depuis le cookie.
|
|
func (s *SessionStore) GetSessionFromRequest(r *http.Request) (*Session, error) {
|
|
cookie, err := r.Cookie(s.config.CookieName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("no session cookie")
|
|
}
|
|
|
|
// Séparer ID et signature
|
|
parts := strings.SplitN(cookie.Value, ".", 2)
|
|
if len(parts) != 2 {
|
|
return nil, fmt.Errorf("invalid cookie format")
|
|
}
|
|
|
|
sessionID := parts[0]
|
|
signature := parts[1]
|
|
|
|
// Vérifier la signature
|
|
if !s.verify(sessionID, signature) {
|
|
return nil, fmt.Errorf("invalid cookie signature")
|
|
}
|
|
|
|
// Récupérer la session
|
|
session, ok := s.Get(sessionID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("session not found or expired")
|
|
}
|
|
|
|
return session, nil
|
|
}
|
|
|
|
// sign signe une donnée avec HMAC-SHA256.
|
|
func (s *SessionStore) sign(data string) string {
|
|
h := hmac.New(sha256.New, []byte(s.config.Secret))
|
|
h.Write([]byte(data))
|
|
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// verify vérifie une signature HMAC.
|
|
func (s *SessionStore) verify(data, signature string) bool {
|
|
expected := s.sign(data)
|
|
return hmac.Equal([]byte(expected), []byte(signature))
|
|
}
|
|
|
|
// generateSecureToken génère un token aléatoire sécurisé.
|
|
func generateSecureToken(length int) (string, error) {
|
|
bytes := make([]byte, length)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(bytes), nil
|
|
}
|