Skip to main content
Glama
orneryd

M.I.M.I.R - Multi-agent Intelligent Memory & Insight Repository

by orneryd
auth.go41.4 kB
// Package auth provides authentication and authorization for NornicDB. // // This package implements JWT authentication with role-based access control, // designed to meet regulatory compliance requirements: // - GDPR Art.32: Technical and organizational measures to ensure security // - HIPAA §164.312(a): Access Control - Unique User Identification // - FISMA AC-2: Account Management // - SOC 2 CC6.1: Logical access controls // // Architecture: // - JWT tokens (HS256 algorithm) for stateless authentication // - Multiple credential sources: Bearer header, cookies, query parameters // - Role-based access control (RBAC) with 4 roles: admin, editor, viewer, none // - Account lockout after failed login attempts // - Password hashing with bcrypt // - Audit logging for compliance // // Example Usage: // // // Initialize authenticator // config := auth.DefaultAuthConfig() // config.JWTSecret = []byte("your-secret-key-min-32-chars") // config.MinPasswordLength = 12 // // authenticator, err := auth.NewAuthenticator(config) // if err != nil { // log.Fatal(err) // } // // // Set audit logging (required for HIPAA/GDPR) // authenticator.SetAuditLogger(func(event auth.AuditEvent) { // log.Printf("[AUDIT] %s: %s (success=%v)", // event.EventType, event.Username, event.Success) // }) // // // Create users // admin, _ := authenticator.CreateUser("admin", "SecurePass123!", // []auth.Role{auth.RoleAdmin}) // // viewer, _ := authenticator.CreateUser("alice", "AlicePass456!", // []auth.Role{auth.RoleViewer}) // // // Authenticate and get JWT token // tokenResp, user, err := authenticator.Authenticate( // "admin", "SecurePass123!", "192.168.1.1", "Mozilla/5.0") // if err != nil { // log.Fatal(err) // } // // fmt.Printf("Token: %s\n", tokenResp.AccessToken) // fmt.Printf("Type: %s\n", tokenResp.TokenType) // "Bearer" // // // Validate token // claims, err := authenticator.ValidateToken(tokenResp.AccessToken) // if err != nil { // log.Fatal(err) // } // // // Check permissions // if user.HasPermission(auth.PermWrite) { // fmt.Println("User can write") // } // // OAuth 2.0 Compatibility: // // The token endpoint follows RFC 6749 (OAuth 2.0) format: // // POST /auth/token // Content-Type: application/x-www-form-urlencoded // // grant_type=password&username=alice&password=secret // // Response: // // { // "access_token": "eyJhbGc...", // "token_type": "Bearer", // "expires_in": 3600 // omitted if never expires // } // // Compliance Features: // // GDPR Art.32 (Security): // - Password hashing (bcrypt with configurable cost) // - Token-based authentication (no session storage) // - Account lockout (prevents brute force) // - Audit logging (tracks all authentication events) // // HIPAA §164.312(a) (Access Control): // - Unique user identification (User.ID) // - Role-based permissions // - Account disable/enable // - Failed login tracking // // FISMA AC-2 (Account Management): // - User creation with roles // - Account lockout // - Audit trail // - Password policy enforcement // // Security Best Practices: // - bcrypt for password hashing (adjustable cost) // - HMAC-SHA256 for JWT signatures // - Constant-time string comparison (prevents timing attacks) // - Account lockout after N failed attempts // - No password in logs or responses // // ELI12 (Explain Like I'm 12): // // Think of this like your school's login system: // // 1. **Creating an account**: Like signing up for a school portal with username/password // 2. **Logging in**: Like entering your credentials at the library computer // 3. **JWT token**: Like a hall pass that proves you're allowed to be here // 4. **Roles**: Like student vs teacher vs admin - different people can do different things // 5. **Account lockout**: If you get your password wrong 5 times, you're locked out for 15 minutes // 6. **Audit log**: Like the principal keeping a record of who entered the building and when // // The system makes sure only the right people can access the right data! package auth import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/json" "errors" "fmt" "strings" "sync" "time" "golang.org/x/crypto/bcrypt" ) // Errors for authentication operations. var ( ErrUserNotFound = errors.New("user not found") ErrUserExists = errors.New("user already exists") ErrInvalidCredentials = errors.New("invalid credentials") ErrAccountLocked = errors.New("account locked due to failed login attempts") ErrPasswordTooShort = errors.New("password does not meet minimum length requirement") ErrInvalidToken = errors.New("invalid or expired token") ErrInsufficientRole = errors.New("insufficient role permissions") ErrSessionExpired = errors.New("session expired") ErrNoCredentials = errors.New("no credentials provided") ErrMissingSecret = errors.New("JWT secret not configured") ) // Role represents a user role with associated permissions. // Follows Mimir's role naming conventions. type Role string // Predefined roles following Neo4j/Mimir conventions. const ( RoleAdmin Role = "admin" // Full access including user management RoleEditor Role = "editor" // Read/write data RoleViewer Role = "viewer" // Read only (default) RoleNone Role = "none" // No access ) // Permission represents an action that can be performed. type Permission string // Permissions map to Neo4j-compatible actions. const ( PermRead Permission = "read" PermWrite Permission = "write" PermCreate Permission = "create" PermDelete Permission = "delete" PermAdmin Permission = "admin" PermSchema Permission = "schema" PermUserManage Permission = "user_manage" ) // RolePermissions maps roles to their allowed permissions. // Follows Mimir's RBAC model. var RolePermissions = map[Role][]Permission{ RoleAdmin: {PermRead, PermWrite, PermCreate, PermDelete, PermAdmin, PermSchema, PermUserManage}, RoleEditor: {PermRead, PermWrite, PermCreate, PermDelete}, RoleViewer: {PermRead}, RoleNone: {}, } // User represents an authenticated user account. // // Users have: // - Unique ID and username // - Password (hashed with bcrypt, never exposed) // - One or more roles (admin, editor, viewer, none) // - Timestamps for auditing // - Metadata for custom properties // // Security features: // - PasswordHash is never serialized (json:"-" tag) // - FailedLogins and LockedUntil track brute force attempts // - Disabled flag allows account suspension // // Example: // // user := &auth.User{ // ID: "usr-abc123", // Username: "alice", // Email: "alice@example.com", // Roles: []auth.Role{auth.RoleEditor}, // Metadata: map[string]string{ // "department": "Engineering", // "team": "Backend", // }, // } // // // Check permissions // if user.HasRole(auth.RoleAdmin) { // fmt.Println("User is admin") // } // // if user.HasPermission(auth.PermWrite) { // fmt.Println("User can write data") // } type User struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email,omitempty"` PasswordHash string `json:"-"` // Never serialize Roles []Role `json:"roles"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` LastLogin time.Time `json:"last_login,omitempty"` FailedLogins int `json:"-"` // Internal tracking LockedUntil time.Time `json:"-"` // Internal tracking Disabled bool `json:"disabled,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` } // HasRole checks if the user has a specific role. // // A user can have multiple roles. This method returns true if any of // the user's roles match the specified role. // // Example: // // user := &auth.User{ // Roles: []auth.Role{auth.RoleEditor, auth.RoleViewer}, // } // // if user.HasRole(auth.RoleAdmin) { // fmt.Println("Is admin") // Not printed // } // // if user.HasRole(auth.RoleEditor) { // fmt.Println("Is editor") // Printed // } func (u *User) HasRole(role Role) bool { for _, r := range u.Roles { if r == role { return true } } return false } // HasPermission checks if the user has a specific permission through any of their roles. // // Permissions are granted by roles. This method checks all the user's roles // and returns true if any role grants the requested permission. // // Permission hierarchy: // - RoleAdmin: read, write, create, delete, admin, schema, user_manage // - RoleEditor: read, write, create, delete // - RoleViewer: read // - RoleNone: (no permissions) // // Example: // // user := &auth.User{ // Roles: []auth.Role{auth.RoleEditor}, // } // // if user.HasPermission(auth.PermRead) { // fmt.Println("Can read") // Printed // } // // if user.HasPermission(auth.PermWrite) { // fmt.Println("Can write") // Printed // } // // if user.HasPermission(auth.PermUserManage) { // fmt.Println("Can manage users") // NOT printed (needs RoleAdmin) // } func (u *User) HasPermission(perm Permission) bool { for _, role := range u.Roles { perms, ok := RolePermissions[role] if !ok { continue } for _, p := range perms { if p == perm { return true } } } return false } // JWTClaims represents the claims in a JWT token. // Compatible with Mimir's JWT structure. type JWTClaims struct { Sub string `json:"sub"` // Subject (user ID) Email string `json:"email,omitempty"` // User email Username string `json:"username,omitempty"` // Username Roles []string `json:"roles"` // User roles Iat int64 `json:"iat"` // Issued at (Unix timestamp) Exp int64 `json:"exp,omitempty"` // Expiration (Unix timestamp, 0 = never) } // TokenResponse follows OAuth 2.0 RFC 6749 token response format. // Compatible with Mimir's /auth/token endpoint. type TokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` // Always "Bearer" ExpiresIn int64 `json:"expires_in,omitempty"` // Seconds until expiration (omitted if never expires) Scope string `json:"scope,omitempty"` } // AuthConfig holds authentication configuration. // Follows Mimir's configuration patterns. type AuthConfig struct { // Password policy MinPasswordLength int BcryptCost int // Token settings JWTSecret []byte TokenExpiry time.Duration // 0 = never expire (Mimir default) // Lockout settings MaxFailedLogins int LockoutDuration time.Duration // Feature flags SecurityEnabled bool } // DefaultAuthConfig returns default authentication configuration. // Matches Mimir's defaults for compatibility. func DefaultAuthConfig() AuthConfig { return AuthConfig{ MinPasswordLength: 8, BcryptCost: bcrypt.DefaultCost, TokenExpiry: 0, // Never expire by default (Mimir behavior) MaxFailedLogins: 5, LockoutDuration: 15 * time.Minute, SecurityEnabled: true, } } // Authenticator manages users and authentication. type Authenticator struct { mu sync.RWMutex users map[string]*User // keyed by username config AuthConfig // Audit callback for compliance logging auditLog func(event AuditEvent) } // AuditEvent represents an authentication-related event for compliance logging. // Required for GDPR Art.30, HIPAA §164.312(b), FISMA AU controls. type AuditEvent struct { Timestamp time.Time `json:"timestamp"` EventType string `json:"event_type"` Username string `json:"username,omitempty"` UserID string `json:"user_id,omitempty"` IPAddress string `json:"ip_address,omitempty"` UserAgent string `json:"user_agent,omitempty"` Success bool `json:"success"` Details string `json:"details,omitempty"` RequestPath string `json:"request_path,omitempty"` } // NewAuthenticator creates a new Authenticator with the given configuration. // // The authenticator manages user accounts, authentication, and authorization. // All operations are thread-safe. // // Configuration validation: // - If SecurityEnabled=true, JWTSecret is required (min 32 bytes recommended) // - MinPasswordLength defaults to 8 characters // - BcryptCost defaults to bcrypt.DefaultCost (10) // - MaxFailedLogins defaults to 5 // - LockoutDuration defaults to 15 minutes // // Example: // // // Production configuration // config := auth.AuthConfig{ // MinPasswordLength: 12, // BcryptCost: 12, // Higher = more secure but slower // JWTSecret: []byte("your-secret-key-at-least-32-chars-long"), // TokenExpiry: 24 * time.Hour, // Tokens expire after 24h // MaxFailedLogins: 5, // LockoutDuration: 30 * time.Minute, // SecurityEnabled: true, // } // // auth, err := auth.NewAuthenticator(config) // if err != nil { // log.Fatal(err) // } // // // Development configuration (no security) // devConfig := auth.AuthConfig{ // SecurityEnabled: false, // All requests allowed // } // auth = auth.NewAuthenticator(devConfig) // // Returns error if SecurityEnabled=true but JWTSecret is empty. // // Example 1 - Production HIPAA-Compliant Setup: // // config := auth.DefaultAuthConfig() // config.SecurityEnabled = true // config.JWTSecret = []byte(os.Getenv("JWT_SECRET")) // Min 32 bytes // config.MinPasswordLength = 12 // config.BcryptCost = 12 // High cost for security // config.MaxFailedAttempts = 5 // config.LockoutDuration = 30 * time.Minute // config.TokenExpiry = 4 * time.Hour // // authenticator, err := auth.NewAuthenticator(config) // if err != nil { // log.Fatal("Failed to initialize auth:", err) // } // // // Set up HIPAA-required audit logging // authenticator.SetAuditLogger(func(event auth.AuditEvent) { // auditLogger.Log(audit.Event{ // Type: audit.EventLogin, // UserID: event.UserID, // Username: event.Username, // IPAddress: event.IPAddress, // Success: event.Success, // Metadata: map[string]string{"reason": event.Message}, // }) // }) // // // Create admin user with strong password // admin, err := authenticator.CreateUser("admin", // "Str0ng!P@ssw0rd#2024", []auth.Role{auth.RoleAdmin}) // // Example 2 - Multi-Tenant SaaS with Short-Lived Tokens: // // config := auth.DefaultAuthConfig() // config.SecurityEnabled = true // config.JWTSecret = loadSecretFromVault() // config.TokenExpiry = 15 * time.Minute // Short-lived tokens // config.MaxFailedAttempts = 3 // Strict lockout // // authenticator, err := auth.NewAuthenticator(config) // if err != nil { // return nil, fmt.Errorf("auth init failed: %w", err) // } // // // Create per-tenant users // for _, tenant := range tenants { // user, err := authenticator.CreateUser( // fmt.Sprintf("%s-%s", tenant.ID, username), // generateStrongPassword(), // []auth.Role{auth.RoleEditor}, // ) // if err != nil { // log.Printf("Failed to create user for tenant %s: %v", tenant.ID, err) // } // } // // Example 3 - Development Mode (No Security): // // config := auth.DefaultAuthConfig() // config.SecurityEnabled = false // Bypass all auth checks // // authenticator, err := auth.NewAuthenticator(config) // if err != nil { // log.Fatal(err) // } // // // In dev mode, any token is accepted // // WARNING: Never use in production! // claims, _ := authenticator.ValidateToken("any-token") // Always succeeds // // Example 4 - API Integration with Rate Limiting: // // config := auth.DefaultAuthConfig() // config.JWTSecret = []byte("secure-secret-32-chars-minimum!!") // config.MaxFailedAttempts = 5 // // authenticator, err := auth.NewAuthenticator(config) // if err != nil { // return nil, err // } // // // Track failed attempts for rate limiting // authenticator.SetAuditLogger(func(event auth.AuditEvent) { // if event.EventType == "login_failed" { // rateLimiter.RecordFailure(event.IPAddress) // if rateLimiter.IsBlocked(event.IPAddress) { // firewall.BlockIP(event.IPAddress, 1*time.Hour) // } // } // }) // // ELI12: // // Think of NewAuthenticator like hiring a bouncer for a club: // // - The bouncer checks IDs (validates JWT tokens) // - They remember troublemakers (account lockout after failed attempts) // - They keep a guest list (user database) // - They write down who comes in (audit logging) // - They have different wristbands for VIP, regular, and just-looking (roles) // // SecurityEnabled = true means "bouncer is on duty" // SecurityEnabled = false means "anyone can walk in" (development only!) // // Real-world Analogy: // // JWT tokens are like temporary wristbands: // - When you log in, you get a wristband (token) // - Show the wristband to access stuff (no need to login again) // - Wristband expires after a few hours (token expiry) // - If you lose it, get a new one by logging in again // // Why JWT Instead of Sessions? // // Sessions: "I'll remember you" (server stores state) // JWT: "Here's a signed badge, prove yourself each time" (stateless) // // JWT Benefits: // - Works across multiple servers (no shared session storage) // - Scales better (no memory for sessions) // - Mobile-friendly (just store the token) // // Security Features: // - Passwords hashed with bcrypt (can't be reversed) // - Account lockout (prevents password guessing) // - Token expiry (stolen tokens eventually die) // - Audit logging (track who did what) // // Compliance: // - GDPR Art.32: Security measures ✓ // - HIPAA §164.312(a): Access control ✓ // - FISMA AC-2: Account management ✓ // - SOC 2 CC6.1: Logical access controls ✓ // // Performance: // - bcrypt hashing: ~50-200ms (intentionally slow for security) // - Token validation: ~1ms (just signature check) // - Thread-safe for concurrent authentication // // Thread Safety: // // All methods are thread-safe for concurrent use. func NewAuthenticator(config AuthConfig) (*Authenticator, error) { if config.SecurityEnabled && len(config.JWTSecret) == 0 { return nil, ErrMissingSecret } if config.BcryptCost == 0 { config.BcryptCost = bcrypt.DefaultCost } if config.MinPasswordLength == 0 { config.MinPasswordLength = 8 } if config.MaxFailedLogins == 0 { config.MaxFailedLogins = 5 } if config.LockoutDuration == 0 { config.LockoutDuration = 15 * time.Minute } return &Authenticator{ users: make(map[string]*User), config: config, }, nil } // SetAuditLogger sets the audit logging callback. func (a *Authenticator) SetAuditLogger(fn func(AuditEvent)) { a.mu.Lock() defer a.mu.Unlock() a.auditLog = fn } func (a *Authenticator) logAudit(event AuditEvent) { if a.auditLog != nil { event.Timestamp = time.Now() a.auditLog(event) } } // CreateUser creates a new user account with the given credentials and roles. // // The password is immediately hashed with bcrypt and never stored in plain text. // If no roles are specified, defaults to RoleViewer. // // Parameters: // - username: Unique username (must not already exist) // - password: Plain text password (will be hashed) // - roles: User roles, or empty slice for default (viewer) // // Returns: // - User object (without password hash) // - ErrUserExists if username already taken // - ErrPasswordTooShort if password doesn't meet minimum length // // Example: // // // Create admin user // admin, err := auth.CreateUser("admin", "SecurePassword123!", // []auth.Role{auth.RoleAdmin}) // if err != nil { // return err // } // // // Create regular user (defaults to viewer) // user, err := auth.CreateUser("alice", "AlicePass456!", nil) // // // Create user with multiple roles // editor, err := auth.CreateUser("bob", "BobPass789!", // []auth.Role{auth.RoleEditor, auth.RoleViewer}) // // Audit event: Logs "user_create" with success/failure. // // Compliance: HIPAA §164.308(a)(3)(ii)(A) - Unique user IDs func (a *Authenticator) CreateUser(username, password string, roles []Role) (*User, error) { a.mu.Lock() defer a.mu.Unlock() // Check if user exists if _, exists := a.users[username]; exists { a.logAudit(AuditEvent{ EventType: "user_create", Username: username, Success: false, Details: "user already exists", }) return nil, ErrUserExists } // Validate password if len(password) < a.config.MinPasswordLength { return nil, fmt.Errorf("%w: minimum %d characters required", ErrPasswordTooShort, a.config.MinPasswordLength) } // Hash password hash, err := bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost) if err != nil { return nil, fmt.Errorf("failed to hash password: %w", err) } // Default to viewer role if none specified if len(roles) == 0 { roles = []Role{RoleViewer} } // Create user now := time.Now() user := &User{ ID: generateID(), Username: username, Email: username + "@localhost", // Mimir pattern for dev users PasswordHash: string(hash), Roles: roles, CreatedAt: now, UpdatedAt: now, Metadata: make(map[string]string), } a.users[username] = user a.logAudit(AuditEvent{ EventType: "user_create", Username: username, UserID: user.ID, Success: true, Details: fmt.Sprintf("created with roles %v", roles), }) // Return copy without password hash return a.copyUserSafe(user), nil } // Authenticate verifies user credentials and returns a JWT token. // // This implements the OAuth 2.0 password grant flow (RFC 6749 Section 4.3). // On successful authentication: // 1. Password is verified with bcrypt // 2. Failed login counter is reset // 3. LastLogin timestamp is updated // 4. JWT token is generated // 5. Audit event is logged // // Security features: // - Account lockout after MaxFailedLogins attempts // - Disabled accounts cannot authenticate // - Timing attack resistant (doesn't reveal if user exists) // - Failed attempts are logged for security monitoring // // Parameters: // - username: User's username // - password: User's password (plain text) // - ipAddress: Client IP for audit logging // - userAgent: Client User-Agent for audit logging // // Returns: // - TokenResponse: OAuth 2.0 token response (access_token, token_type, expires_in) // - User: User object (without password) // - Error: ErrInvalidCredentials, ErrAccountLocked, or ErrUserNotFound // // Example: // // token, user, err := auth.Authenticate( // "alice", // "AlicePassword123!", // "192.168.1.100", // "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", // ) // if err != nil { // if errors.Is(err, auth.ErrAccountLocked) { // http.Error(w, "Account locked. Try again in 15 minutes.", 423) // return // } // http.Error(w, "Invalid credentials", 401) // return // } // // // Use token in Authorization header // fmt.Printf("Authorization: Bearer %s\n", token.AccessToken) // // // Set cookie for browser sessions // http.SetCookie(w, &http.Cookie{ // Name: "token", // Value: token.AccessToken, // HttpOnly: true, // Secure: true, // SameSite: http.SameSiteStrictMode, // }) // // Compliance: // - HIPAA §164.312(a)(2)(i): Unique User Identification // - HIPAA §164.312(d): Person or Entity Authentication // - GDPR Art.32: Technical measures to ensure security // - FISMA AC-7: Unsuccessful Login Attempts func (a *Authenticator) Authenticate(username, password, ipAddress, userAgent string) (*TokenResponse, *User, error) { a.mu.Lock() defer a.mu.Unlock() user, exists := a.users[username] if !exists { a.logAudit(AuditEvent{ EventType: "login", Username: username, IPAddress: ipAddress, UserAgent: userAgent, Success: false, Details: "user not found", }) return nil, nil, ErrInvalidCredentials // Don't reveal if user exists } // Check if account is locked if !user.LockedUntil.IsZero() && time.Now().Before(user.LockedUntil) { a.logAudit(AuditEvent{ EventType: "login", Username: username, UserID: user.ID, IPAddress: ipAddress, UserAgent: userAgent, Success: false, Details: "account locked", }) return nil, nil, ErrAccountLocked } // Check if account is disabled if user.Disabled { a.logAudit(AuditEvent{ EventType: "login", Username: username, UserID: user.ID, IPAddress: ipAddress, UserAgent: userAgent, Success: false, Details: "account disabled", }) return nil, nil, ErrInvalidCredentials } // Verify password if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { // Increment failed login counter user.FailedLogins++ if user.FailedLogins >= a.config.MaxFailedLogins { user.LockedUntil = time.Now().Add(a.config.LockoutDuration) } user.UpdatedAt = time.Now() a.logAudit(AuditEvent{ EventType: "login", Username: username, UserID: user.ID, IPAddress: ipAddress, UserAgent: userAgent, Success: false, Details: fmt.Sprintf("invalid password (attempt %d/%d)", user.FailedLogins, a.config.MaxFailedLogins), }) return nil, nil, ErrInvalidCredentials } // Reset failed login counter on success user.FailedLogins = 0 user.LockedUntil = time.Time{} user.LastLogin = time.Now() user.UpdatedAt = time.Now() // Generate JWT token token, err := a.generateJWT(user) if err != nil { return nil, nil, fmt.Errorf("failed to generate token: %w", err) } // Build OAuth 2.0 compliant response response := &TokenResponse{ AccessToken: token, TokenType: "Bearer", Scope: "default", } // Only include expires_in if token actually expires if a.config.TokenExpiry > 0 { response.ExpiresIn = int64(a.config.TokenExpiry.Seconds()) } a.logAudit(AuditEvent{ EventType: "login", Username: username, UserID: user.ID, IPAddress: ipAddress, UserAgent: userAgent, Success: true, Details: "token generated", }) return response, a.copyUserSafe(user), nil } // ValidateToken validates a JWT token and returns the claims. // // The token is verified using HMAC-SHA256. If valid, returns the decoded claims // including user ID, username, roles, and expiration. // // Validation checks: // 1. Token format (header.payload.signature) // 2. Signature verification (HMAC-SHA256) // 3. Expiration (if Exp > 0) // 4. Not before (if configured) // // If SecurityEnabled=false, returns dummy claims allowing all access. // // The token can include "Bearer " prefix (will be stripped). // // Parameters: // - token: JWT token string (with or without "Bearer " prefix) // // Returns: // - JWTClaims: Decoded claims with user info and roles // - Error: ErrInvalidToken, ErrSessionExpired, or ErrNoCredentials // // Example: // // // From Authorization header // authHeader := r.Header.Get("Authorization") // "Bearer eyJhbGc..." // claims, err := auth.ValidateToken(authHeader) // if err != nil { // http.Error(w, "Unauthorized", 401) // return // } // // fmt.Printf("User: %s\n", claims.Username) // fmt.Printf("Roles: %v\n", claims.Roles) // // // Check if user has admin role // hasAdmin := false // for _, role := range claims.Roles { // if role == string(auth.RoleAdmin) { // hasAdmin = true // break // } // } // // Security: // - Uses constant-time comparison to prevent timing attacks // - Validates expiration to prevent replay attacks // - Checks signature to prevent tampering func (a *Authenticator) ValidateToken(token string) (*JWTClaims, error) { if !a.config.SecurityEnabled { // Security disabled - return dummy claims return &JWTClaims{ Sub: "anonymous", Roles: []string{string(RoleAdmin)}, }, nil } if token == "" { return nil, ErrNoCredentials } // Strip "Bearer " prefix if present token = strings.TrimPrefix(token, "Bearer ") token = strings.TrimSpace(token) return a.verifyJWT(token) } // GetUserByID retrieves a user by their ID. func (a *Authenticator) GetUserByID(id string) (*User, error) { a.mu.RLock() defer a.mu.RUnlock() for _, user := range a.users { if user.ID == id { return a.copyUserSafe(user), nil } } return nil, ErrUserNotFound } // GetUser returns user info by username without sensitive data. func (a *Authenticator) GetUser(username string) (*User, error) { a.mu.RLock() defer a.mu.RUnlock() user, exists := a.users[username] if !exists { return nil, ErrUserNotFound } return a.copyUserSafe(user), nil } // ListUsers returns all users without sensitive data. func (a *Authenticator) ListUsers() []*User { a.mu.RLock() defer a.mu.RUnlock() users := make([]*User, 0, len(a.users)) for _, u := range a.users { users = append(users, a.copyUserSafe(u)) } return users } // ChangePassword updates a user's password. func (a *Authenticator) ChangePassword(username, oldPassword, newPassword string) error { a.mu.Lock() defer a.mu.Unlock() user, exists := a.users[username] if !exists { return ErrUserNotFound } // Verify old password if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldPassword)); err != nil { a.logAudit(AuditEvent{ EventType: "password_change", Username: username, UserID: user.ID, Success: false, Details: "old password incorrect", }) return ErrInvalidCredentials } // Validate new password if len(newPassword) < a.config.MinPasswordLength { return fmt.Errorf("%w: minimum %d characters required", ErrPasswordTooShort, a.config.MinPasswordLength) } // Hash new password hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), a.config.BcryptCost) if err != nil { return fmt.Errorf("failed to hash password: %w", err) } user.PasswordHash = string(hash) user.UpdatedAt = time.Now() a.logAudit(AuditEvent{ EventType: "password_change", Username: username, UserID: user.ID, Success: true, }) return nil } // UpdateRoles changes a user's roles. func (a *Authenticator) UpdateRoles(username string, newRoles []Role) error { a.mu.Lock() defer a.mu.Unlock() user, exists := a.users[username] if !exists { return ErrUserNotFound } oldRoles := user.Roles user.Roles = newRoles user.UpdatedAt = time.Now() a.logAudit(AuditEvent{ EventType: "role_change", Username: username, UserID: user.ID, Success: true, Details: fmt.Sprintf("roles changed from %v to %v", oldRoles, newRoles), }) return nil } // DisableUser disables a user account. func (a *Authenticator) DisableUser(username string) error { a.mu.Lock() defer a.mu.Unlock() user, exists := a.users[username] if !exists { return ErrUserNotFound } user.Disabled = true user.UpdatedAt = time.Now() a.logAudit(AuditEvent{ EventType: "user_disable", Username: username, UserID: user.ID, Success: true, }) return nil } // EnableUser re-enables a disabled user account. func (a *Authenticator) EnableUser(username string) error { a.mu.Lock() defer a.mu.Unlock() user, exists := a.users[username] if !exists { return ErrUserNotFound } user.Disabled = false user.FailedLogins = 0 user.LockedUntil = time.Time{} user.UpdatedAt = time.Now() a.logAudit(AuditEvent{ EventType: "user_enable", Username: username, UserID: user.ID, Success: true, }) return nil } // UnlockUser manually unlocks a locked user account. func (a *Authenticator) UnlockUser(username string) error { a.mu.Lock() defer a.mu.Unlock() user, exists := a.users[username] if !exists { return ErrUserNotFound } user.FailedLogins = 0 user.LockedUntil = time.Time{} user.UpdatedAt = time.Now() a.logAudit(AuditEvent{ EventType: "user_unlock", Username: username, UserID: user.ID, Success: true, }) return nil } // DeleteUser removes a user. func (a *Authenticator) DeleteUser(username string) error { a.mu.Lock() defer a.mu.Unlock() user, exists := a.users[username] if !exists { return ErrUserNotFound } userID := user.ID delete(a.users, username) a.logAudit(AuditEvent{ EventType: "user_delete", Username: username, UserID: userID, Success: true, }) return nil } // UserCount returns the number of registered users. func (a *Authenticator) UserCount() int { a.mu.RLock() defer a.mu.RUnlock() return len(a.users) } // IsSecurityEnabled returns whether security is enabled. func (a *Authenticator) IsSecurityEnabled() bool { return a.config.SecurityEnabled } // GenerateClusterToken creates a JWT token for cluster inter-node authentication. // This token can be used by cluster nodes to authenticate with each other // using the same shared JWT secret. // // The token contains: // - Sub: node identifier (e.g., "cluster-node-2") // - Username: same as nodeID for identification // - Roles: specified role (typically "admin" for cluster nodes) // - Iat: issued at timestamp // - Exp: expiration (if TokenExpiry is configured, otherwise never expires) // // Usage: // // 1. Generate the secret (must be same on all nodes): // openssl rand -base64 48 // // 2. Configure all cluster nodes with the same secret: // NORNICDB_JWT_SECRET=<your-generated-secret> // // 3. Generate a cluster token (from admin API or code): // token, _ := authenticator.GenerateClusterToken("node-2", auth.RoleAdmin) // // 4. Use the token to connect from other nodes: // driver = GraphDatabase.driver("bolt://node1:7687", // basic_auth("", token)) # Empty principal triggers bearer/JWT auth // // Example: // // // On cluster setup, generate tokens for each node // token, err := authenticator.GenerateClusterToken("cluster-node-west", auth.RoleAdmin) // if err != nil { // log.Fatal(err) // } // // // Distribute token securely to node-west // // Node-west uses it to connect: // driver, _ := neo4j.NewDriverWithContext( // "bolt://node-east:7687", // neo4j.BasicAuth("", token, ""), // Empty username = bearer auth // ) // // Security considerations: // - Tokens are signed with HMAC-SHA256 using the shared JWT secret // - All cluster nodes MUST use the same JWT secret // - Store the secret securely (e.g., HashiCorp Vault, K8s Secrets) // - Rotate secrets periodically by generating new tokens func (a *Authenticator) GenerateClusterToken(nodeID string, role Role) (string, error) { if len(a.config.JWTSecret) == 0 { return "", ErrMissingSecret } // Create a virtual user for the cluster node user := &User{ ID: "cluster-" + nodeID, Username: nodeID, Roles: []Role{role}, } token, err := a.generateJWT(user) if err != nil { return "", fmt.Errorf("failed to generate cluster token: %w", err) } a.logAudit(AuditEvent{ Timestamp: time.Now(), EventType: "cluster_token_generated", Username: nodeID, UserID: user.ID, Success: true, Details: fmt.Sprintf("cluster token generated for node %s with role %s", nodeID, role), }) return token, nil } // GenerateClusterTokenWithExpiry creates a JWT token with a custom expiration time. // Useful for short-lived tokens during initial cluster setup or testing. // // Parameters: // - nodeID: identifier for the cluster node (e.g., "node-2", "replica-west") // - role: role to assign (typically RoleAdmin for cluster nodes) // - expiry: token lifetime (e.g., 24*time.Hour, 0 for never expires) // // Example: // // // Generate a token that expires in 7 days // token, _ := auth.GenerateClusterTokenWithExpiry("node-2", auth.RoleAdmin, 7*24*time.Hour) func (a *Authenticator) GenerateClusterTokenWithExpiry(nodeID string, role Role, expiry time.Duration) (string, error) { if len(a.config.JWTSecret) == 0 { return "", ErrMissingSecret } now := time.Now().Unix() claims := JWTClaims{ Sub: "cluster-" + nodeID, Username: nodeID, Roles: []string{string(role)}, Iat: now, } // Set expiration if provided if expiry > 0 { claims.Exp = now + int64(expiry.Seconds()) } // Build JWT manually (header.payload.signature) header := map[string]string{ "alg": "HS256", "typ": "JWT", } headerJSON, _ := json.Marshal(header) claimsJSON, _ := json.Marshal(claims) headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) // Sign with HMAC-SHA256 message := headerB64 + "." + claimsB64 mac := hmac.New(sha256.New, a.config.JWTSecret) mac.Write([]byte(message)) signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) token := message + "." + signature a.logAudit(AuditEvent{ Timestamp: time.Now(), EventType: "cluster_token_generated", Username: nodeID, UserID: "cluster-" + nodeID, Success: true, Details: fmt.Sprintf("cluster token generated for node %s with role %s, expiry=%v", nodeID, role, expiry), }) return token, nil } // JWT Generation and Validation // generateJWT creates a JWT token for the user. // Uses HS256 algorithm matching Mimir's implementation. func (a *Authenticator) generateJWT(user *User) (string, error) { if len(a.config.JWTSecret) == 0 { return "", ErrMissingSecret } now := time.Now().Unix() // Convert roles to strings roles := make([]string, len(user.Roles)) for i, r := range user.Roles { roles[i] = string(r) } claims := JWTClaims{ Sub: user.ID, Email: user.Email, Username: user.Username, Roles: roles, Iat: now, } // Only set expiration if configured (0 = never expire, Mimir default) if a.config.TokenExpiry > 0 { claims.Exp = now + int64(a.config.TokenExpiry.Seconds()) } // Build JWT manually (header.payload.signature) header := map[string]string{ "alg": "HS256", "typ": "JWT", } headerJSON, _ := json.Marshal(header) claimsJSON, _ := json.Marshal(claims) headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) // Sign with HMAC-SHA256 message := headerB64 + "." + claimsB64 mac := hmac.New(sha256.New, a.config.JWTSecret) mac.Write([]byte(message)) signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) return message + "." + signature, nil } // verifyJWT validates a JWT token and returns the claims. func (a *Authenticator) verifyJWT(token string) (*JWTClaims, error) { if len(a.config.JWTSecret) == 0 { return nil, ErrMissingSecret } parts := strings.Split(token, ".") if len(parts) != 3 { return nil, ErrInvalidToken } // Verify signature message := parts[0] + "." + parts[1] mac := hmac.New(sha256.New, a.config.JWTSecret) mac.Write([]byte(message)) expectedSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) // Constant-time comparison to prevent timing attacks if !SecureCompare(parts[2], expectedSig) { return nil, ErrInvalidToken } // Decode claims claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, ErrInvalidToken } var claims JWTClaims if err := json.Unmarshal(claimsJSON, &claims); err != nil { return nil, ErrInvalidToken } // Check expiration (0 = never expires) if claims.Exp > 0 && time.Now().Unix() > claims.Exp { return nil, ErrSessionExpired } return &claims, nil } // copyUserSafe returns a copy of user without sensitive data. func (a *Authenticator) copyUserSafe(u *User) *User { roles := make([]Role, len(u.Roles)) copy(roles, u.Roles) metadata := make(map[string]string) for k, v := range u.Metadata { metadata[k] = v } return &User{ ID: u.ID, Username: u.Username, Email: u.Email, Roles: roles, CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, LastLogin: u.LastLogin, Disabled: u.Disabled, Metadata: metadata, } } // Helper functions func generateID() string { b := make([]byte, 16) _, _ = rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) } // SecureCompare performs a constant-time string comparison. // Prevents timing attacks on token validation. func SecureCompare(a, b string) bool { return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 } // ValidRole checks if a role is valid. func ValidRole(r Role) bool { switch r { case RoleAdmin, RoleEditor, RoleViewer, RoleNone: return true default: return false } } // RoleFromString converts a string to a Role. func RoleFromString(s string) (Role, error) { r := Role(s) if !ValidRole(r) { return RoleNone, fmt.Errorf("invalid role: %s", s) } return r, nil } // HasCredentials checks if a request has any form of authentication credentials. // Compatible with Mimir's hasAuthCredentials() helper. // Checks: Authorization header, X-API-Key header, cookie, query params. func HasCredentials(authHeader, apiKeyHeader, cookie, queryToken, queryAPIKey string) bool { return authHeader != "" || apiKeyHeader != "" || cookie != "" || queryToken != "" || queryAPIKey != "" } // ExtractToken extracts the token from various sources. // Priority: Authorization header > X-API-Key > Cookie > Query param // Compatible with Mimir's token extraction pattern. func ExtractToken(authHeader, apiKeyHeader, cookie, queryToken, queryAPIKey string) string { // 1. Authorization: Bearer header (OAuth 2.0 RFC 6750 standard) if authHeader != "" { return strings.TrimPrefix(authHeader, "Bearer ") } // 2. X-API-Key header (common alternative) if apiKeyHeader != "" { return apiKeyHeader } // 3. Cookie (browser sessions) if cookie != "" { return cookie } // 4. Query parameter (SSE connections that can't send headers) if queryToken != "" { return queryToken } if queryAPIKey != "" { return queryAPIKey } return "" }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/orneryd/Mimir'

If you have feedback or need assistance with the MCP directory API, please join our Discord server