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,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
|
||||
}
|
||||
Reference in New Issue
Block a user