Add users and network settings tabs with admin protections.

List and delete accounts with last-admin and Administrators-group guards, and expose editable network settings via the configuration UI.
This commit is contained in:
2026-07-18 15:18:01 +02:00
parent f049f766d9
commit 2605c3b346
49 changed files with 5440 additions and 209 deletions
+16
View File
@@ -0,0 +1,16 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"fmt"
)
// NewRandomID returns a URL-safe high-entropy identifier.
func NewRandomID() (string, error) {
buffer := make([]byte, 16)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("random id: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
+135
View File
@@ -0,0 +1,135 @@
package auth
import (
"fmt"
"net"
"regexp"
"strings"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
var hostnamePattern = regexp.MustCompile(`^(?i)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$|^localhost$`)
// ValidateHostname checks a public hostname (empty is allowed for local/dev).
func ValidateHostname(hostname string) error {
trimmed := strings.TrimSpace(hostname)
if trimmed == "" {
return nil
}
if strings.Contains(trimmed, "/") || strings.Contains(trimmed, ":") || strings.Contains(trimmed, " ") {
return fmt.Errorf("public_hostname must be a bare DNS name without scheme, port, or path")
}
if !hostnamePattern.MatchString(trimmed) {
return fmt.Errorf("public_hostname is not a valid DNS hostname")
}
return nil
}
// HostMatches reports whether requestHost matches the configured public hostname.
// An empty / whitespace public hostname means any host is allowed (local/dev).
func HostMatches(requestHost string, publicHostname string) bool {
wanted := strings.TrimSpace(publicHostname)
if wanted == "" {
return true
}
host := strings.TrimSpace(requestHost)
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
host = parsedHost
}
return strings.EqualFold(host, wanted)
}
// IPAllowed evaluates whitelist/blacklist rules against clientIP.
func IPAllowed(clientIP string, network settings.NetworkSettings) (bool, string) {
network = settings.EffectiveNetwork(network)
parsedIP := net.ParseIP(clientIP)
if parsedIP == nil {
return false, "unable to parse client IP"
}
switch network.AccessMode {
case settings.AccessModeOpen, "":
return true, ""
case settings.AccessModeWhitelist:
if matchesAnyRule(parsedIP, network.Rules) {
return true, ""
}
return false, "client IP is not on the whitelist"
case settings.AccessModeBlacklist:
if matchesAnyRule(parsedIP, network.Rules) {
return false, "client IP matches a blacklist rule"
}
return true, ""
default:
return false, "invalid access mode"
}
}
// WouldLockOut estimates whether rules would block clientIP.
func WouldLockOut(clientIP string, network settings.NetworkSettings) (bool, string) {
allowed, reason := IPAllowed(clientIP, network)
if allowed {
return false, ""
}
return true, reason
}
func matchesAnyRule(ip net.IP, rules []string) bool {
for _, rule := range rules {
trimmed := strings.TrimSpace(rule)
if trimmed == "" {
continue
}
if strings.Contains(trimmed, "/") {
_, network, err := net.ParseCIDR(trimmed)
if err != nil {
continue
}
if network.Contains(ip) {
return true
}
continue
}
ruleIP := net.ParseIP(trimmed)
if ruleIP != nil && ruleIP.Equal(ip) {
return true
}
}
return false
}
// ValidateListenAddress checks a bind address (IP or hostname).
func ValidateListenAddress(address string) error {
trimmed := strings.TrimSpace(address)
if trimmed == "" {
return fmt.Errorf("listen_address is required")
}
if trimmed == "0.0.0.0" || trimmed == "::" || trimmed == "localhost" {
return nil
}
if net.ParseIP(trimmed) != nil {
return nil
}
return fmt.Errorf("listen_address must be an IP address or 0.0.0.0")
}
// ValidateAccessRules validates CIDR/IP entries.
func ValidateAccessRules(rules []string) error {
for _, rule := range rules {
trimmed := strings.TrimSpace(rule)
if trimmed == "" {
continue
}
if strings.Contains(trimmed, "/") {
if _, _, err := net.ParseCIDR(trimmed); err != nil {
return fmt.Errorf("invalid CIDR rule %q", trimmed)
}
continue
}
if net.ParseIP(trimmed) == nil {
return fmt.Errorf("invalid IP rule %q", trimmed)
}
}
return nil
}
+128
View File
@@ -0,0 +1,128 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"unicode"
"golang.org/x/crypto/argon2"
)
const (
argon2Time = 3
argon2Memory = 64 * 1024
argon2Threads = 4
argon2KeyLen = 32
argon2SaltLen = 16
minPasswordLength = 14
minPassphraseWords = 5
maxPasswordLength = 256
)
var (
ErrPasswordTooLong = errors.New("password must be at most 256 characters")
ErrPasswordTooWeak = errors.New("password must be at least 14 characters or at least 5 words")
ErrPasswordMatchesUser = errors.New("password cannot match the login name")
ErrPasswordCommon = errors.New("password is too common")
ErrPasswordNotPrintable = errors.New("password may only contain printable characters")
)
// ValidatePassword checks password policy rules.
func ValidatePassword(password string, username string) error {
if len(password) > maxPasswordLength {
return ErrPasswordTooLong
}
for _, runeValue := range password {
if !unicode.IsPrint(runeValue) {
return ErrPasswordNotPrintable
}
}
if strings.EqualFold(strings.TrimSpace(password), strings.TrimSpace(username)) {
return ErrPasswordMatchesUser
}
if isCommonPassword(password) {
return ErrPasswordCommon
}
wordCount := countWords(password)
if len(password) >= minPasswordLength || wordCount >= minPassphraseWords {
return nil
}
return ErrPasswordTooWeak
}
func countWords(password string) int {
fields := strings.Fields(password)
return len(fields)
}
// HashPassword returns a PHC-formatted argon2id hash string.
func HashPassword(password string) (string, error) {
salt := make([]byte, argon2SaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
hash := argon2.IDKey(
[]byte(password),
salt,
argon2Time,
argon2Memory,
argon2Threads,
argon2KeyLen,
)
return fmt.Sprintf(
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
argon2Memory,
argon2Time,
argon2Threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
), nil
}
// VerifyPassword compares password against a PHC argon2id hash.
func VerifyPassword(password string, encodedHash string) (bool, error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("unsupported password hash format")
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, errors.New("invalid hash version")
}
var memory uint32
var timeCost uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
return false, errors.New("invalid hash parameters")
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, errors.New("invalid hash salt")
}
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, errors.New("invalid hash digest")
}
computed := argon2.IDKey(
[]byte(password),
salt,
timeCost,
memory,
threads,
uint32(len(expectedHash)),
)
return subtle.ConstantTimeCompare(computed, expectedHash) == 1, nil
}
+70
View File
@@ -0,0 +1,70 @@
package auth
import (
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestValidatePasswordAcceptsLongRandom(t *testing.T) {
t.Parallel()
if err := ValidatePassword("Tr0ub4dor&3-extra!", "Admin"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidatePasswordAcceptsPassphrase(t *testing.T) {
t.Parallel()
if err := ValidatePassword("apple banana cherry date elderberry", "Admin"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidatePasswordRejectsShort(t *testing.T) {
t.Parallel()
if err := ValidatePassword("short-pass", "Admin"); err == nil {
t.Fatal("expected error")
}
}
func TestValidatePasswordRejectsUsername(t *testing.T) {
t.Parallel()
if err := ValidatePassword("AdminAdminAdmin!", "AdminAdminAdmin!"); err == nil {
t.Fatal("expected error")
}
}
func TestHashAndVerifyPassword(t *testing.T) {
t.Parallel()
hash, err := HashPassword("apple banana cherry date elderberry")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
ok, err := VerifyPassword("apple banana cherry date elderberry", hash)
if err != nil || !ok {
t.Fatalf("VerifyPassword failed: ok=%v err=%v", ok, err)
}
ok, err = VerifyPassword("wrong password here!!", hash)
if err != nil {
t.Fatalf("VerifyPassword error: %v", err)
}
if ok {
t.Fatal("expected mismatch")
}
}
func TestWouldLockOutWhitelist(t *testing.T) {
t.Parallel()
network := settings.NetworkSettings{
AccessMode: settings.AccessModeWhitelist,
Rules: []string{"10.0.0.0/8"},
}
locked, _ := WouldLockOut("192.168.1.5", network)
if !locked {
t.Fatal("expected lock out")
}
locked, _ = WouldLockOut("10.1.2.3", network)
if locked {
t.Fatal("expected allow")
}
}
+250
View File
@@ -0,0 +1,250 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const (
SessionCookieName = "__Host-ClusterCanvas-Session"
sessionIDBytes = 32
sessionRotateAfter = time.Hour
)
var ErrSessionNotFound = errors.New("session not found")
// SessionManager persists sessions in sessions.enc and sets hardened cookies.
type SessionManager struct {
configDir string
key []byte
mu sync.Mutex
}
func NewSessionManager(configDir string, key []byte) *SessionManager {
return &SessionManager{configDir: configDir, key: key}
}
// CreateSession invalidates prior sessions for the user, stores a new session, and sets the cookie.
func (manager *SessionManager) CreateSession(
writer http.ResponseWriter,
userID string,
security settings.SecuritySettings,
) (settings.SessionRecord, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
if err != nil {
return settings.SessionRecord{}, err
}
now := time.Now().UTC()
lifetime := time.Duration(security.SessionLifetimeHours) * time.Hour
filtered := make([]settings.SessionRecord, 0, len(store.Sessions))
for _, session := range store.Sessions {
if session.UserID == userID {
continue
}
if session.ExpiresAt.Before(now) {
continue
}
filtered = append(filtered, session)
}
sessionID, err := newSessionID()
if err != nil {
return settings.SessionRecord{}, err
}
record := settings.SessionRecord{
ID: sessionID,
UserID: userID,
CreatedAt: now,
LastSeenAt: now,
ExpiresAt: now.Add(lifetime),
}
filtered = append(filtered, record)
store.Sessions = filtered
if err := settings.SaveSessions(manager.configDir, store, manager.key); err != nil {
return settings.SessionRecord{}, err
}
setSessionCookie(writer, sessionID, int(lifetime.Seconds()))
return record, nil
}
// LookupValidSession finds a non-expired, non-idle session and may rotate it.
func (manager *SessionManager) LookupValidSession(
writer http.ResponseWriter,
request *http.Request,
security settings.SecuritySettings,
) (settings.SessionRecord, error) {
cookie, err := request.Cookie(SessionCookieName)
if err != nil || strings.TrimSpace(cookie.Value) == "" {
return settings.SessionRecord{}, ErrSessionNotFound
}
manager.mu.Lock()
defer manager.mu.Unlock()
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
if err != nil {
return settings.SessionRecord{}, err
}
now := time.Now().UTC()
idleLimit := time.Duration(security.IdleTimeoutMinutes) * time.Minute
lifetime := time.Duration(security.SessionLifetimeHours) * time.Hour
var found *settings.SessionRecord
remaining := make([]settings.SessionRecord, 0, len(store.Sessions))
for index := range store.Sessions {
session := store.Sessions[index]
if session.ExpiresAt.Before(now) {
continue
}
if session.ID == cookie.Value {
if now.Sub(session.LastSeenAt) > idleLimit {
continue
}
copySession := session
found = &copySession
continue
}
remaining = append(remaining, session)
}
if found == nil {
store.Sessions = remaining
_ = settings.SaveSessions(manager.configDir, store, manager.key)
clearSessionCookie(writer)
return settings.SessionRecord{}, ErrSessionNotFound
}
shouldRotate := now.Sub(found.CreatedAt) >= sessionRotateAfter ||
now.Sub(found.CreatedAt) >= lifetime/2
found.LastSeenAt = now
if shouldRotate {
newID, err := newSessionID()
if err != nil {
return settings.SessionRecord{}, err
}
found.ID = newID
found.CreatedAt = now
setSessionCookie(writer, newID, int(found.ExpiresAt.Sub(now).Seconds()))
}
remaining = append(remaining, *found)
store.Sessions = remaining
if err := settings.SaveSessions(manager.configDir, store, manager.key); err != nil {
return settings.SessionRecord{}, err
}
return *found, nil
}
// InvalidateUserSessions removes all persisted sessions for the given user ID.
func (manager *SessionManager) InvalidateUserSessions(userID string) error {
manager.mu.Lock()
defer manager.mu.Unlock()
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
if err != nil {
return err
}
filtered := make([]settings.SessionRecord, 0, len(store.Sessions))
for _, session := range store.Sessions {
if session.UserID == userID {
continue
}
filtered = append(filtered, session)
}
store.Sessions = filtered
return settings.SaveSessions(manager.configDir, store, manager.key)
}
// DestroySession removes the presented session and clears the cookie.
func (manager *SessionManager) DestroySession(writer http.ResponseWriter, request *http.Request) error {
cookie, err := request.Cookie(SessionCookieName)
clearSessionCookie(writer)
if err != nil || cookie.Value == "" {
return nil
}
manager.mu.Lock()
defer manager.mu.Unlock()
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
if err != nil {
return err
}
filtered := make([]settings.SessionRecord, 0, len(store.Sessions))
for _, session := range store.Sessions {
if session.ID == cookie.Value {
continue
}
filtered = append(filtered, session)
}
store.Sessions = filtered
return settings.SaveSessions(manager.configDir, store, manager.key)
}
func newSessionID() (string, error) {
buffer := make([]byte, sessionIDBytes)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("session id: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
func setSessionCookie(writer http.ResponseWriter, sessionID string, maxAgeSeconds int) {
if maxAgeSeconds < 0 {
maxAgeSeconds = 0
}
http.SetCookie(writer, &http.Cookie{
Name: SessionCookieName,
Value: sessionID,
Path: "/",
MaxAge: maxAgeSeconds,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func clearSessionCookie(writer http.ResponseWriter) {
http.SetCookie(writer, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
// ClientIP extracts the remote IP, preferring X-Forwarded-For when present.
func ClientIP(request *http.Request) string {
if forwarded := strings.TrimSpace(request.Header.Get("X-Forwarded-For")); forwarded != "" {
parts := strings.Split(forwarded, ",")
return strings.TrimSpace(parts[0])
}
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return request.RemoteAddr
}
return host
}
+47
View File
@@ -0,0 +1,47 @@
package auth
import (
"fmt"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
const totpIssuer = "ClusterCanvas"
// GenerateTOTPSecret creates a new TOTP key for username.
func GenerateTOTPSecret(username string) (*otp.Key, error) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: totpIssuer,
AccountName: username,
Period: 30,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
return nil, fmt.Errorf("generate totp: %w", err)
}
return key, nil
}
// VerifyTOTPCode validates a TOTP code against a base32 secret.
func VerifyTOTPCode(secret string, code string) bool {
return totp.Validate(code, secret)
}
// OTPAuthURL builds an otpauth URL from an existing secret.
func OTPAuthURL(username string, secret string) (string, error) {
key, err := otp.NewKeyFromURL(
fmt.Sprintf(
"otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30",
totpIssuer,
username,
secret,
totpIssuer,
),
)
if err != nil {
return "", err
}
return key.URL(), nil
}
+28
View File
@@ -0,0 +1,28 @@
package auth
import "strings"
// Small denylist of common weak passwords (case-insensitive compare).
var commonPasswords = map[string]struct{}{
"password": {},
"password123": {},
"password1234": {},
"12345678901234": {},
"qwertyuiopasdf": {},
"admin": {},
"adminadmin": {},
"letmein": {},
"welcome": {},
"welcome123": {},
"changeme": {},
"changeme123": {},
"cluster canvas": {},
"clustercanvas": {},
"correct horse battery staple": {},
}
func isCommonPassword(password string) bool {
normalized := strings.ToLower(strings.TrimSpace(password))
_, found := commonPasswords[normalized]
return found
}