// Package session implementiert Core IAM-12: Selbstbedienungs-Sitzungs- // uebersicht mit Geraete-/Zugriffsinformationen und gezieltem Widerruf. // Bewusst serverseitig und widerrufbar — ein separater Mechanismus neben // IAM-02s zustandslosem JWT (das fuer schnelle Modul-zu-Modul-Verifikation // ohne Core-Rueckfrage gewaehlt wurde, siehe API-05). Fuer den // Selbstbedienungs-Anwendungsfall hier ist sofortige Widerrufbarkeit // wichtiger als Zustandslosigkeit. package session import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ErrSessionInvalid = errors.New("session: sitzung ungueltig oder widerrufen") type Session struct { ID string UserID string DeviceInfo string IP string CreatedAt time.Time LastSeenAt time.Time } type Store struct { pool *pgxpool.Pool } func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // Create legt eine neue Sitzung an und liefert den Klartext-Token NUR an den // Aufrufer zurueck (als Cookie-Wert) — gespeichert wird ausschliesslich der Hash. func (s *Store) Create(ctx context.Context, userID, deviceInfo, ip string) (id, token string, err error) { token, err = randomToken() if err != nil { return "", "", fmt.Errorf("token erzeugen: %w", err) } hash := hashToken(token) err = s.pool.QueryRow(ctx, ` INSERT INTO sessions (user_id, token_hash, device_info, ip) VALUES ($1, $2, $3, $4) RETURNING id `, userID, hash, deviceInfo, ip).Scan(&id) if err != nil { return "", "", fmt.Errorf("sitzung anlegen: %w", err) } return id, token, nil } // Validate prueft ein Sitzungs-Token direkt gegen die Datenbank — kein // Cache, ein widerrufenes Token ist beim naechsten Zugriffsversuch sofort // ungueltig (Akzeptanzkriterium 2). Erfolgreiche Validierung aktualisiert // last_seen_at (Akzeptanzkriterium 1: letzter Zugriff). func (s *Store) Validate(ctx context.Context, token string) (Session, error) { hash := hashToken(token) var sess Session err := s.pool.QueryRow(ctx, ` UPDATE sessions SET last_seen_at = now() WHERE token_hash = $1 AND revoked_at IS NULL RETURNING id, user_id, device_info, ip, created_at, last_seen_at `, hash).Scan(&sess.ID, &sess.UserID, &sess.DeviceInfo, &sess.IP, &sess.CreatedAt, &sess.LastSeenAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return Session{}, ErrSessionInvalid } return Session{}, fmt.Errorf("sitzung pruefen: %w", err) } return sess, nil } // ListForUser liefert alle NICHT widerrufenen Sitzungen eines Benutzers // (Akzeptanzkriterium 1). func (s *Store) ListForUser(ctx context.Context, userID string) ([]Session, error) { rows, err := s.pool.Query(ctx, ` SELECT id, user_id, device_info, ip, created_at, last_seen_at FROM sessions WHERE user_id = $1 AND revoked_at IS NULL ORDER BY last_seen_at DESC `, userID) if err != nil { return nil, fmt.Errorf("sitzungen auflisten: %w", err) } defer rows.Close() var out []Session for rows.Next() { var sess Session if err := rows.Scan(&sess.ID, &sess.UserID, &sess.DeviceInfo, &sess.IP, &sess.CreatedAt, &sess.LastSeenAt); err != nil { return nil, fmt.Errorf("sitzung lesen: %w", err) } out = append(out, sess) } return out, rows.Err() } // Revoke widerruft eine einzelne Sitzung sofort (Akzeptanzkriterium 2). func (s *Store) Revoke(ctx context.Context, sessionID string) error { _, err := s.pool.Exec(ctx, `UPDATE sessions SET revoked_at = now() WHERE id = $1`, sessionID) if err != nil { return fmt.Errorf("sitzung widerrufen: %w", err) } return nil } // RevokeAllExcept beendet alle Sitzungen eines Benutzers ausser der // angegebenen (Akzeptanzkriterium 3: "alle anderen beenden", aktuelle // Sitzung bleibt bestehen). func (s *Store) RevokeAllExcept(ctx context.Context, userID, keepSessionID string) error { _, err := s.pool.Exec(ctx, ` UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND id <> $2 AND revoked_at IS NULL `, userID, keepSessionID) if err != nil { return fmt.Errorf("andere sitzungen widerrufen: %w", err) } return nil } func randomToken() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } func hashToken(token string) []byte { sum := sha256.Sum256([]byte(token)) return sum[:] }