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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 = ©Session
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user