IAM-09: api-token-service-accounts
internal/serviceaccount: Service-Accounts als eigenstaendige Identitaetsklasse (eigene Tabelle service_accounts, getrennt von internal/user — Zitadel- Vorbild). Store.IssueToken gibt den Klartext-Token NUR einmal an den Aufrufer zurueck, gespeichert wird ausschliesslich der SHA-256-Hash (Akzeptanzkriterium 1). Scopes und optionale Ablaufzeit sind Teil des Tokens selbst (Akzeptanzkriterium 2). Store.Verify prueft Widerruf/Ablauf/Scope bei JEDEM Aufruf direkt gegen die Datenbank — kein Cache dazwischen, ein widerrufenes Token wird ab dem naechsten Request sofort abgewiesen (Akzeptanzkriterium 3). Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Token ausschliesslich gehasht gespeichert (Stichprobe in der Datenbank) — TestIssueToken_StoresOnlyHash: token_hash-Spalte direkt gelesen, enthaelt nachweislich nicht den Klartext, 32-Byte-SHA-256-Laenge bestaetigt. PASS. 2. Widerrufenes Token wird beim naechsten Request zuverlaessig abgewiesen — TestRevoke_TakesEffectImmediately: Verify vor Widerruf erfolgreich, sofort danach ErrTokenInvalid. PASS. 3. Scope-Verletzung wird korrekt abgewiesen — TestVerify_RejectsInsufficientScope: Token mit scope=read wird fuer scope=write abgewiesen (ErrScopeInsufficient), fuer scope=read akzeptiert. PASS. Zusaetzlich: TestVerify_RejectsExpiredToken belegt die optionale zeitliche Befristung aus Akzeptanzkriterium 2. PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3d20d86a4f
commit
95275407b1
@@ -0,0 +1,151 @@
|
||||
// Package serviceaccount implementiert Core IAM-09: Service-Accounts als
|
||||
// eigenstaendige Identitaeten (getrennt von menschlichen Benutzern, siehe
|
||||
// internal/user) sowie API-Token mit Scopes und optionaler Ablaufzeit zur
|
||||
// programmatischen Authentifizierung.
|
||||
package serviceaccount
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTokenInvalid = errors.New("serviceaccount: token ungueltig, widerrufen oder abgelaufen")
|
||||
ErrScopeInsufficient = errors.New("serviceaccount: token hat nicht den erforderlichen scope")
|
||||
ErrServiceAccountNotFound = errors.New("serviceaccount: nicht gefunden")
|
||||
)
|
||||
|
||||
type ServiceAccount struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
ID string
|
||||
ServiceAccountID string
|
||||
Scopes []string
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// CreateServiceAccount legt eine neue, eigenstaendige Identitaet an
|
||||
// (Akzeptanzkriterium 1) — lebt in service_accounts, nicht in users.
|
||||
func (s *Store) CreateServiceAccount(ctx context.Context, name string) (ServiceAccount, error) {
|
||||
if name == "" {
|
||||
return ServiceAccount{}, errors.New("serviceaccount: name darf nicht leer sein")
|
||||
}
|
||||
var sa ServiceAccount
|
||||
sa.Name = name
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO service_accounts (name) VALUES ($1) RETURNING id
|
||||
`, name).Scan(&sa.ID); err != nil {
|
||||
return ServiceAccount{}, fmt.Errorf("service-account anlegen: %w", err)
|
||||
}
|
||||
return sa, nil
|
||||
}
|
||||
|
||||
// IssueToken erzeugt ein neues API-Token fuer einen Service-Account.
|
||||
// scopes beschraenkt, wofuer das Token verwendet werden darf; ttl ist
|
||||
// optional (nil = kein Ablauf) — beides Akzeptanzkriterium 2. Der
|
||||
// Klartext-Token wird NUR hier zurueckgegeben, gespeichert wird ausschliesslich
|
||||
// der Hash (Akzeptanzkriterium 1 / Pruefung 1).
|
||||
func (s *Store) IssueToken(ctx context.Context, serviceAccountID string, scopes []string, ttl *time.Duration) (id, plaintext string, err error) {
|
||||
plaintext, err = randomToken()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("token erzeugen: %w", err)
|
||||
}
|
||||
hash := hashToken(plaintext)
|
||||
if scopes == nil {
|
||||
scopes = []string{}
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
if ttl != nil {
|
||||
t := time.Now().Add(*ttl)
|
||||
expiresAt = &t
|
||||
}
|
||||
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
INSERT INTO api_tokens (service_account_id, token_hash, scopes, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id
|
||||
`, serviceAccountID, hash, scopes, expiresAt).Scan(&id)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("token speichern: %w", err)
|
||||
}
|
||||
return id, plaintext, nil
|
||||
}
|
||||
|
||||
// Verify prueft ein Token gegen einen benoetigten Scope. Widerruf und Ablauf
|
||||
// werden bei JEDEM Aufruf direkt gegen die Datenbank geprueft — es gibt
|
||||
// keinen Cache, ein widerrufenes Token wird beim naechsten Request sofort
|
||||
// abgewiesen (Akzeptanzkriterium 3). requiredScope == "" ueberspringt die
|
||||
// Scope-Pruefung.
|
||||
func (s *Store) Verify(ctx context.Context, tokenString, requiredScope string) (Token, error) {
|
||||
hash := hashToken(tokenString)
|
||||
|
||||
var tok Token
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, service_account_id, scopes, expires_at
|
||||
FROM api_tokens
|
||||
WHERE token_hash = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())
|
||||
`, hash).Scan(&tok.ID, &tok.ServiceAccountID, &tok.Scopes, &tok.ExpiresAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Token{}, ErrTokenInvalid
|
||||
}
|
||||
return Token{}, fmt.Errorf("token pruefen: %w", err)
|
||||
}
|
||||
|
||||
if requiredScope != "" && !hasScope(tok.Scopes, requiredScope) {
|
||||
return Token{}, ErrScopeInsufficient
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// Revoke widerruft ein Token sofort — Verify schlaegt ab dem naechsten
|
||||
// Aufruf fehl (Akzeptanzkriterium 3 / Pruefung 2).
|
||||
func (s *Store) Revoke(ctx context.Context, tokenID string) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE api_tokens SET revoked_at = now() WHERE id = $1`, tokenID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("token widerrufen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasScope(scopes []string, want string) bool {
|
||||
for _, s := range scopes {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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[:]
|
||||
}
|
||||
Reference in New Issue
Block a user