feat(PROJ-17): Admin Dashboard Systemauslastung immer anzeigen

- Systemauslastungs-Sektion wird immer gerendert (nicht nur bei Erfolg)
- Fehlermeldung wenn /api/admin/system/stats nicht erreichbar ist
- Feature-Status auf In Review gesetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-14 11:43:19 +01:00
parent a893084a88
commit d360c9a5ba
68 changed files with 11938 additions and 435 deletions
+156
View File
@@ -0,0 +1,156 @@
package auth
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/archivmail/internal/userstore"
)
// Session holds the claims extracted from a validated JWT.
type Session struct {
UserID int64
Username string
Role string
JTI string // unique JWT ID
}
// Manager handles login, token issuance, validation, and logout.
type Manager struct {
store *userstore.Store
ldap interface{} // placeholder for LDAP provider
jwtSecret []byte
}
// New creates a new auth Manager.
func New(store *userstore.Store, ldap interface{}, jwtSecret string) *Manager {
return &Manager{
store: store,
ldap: ldap,
jwtSecret: []byte(jwtSecret),
}
}
// Login verifies credentials and returns a signed JWT token.
func (m *Manager) Login(username, password string) (string, *userstore.User, error) {
user, err := m.store.VerifyPassword(username, password)
if err != nil {
return "", nil, fmt.Errorf("auth: login: %w", err)
}
jti := generateJTI()
now := time.Now()
claims := jwt.MapClaims{
"sub": user.Username,
"role": user.Role,
"uid": user.ID,
"jti": jti,
"iat": now.Unix(),
"exp": now.Add(8 * time.Hour).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(m.jwtSecret)
if err != nil {
return "", nil, fmt.Errorf("auth: sign token: %w", err)
}
return signed, user, nil
}
// ValidateToken parses and validates the token, checking the blacklist.
func (m *Manager) ValidateToken(tokenStr string) (*Session, error) {
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("auth: unexpected signing method: %v", t.Header["alg"])
}
return m.jwtSecret, nil
})
if err != nil {
return nil, fmt.Errorf("auth: invalid token: %w", err)
}
if !token.Valid {
return nil, errors.New("auth: token not valid")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, errors.New("auth: bad claims")
}
jti, _ := claims["jti"].(string)
blacklisted, err := m.store.IsBlacklisted(jti)
if err != nil {
return nil, fmt.Errorf("auth: blacklist check: %w", err)
}
if blacklisted {
return nil, errors.New("auth: token revoked")
}
username, _ := claims["sub"].(string)
role, _ := claims["role"].(string)
var userID int64
switch v := claims["uid"].(type) {
case float64:
userID = int64(v)
case int64:
userID = v
}
return &Session{
UserID: userID,
Username: username,
Role: role,
JTI: jti,
}, nil
}
// Logout revokes the token by adding its JTI to the blacklist.
func (m *Manager) Logout(tokenStr string) error {
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("auth: unexpected signing method")
}
return m.jwtSecret, nil
})
if err != nil {
return fmt.Errorf("auth: logout parse: %w", err)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return errors.New("auth: bad claims on logout")
}
jti, _ := claims["jti"].(string)
var exp time.Time
switch v := claims["exp"].(type) {
case float64:
exp = time.Unix(int64(v), 0)
case int64:
exp = time.Unix(v, 0)
default:
exp = time.Now().Add(8 * time.Hour)
}
return m.store.BlacklistToken(jti, exp)
}
// HasRole returns true when userRole satisfies the required role level.
// Hierarchy: admin > auditor > user
func HasRole(userRole, required string) bool {
levels := map[string]int{
userstore.RoleUser: 1,
userstore.RoleAuditor: 2,
userstore.RoleAdmin: 3,
}
return levels[userRole] >= levels[required]
}
// generateJTI returns a pseudo-unique identifier for a JWT.
func generateJTI() string {
return fmt.Sprintf("%d-%x", time.Now().UnixNano(), time.Now().UnixNano()^0xdeadbeef)
}
+161
View File
@@ -0,0 +1,161 @@
package auth_test
import (
"path/filepath"
"testing"
"github.com/archivmail/internal/auth"
"github.com/archivmail/internal/userstore"
)
func newTestAuth(t *testing.T) (*auth.Manager, *userstore.Store) {
t.Helper()
store, err := userstore.New(filepath.Join(t.TempDir(), "users.db"))
if err != nil {
t.Fatalf("userstore.New: %v", err)
}
t.Cleanup(func() { store.Close() })
// Seed a test user
store.Create(userstore.CreateUserRequest{
Username: "testadmin",
Email: "admin@example.com",
Password: "adminpass",
Role: userstore.RoleAdmin,
})
store.Create(userstore.CreateUserRequest{
Username: "regularuser",
Email: "user@example.com",
Password: "userpass",
Role: userstore.RoleUser,
})
mgr := auth.New(store, nil, "test-jwt-secret-32chars-long-enough")
return mgr, store
}
func TestLoginSuccess(t *testing.T) {
mgr, _ := newTestAuth(t)
token, user, err := mgr.Login("testadmin", "adminpass")
if err != nil {
t.Fatalf("Login: %v", err)
}
if token == "" {
t.Error("expected non-empty token")
}
if user.Username != "testadmin" {
t.Errorf("Username = %q", user.Username)
}
if user.Role != userstore.RoleAdmin {
t.Errorf("Role = %q", user.Role)
}
}
func TestLoginWrongPassword(t *testing.T) {
mgr, _ := newTestAuth(t)
if _, _, err := mgr.Login("testadmin", "wrongpass"); err == nil {
t.Error("expected error for wrong password")
}
}
func TestLoginUnknownUser(t *testing.T) {
mgr, _ := newTestAuth(t)
if _, _, err := mgr.Login("nobody", "pw"); err == nil {
t.Error("expected error for unknown user")
}
}
func TestTokenValidation(t *testing.T) {
mgr, _ := newTestAuth(t)
token, _, _ := mgr.Login("testadmin", "adminpass")
sess, err := mgr.ValidateToken(token)
if err != nil {
t.Fatalf("ValidateToken: %v", err)
}
if sess.Username != "testadmin" {
t.Errorf("Session Username = %q", sess.Username)
}
if sess.Role != userstore.RoleAdmin {
t.Errorf("Session Role = %q", sess.Role)
}
if sess.JTI == "" {
t.Error("Session JTI should not be empty")
}
}
func TestTokenTampering(t *testing.T) {
mgr, _ := newTestAuth(t)
token, _, _ := mgr.Login("testadmin", "adminpass")
tampered := token + "x"
if _, err := mgr.ValidateToken(tampered); err == nil {
t.Error("tampered token should fail validation")
}
}
func TestLogout(t *testing.T) {
mgr, _ := newTestAuth(t)
token, _, _ := mgr.Login("testadmin", "adminpass")
// Token valid before logout
if _, err := mgr.ValidateToken(token); err != nil {
t.Fatalf("token should be valid before logout: %v", err)
}
if err := mgr.Logout(token); err != nil {
t.Fatalf("Logout: %v", err)
}
// Token invalid after logout
if _, err := mgr.ValidateToken(token); err == nil {
t.Error("token should be invalid after logout")
}
}
func TestHasRole(t *testing.T) {
tests := []struct {
userRole string
required string
want bool
}{
{userstore.RoleAdmin, userstore.RoleAdmin, true},
{userstore.RoleAdmin, userstore.RoleAuditor, true},
{userstore.RoleAdmin, userstore.RoleUser, true},
{userstore.RoleAuditor, userstore.RoleAdmin, false},
{userstore.RoleAuditor, userstore.RoleAuditor, true},
{userstore.RoleAuditor, userstore.RoleUser, true},
{userstore.RoleUser, userstore.RoleAdmin, false},
{userstore.RoleUser, userstore.RoleAuditor, false},
{userstore.RoleUser, userstore.RoleUser, true},
}
for _, tt := range tests {
got := auth.HasRole(tt.userRole, tt.required)
if got != tt.want {
t.Errorf("HasRole(%q, %q) = %v, want %v", tt.userRole, tt.required, got, tt.want)
}
}
}
func TestMultipleSessionsIndependent(t *testing.T) {
mgr, _ := newTestAuth(t)
token1, _, _ := mgr.Login("testadmin", "adminpass")
token2, _, _ := mgr.Login("testadmin", "adminpass")
if token1 == token2 {
t.Error("two logins should produce different tokens (different JTIs)")
}
// Logout session 1, session 2 should still work
mgr.Logout(token1)
if _, err := mgr.ValidateToken(token2); err != nil {
t.Errorf("session 2 should still be valid after session 1 logout: %v", err)
}
}