From 23c7a65799605c7f3b157ad19c9907cfd078bee6 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Fri, 6 Jun 2025 13:50:37 -0700 Subject: [PATCH] Junie: security cleanups. --- README.org | 43 +++++--- api/auth.go | 240 ++++++++++++++++++++++++++++++++---------- api/server.go | 187 ++++++++++++++++++++++++++++++-- cmd/mcias/token.go | 11 +- data/user.go | 22 +--- docs/api.md | 67 +++++++++++- docs/api.org | 81 +++++++++++++- docs/deployment.md | 130 +++++++++++++++++++++++ docs/installation.org | 85 +++++++++++++-- docs/overview.org | 27 ++++- go.mod | 4 + go.sum | 9 ++ mcias.service | 25 +++++ 13 files changed, 812 insertions(+), 119 deletions(-) create mode 100644 docs/deployment.md create mode 100644 mcias.service diff --git a/README.org b/README.org index 0124957..e8b9d57 100644 --- a/README.org +++ b/README.org @@ -10,10 +10,10 @@ 1. User password authentication. 2. User token authentication. 3. Database credential authentication. + 4. TOTP (Time-based One-Time Password) authentication. Future work should consider adding support for: - 1. TOTP (Time-based One-Time Password) - 2. Policy management for fine-grained access control. + 1. Policy management for fine-grained access control. * Documentation @@ -29,12 +29,12 @@ 1. Initialize the database: #+begin_src bash - go run main.go init --db ./mcias.db + go run cmd/mcias/main.go init --db ./mcias.db #+end_src 2. Start the server: #+begin_src bash - go run main.go server --db ./mcias.db + go run cmd/mcias/main.go server --db ./mcias.db #+end_src 3. The server will listen on port 8080 by default. @@ -52,55 +52,72 @@ Start the MCIAS server: #+begin_src bash - go run main.go server [--db ] [--addr
] + go run cmd/mcias/main.go server [--db ] [--addr
] #+end_src ** Init Command Initialize the database: #+begin_src bash - go run main.go init [--db ] + go run cmd/mcias/main.go init [--db ] #+end_src ** User Commands Add a new user: #+begin_src bash - go run main.go user add --username --password + go run cmd/mcias/main.go user add --username --password #+end_src List all users: #+begin_src bash - go run main.go user list + go run cmd/mcias/main.go user list #+end_src ** Token Commands Add a new token for a user: #+begin_src bash - go run main.go token add --username [--duration ] + go run cmd/mcias/main.go token add --username [--duration ] #+end_src List all tokens: #+begin_src bash - go run main.go token list + go run cmd/mcias/main.go token list + #+end_src + +** TOTP Commands + + Enable TOTP for a user: + #+begin_src bash + go run cmd/mcias/main.go totp enable --username + #+end_src + + Add a TOTP token with QR code generation: + #+begin_src bash + go run cmd/mcias/main.go totp add --username --qr-output [--issuer ] + #+end_src + + Validate a TOTP code: + #+begin_src bash + go run cmd/mcias/main.go totp validate --username --code #+end_src ** Migrate Commands Apply database migrations: #+begin_src bash - go run main.go migrate up [--migrations ] [--steps ] + go run cmd/mcias/main.go migrate up [--migrations ] [--steps ] #+end_src Revert database migrations: #+begin_src bash - go run main.go migrate down [--migrations ] [--steps ] + go run cmd/mcias/main.go migrate down [--migrations ] [--steps ] #+end_src Show current migration version: #+begin_src bash - go run main.go migrate version [--migrations ] + go run cmd/mcias/main.go migrate version [--migrations ] #+end_src * API Overview diff --git a/api/auth.go b/api/auth.go index 0a547a9..a95f55c 100644 --- a/api/auth.go +++ b/api/auth.go @@ -1,14 +1,18 @@ package api import ( + "crypto/rand" "database/sql" + "encoding/hex" "encoding/json" "errors" + "fmt" "net/http" "strings" "time" "git.wntrmute.dev/kyle/mcias/data" + "github.com/Masterminds/squirrel" "github.com/oklog/ulid/v2" ) @@ -63,48 +67,51 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { return } - // Check password only first + // Check password only if !user.CheckPassword(&req.Login) { s.sendError(w, "Invalid username or password", http.StatusUnauthorized) return } - // If TOTP is enabled and a code was provided, verify it - if user.HasTOTP() { - if req.Login.TOTPCode == "" { - // TOTP is enabled but no code was provided - // Return a special response indicating TOTP is required - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - if err := json.NewEncoder(w).Encode(ErrorResponse{ - Error: "TOTP code required", - }); err != nil { - s.Logger.Printf("Error encoding response: %v", err) - } - return - } - - // Validate the TOTP code - valid, validErr := user.ValidateTOTPCode(req.Login.TOTPCode) - if validErr != nil || !valid { - s.sendError(w, "Invalid TOTP code", http.StatusUnauthorized) - return - } - } - + // Password is correct, create a token regardless of TOTP status token, expires, err := s.createToken(user.ID) if err != nil { s.Logger.Printf("Token creation error: %v", err) + // Log the security event + details := map[string]string{ + "reason": "Token creation error", + "error": err.Error(), + } + s.LogSecurityEvent(r, "login_attempt", user.ID, user.User, false, details) s.sendError(w, "Internal server error", http.StatusInternalServerError) return } + // If user has TOTP enabled, include this information in the response + totpEnabled := user.HasTOTP() + + // Log successful login + details := map[string]string{ + "token_expires": time.Unix(expires, 0).UTC().Format(time.RFC3339), + "totp_enabled": fmt.Sprintf("%t", totpEnabled), + } + s.LogSecurityEvent(r, "login_success", user.ID, user.User, true, details) + + // Include TOTP status in the response + response := struct { + TokenResponse + TOTPEnabled bool `json:"totp_enabled"` + }{ + TokenResponse: TokenResponse{ + Token: token, + Expires: expires, + }, + TOTPEnabled: totpEnabled, + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(TokenResponse{ - Token: token, - Expires: expires, - }); err != nil { + if err := json.NewEncoder(w).Encode(response); err != nil { s.Logger.Printf("Error encoding response: %v", err) } } @@ -147,20 +154,61 @@ func (s *Server) handleTokenLogin(w http.ResponseWriter, r *http.Request) { } func (s *Server) sendError(w http.ResponseWriter, message string, status int) { + // Log the detailed error message for debugging + s.Logger.Printf("Error response: %s (Status: %d)", message, status) + + // Create a generic error message based on status code + publicMessage := "An error occurred processing your request" + errorCode := "E" + string(status) + + // Customize public messages for common status codes + // but don't leak specific details about the error + switch status { + case http.StatusBadRequest: + publicMessage = "Invalid request format" + case http.StatusUnauthorized: + publicMessage = "Authentication required" + case http.StatusForbidden: + publicMessage = "Insufficient permissions" + case http.StatusNotFound: + publicMessage = "Resource not found" + case http.StatusTooManyRequests: + publicMessage = "Rate limit exceeded" + case http.StatusInternalServerError: + publicMessage = "Internal server error" + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) - if err := json.NewEncoder(w).Encode(ErrorResponse{Error: message}); err != nil { + + response := struct { + Error string `json:"error"` + ErrorCode string `json:"error_code"` + }{ + Error: publicMessage, + ErrorCode: errorCode, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { s.Logger.Printf("Error encoding error response: %v", err) } } func (s *Server) getUserByUsername(username string) (*data.User, error) { - query := `SELECT id, created, user, password, salt, totp_secret FROM users WHERE user = ?` - row := s.DB.QueryRow(query, username) + // Use squirrel to build the query safely + query, args, err := squirrel.Select("id", "created", "user", "password", "salt", "totp_secret"). + From("users"). + Where(squirrel.Eq{"user": username}). + ToSql() + if err != nil { + return nil, err + } + + row := s.DB.QueryRow(query, args...) user := &data.User{} var totpSecret sql.NullString - err := row.Scan(&user.ID, &user.Created, &user.User, &user.Password, &user.Salt, &totpSecret) + err = row.Scan(&user.ID, &user.Created, &user.User, &user.Password, &user.Salt, &totpSecret) if err != nil { return nil, err } @@ -170,12 +218,17 @@ func (s *Server) getUserByUsername(username string) (*data.User, error) { user.TOTPSecret = totpSecret.String } - rolesQuery := ` - SELECT r.role FROM roles r - JOIN user_roles ur ON r.id = ur.rid - WHERE ur.uid = ? - ` - rows, err := s.DB.Query(rolesQuery, user.ID) + // Use squirrel to build the roles query safely + rolesQuery, rolesArgs, err := squirrel.Select("r.role"). + From("roles r"). + Join("user_roles ur ON r.id = ur.rid"). + Where(squirrel.Eq{"ur.uid": user.ID}). + ToSql() + if err != nil { + return nil, err + } + + rows, err := s.DB.Query(rolesQuery, rolesArgs...) if err != nil { return nil, err } @@ -195,12 +248,28 @@ func (s *Server) getUserByUsername(username string) (*data.User, error) { } func (s *Server) createToken(userID string) (string, int64, error) { - token := ulid.Make().String() + // Generate 16 bytes of random data + tokenBytes := make([]byte, 16) + if _, err := rand.Read(tokenBytes); err != nil { + return "", 0, err + } + + // Hex encode the random bytes to get a 32-character string + token := hex.EncodeToString(tokenBytes) expires := time.Now().Add(24 * time.Hour).Unix() - query := `INSERT INTO tokens (id, uid, token, expires) VALUES (?, ?, ?, ?)` tokenID := ulid.Make().String() - _, err := s.DB.Exec(query, tokenID, userID, token, expires) + + // Use squirrel to build the insert query safely + query, args, err := squirrel.Insert("tokens"). + Columns("id", "uid", "token", "expires"). + Values(tokenID, userID, token, expires). + ToSql() + if err != nil { + return "", 0, err + } + + _, err = s.DB.Exec(query, args...) if err != nil { return "", 0, err } @@ -209,14 +278,22 @@ func (s *Server) createToken(userID string) (string, int64, error) { } func (s *Server) verifyToken(username, token string) (string, error) { - query := ` - SELECT t.uid, t.expires FROM tokens t - JOIN users u ON t.uid = u.id - WHERE u.user = ? AND t.token = ? - ` + // Use squirrel to build the query safely + query, args, err := squirrel.Select("t.uid", "t.expires"). + From("tokens t"). + Join("users u ON t.uid = u.id"). + Where(squirrel.And{ + squirrel.Eq{"u.user": username}, + squirrel.Eq{"t.token": token}, + }). + ToSql() + if err != nil { + return "", err + } + var userID string var expires int64 - err := s.DB.QueryRow(query, username, token).Scan(&userID, &expires) + err = s.DB.QueryRow(query, args...).Scan(&userID, &expires) if err != nil { return "", err } @@ -230,21 +307,38 @@ func (s *Server) verifyToken(username, token string) (string, error) { func (s *Server) renewToken(username, token string) (int64, error) { // First, verify the token exists and get the token ID - query := ` - SELECT t.id FROM tokens t - JOIN users u ON t.uid = u.id - WHERE u.user = ? AND t.token = ? - ` + // Use squirrel to build the query safely + query, args, err := squirrel.Select("t.id"). + From("tokens t"). + Join("users u ON t.uid = u.id"). + Where(squirrel.And{ + squirrel.Eq{"u.user": username}, + squirrel.Eq{"t.token": token}, + }). + ToSql() + if err != nil { + return 0, err + } + var tokenID string - err := s.DB.QueryRow(query, username, token).Scan(&tokenID) + err = s.DB.QueryRow(query, args...).Scan(&tokenID) if err != nil { return 0, err } // Update the token's expiry time expires := time.Now().Add(24 * time.Hour).Unix() - updateQuery := `UPDATE tokens SET expires = ? WHERE id = ?` - _, err = s.DB.Exec(updateQuery, expires, tokenID) + + // Use squirrel to build the update query safely + updateQuery, updateArgs, err := squirrel.Update("tokens"). + Set("expires", expires). + Where(squirrel.Eq{"id": tokenID}). + ToSql() + if err != nil { + return 0, err + } + + _, err = s.DB.Exec(updateQuery, updateArgs...) if err != nil { return 0, err } @@ -277,6 +371,11 @@ func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request) { // Check if TOTP is enabled for the user if !user.HasTOTP() { + // Log the security event + details := map[string]string{ + "reason": "TOTP not enabled for user", + } + s.LogSecurityEvent(r, "totp_verification_attempt", user.ID, user.User, false, details) s.sendError(w, "TOTP not enabled for user", http.StatusBadRequest) return } @@ -284,6 +383,11 @@ func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request) { // Validate the TOTP code valid, validErr := user.ValidateTOTPCode(req.TOTPCode) if validErr != nil || !valid { + // Log the security event + details := map[string]string{ + "reason": "Invalid TOTP code", + } + s.LogSecurityEvent(r, "totp_verification_attempt", user.ID, user.User, false, details) s.sendError(w, "Invalid TOTP code", http.StatusUnauthorized) return } @@ -292,10 +396,22 @@ func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request) { token, expires, err := s.createToken(user.ID) if err != nil { s.Logger.Printf("Token creation error: %v", err) + // Log the security event + details := map[string]string{ + "reason": "Token creation error", + "error": err.Error(), + } + s.LogSecurityEvent(r, "totp_verification_attempt", user.ID, user.User, false, details) s.sendError(w, "Internal server error", http.StatusInternalServerError) return } + // Log successful TOTP verification + details := map[string]string{ + "token_expires": time.Unix(expires, 0).UTC().Format(time.RFC3339), + } + s.LogSecurityEvent(r, "totp_verification_success", user.ID, user.User, true, details) + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(TokenResponse{ @@ -356,8 +472,18 @@ func (s *Server) handleDatabaseCredentials(w http.ResponseWriter, r *http.Reques } // Retrieve database credentials - query := `SELECT id, host, port, name, user, password FROM database LIMIT 1` - row := s.DB.QueryRow(query) + // Use squirrel to build the query safely + query, args, err := squirrel.Select("id", "host", "port", "name", "user", "password"). + From("database"). + Limit(1). + ToSql() + if err != nil { + s.Logger.Printf("Query building error: %v", err) + s.sendError(w, "Internal server error", http.StatusInternalServerError) + return + } + + row := s.DB.QueryRow(query, args...) var id string var creds DatabaseCredentials diff --git a/api/server.go b/api/server.go index ec8f916..e8b35ed 100644 --- a/api/server.go +++ b/api/server.go @@ -2,28 +2,134 @@ package api import ( "database/sql" + "encoding/json" "log" + "net" "net/http" + "sync" + "time" "git.wntrmute.dev/kyle/mcias/data" _ "github.com/mattn/go-sqlite3" + "golang.org/x/time/rate" ) +// client represents a client with rate limiting information +type client struct { + limiter *rate.Limiter + lastSeen time.Time +} + +// SecurityEvent represents a security-related event +type SecurityEvent struct { + Timestamp string `json:"timestamp"` + EventType string `json:"event_type"` + UserID string `json:"user_id,omitempty"` + Username string `json:"username,omitempty"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + RequestURI string `json:"request_uri"` + Success bool `json:"success"` + Details map[string]string `json:"details,omitempty"` +} + +// RateLimiter manages rate limiting for clients +type RateLimiter struct { + clients map[string]*client + mu sync.Mutex + // Requests per second, burst size + rate rate.Limit + burst int +} + +// NewRateLimiter creates a new rate limiter +func NewRateLimiter(r rate.Limit, b int) *RateLimiter { + return &RateLimiter{ + clients: make(map[string]*client), + rate: r, + burst: b, + } +} + +// GetLimiter returns a rate limiter for a client +func (rl *RateLimiter) GetLimiter(ip string) *rate.Limiter { + rl.mu.Lock() + defer rl.mu.Unlock() + + c, exists := rl.clients[ip] + if !exists { + c = &client{ + limiter: rate.NewLimiter(rl.rate, rl.burst), + lastSeen: time.Now(), + } + rl.clients[ip] = c + } else { + c.lastSeen = time.Now() + } + + return c.limiter +} + +// CleanupClients removes old clients +func (rl *RateLimiter) CleanupClients() { + rl.mu.Lock() + defer rl.mu.Unlock() + + for ip, client := range rl.clients { + if time.Since(client.lastSeen) > 1*time.Hour { + delete(rl.clients, ip) + } + } +} + type Server struct { - DB *sql.DB - Router *http.ServeMux - Logger *log.Logger - Auth *data.AuthorizationService + DB *sql.DB + Router *http.ServeMux + Logger *log.Logger + Auth *data.AuthorizationService + RateLimiter *RateLimiter +} + +// getClientIP extracts the client IP address from the request +func getClientIP(r *http.Request) string { + // Check for X-Forwarded-For header first (for clients behind proxy) + ip := r.Header.Get("X-Forwarded-For") + if ip != "" { + // X-Forwarded-For can contain multiple IPs, use the first one + ips := net.ParseIP(ip) + if ips != nil { + return ips.String() + } + } + + // Fall back to RemoteAddr + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return ip } func NewServer(db *sql.DB, logger *log.Logger) *Server { + // Create a rate limiter with 10 requests per second and burst of 30 + rateLimiter := NewRateLimiter(10, 30) + s := &Server{ - DB: db, - Router: http.NewServeMux(), - Logger: logger, - Auth: data.NewAuthorizationService(db), + DB: db, + Router: http.NewServeMux(), + Logger: logger, + Auth: data.NewAuthorizationService(db), + RateLimiter: rateLimiter, } + // Start a goroutine to clean up old clients + go func() { + for { + time.Sleep(1 * time.Hour) + rateLimiter.CleanupClients() + } + }() + s.registerRoutes() return s @@ -36,11 +142,76 @@ func (s *Server) registerRoutes() { s.Router.HandleFunc("GET /v1/database/credentials", s.handleDatabaseCredentials) } +// sendRateLimitExceeded sends a rate limit exceeded response +func (s *Server) sendRateLimitExceeded(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + + response := map[string]string{ + "error": "Rate limit exceeded. Please try again later.", + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + s.Logger.Printf("Error encoding rate limit response: %v", err) + } +} + func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Get client IP + clientIP := getClientIP(r) + + // Get rate limiter for this client + limiter := s.RateLimiter.GetLimiter(clientIP) + + // Check if rate limit is exceeded + if !limiter.Allow() { + s.Logger.Printf("Rate limit exceeded for IP: %s, URI: %s", clientIP, r.RequestURI) + s.sendRateLimitExceeded(w) + return + } + + // Apply stricter rate limiting for authentication endpoints + if r.URL.Path == "/v1/login/password" || r.URL.Path == "/v1/login/token" || r.URL.Path == "/v1/login/totp" { + // Use a separate limiter with lower rate for auth endpoints + authLimiter := rate.NewLimiter(rate.Limit(1), 5) // 1 request per second, burst of 5 + if !authLimiter.Allow() { + s.Logger.Printf("Auth rate limit exceeded for IP: %s, URI: %s", clientIP, r.RequestURI) + s.sendRateLimitExceeded(w) + return + } + } + + // Proceed with the request s.Router.ServeHTTP(w, r) } +// LogSecurityEvent logs a security-related event +func (s *Server) LogSecurityEvent(r *http.Request, eventType string, userID, username string, success bool, details map[string]string) { + event := SecurityEvent{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + EventType: eventType, + UserID: userID, + Username: username, + IPAddress: getClientIP(r), + UserAgent: r.UserAgent(), + RequestURI: r.RequestURI, + Success: success, + Details: details, + } + + // Convert to JSON for structured logging + eventJSON, err := json.Marshal(event) + if err != nil { + s.Logger.Printf("Error marshaling security event: %v", err) + return + } + + // Log the security event + s.Logger.Printf("SECURITY_EVENT: %s", eventJSON) +} + func (s *Server) Start(addr string) error { s.Logger.Printf("Starting server on %s", addr) + s.Logger.Printf("Note: This server is designed to run behind a reverse proxy that handles TLS") return http.ListenAndServe(addr, s) } diff --git a/cmd/mcias/token.go b/cmd/mcias/token.go index 0d33ef7..9dff007 100644 --- a/cmd/mcias/token.go +++ b/cmd/mcias/token.go @@ -1,7 +1,9 @@ package main import ( + "crypto/rand" "database/sql" + "encoding/hex" "fmt" "log" "os" @@ -76,7 +78,14 @@ func addToken() { logger.Fatalf("Failed to check if user exists: %v", err) } - token := ulid.Make().String() + // Generate 16 bytes of random data + tokenBytes := make([]byte, 16) + if _, err := rand.Read(tokenBytes); err != nil { + logger.Fatalf("Failed to generate random token: %v", err) + } + + // Hex encode the random bytes to get a 32-character string + token := hex.EncodeToString(tokenBytes) expires := time.Now().Add(time.Duration(tokenDuration) * time.Hour).Unix() diff --git a/data/user.go b/data/user.go index 3e8f438..2a12f48 100644 --- a/data/user.go +++ b/data/user.go @@ -81,25 +81,11 @@ func (u *User) CheckPassword(login *Login) bool { return subtle.ConstantTimeCompare(derived, u.Password) == validCompareResult } +// Check is a legacy method that now only checks the password +// It's kept for backward compatibility but is equivalent to CheckPassword func (u *User) Check(login *Login) bool { - // First check username and password - if !u.CheckPassword(login) { - return false - } - - // If TOTP is enabled for the user, validate the TOTP code - if u.TOTPSecret != emptyString && login.TOTPCode != emptyString { - // Use the ValidateTOTPCode method to validate the TOTP code - valid, validErr := u.ValidateTOTPCode(login.TOTPCode) - if validErr != nil || !valid { - return false - } - } else if u.TOTPSecret != emptyString && login.TOTPCode == emptyString { - // TOTP is enabled but no code was provided - return false - } - - return true + // Only check username and password, TOTP verification is now a separate flow + return u.CheckPassword(login) } func (u *User) Register(login *Login) error { diff --git a/docs/api.md b/docs/api.md index 86d6e74..f8e8ead 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12,7 +12,7 @@ MCIAS (Metacircular Identity and Access System) provides identity and authentica **Endpoint**: `POST /v1/login/password` -**Description**: Authenticates a user using username and password credentials. +**Description**: Authenticates a user using username and password credentials. This endpoint does not require TOTP verification, even if TOTP is enabled for the user. **Request Format**: ```json @@ -34,13 +34,15 @@ MCIAS (Metacircular Identity and Access System) provides identity and authentica ```json { "token": "authentication_token", - "expires": 1621234567 + "expires": 1621234567, + "totp_enabled": true } ``` **Response Fields**: - `token`: Authentication token to be used for subsequent requests - `expires`: Unix timestamp when the token expires +- `totp_enabled`: Boolean indicating whether TOTP is enabled for the user **Error Responses**: - 400 Bad Request: Invalid request format or missing required fields @@ -86,6 +88,43 @@ MCIAS (Metacircular Identity and Access System) provides identity and authentica - 401 Unauthorized: Invalid or expired token - 500 Internal Server Error: Server-side error +#### TOTP Verification + +**Endpoint**: `POST /v1/login/totp` + +**Description**: Verifies a TOTP code for a user and issues a token upon successful verification. This endpoint is used as a separate flow from password authentication. + +**Request Format**: +```json +{ + "version": "v1", + "username": "username", + "totp_code": "123456" +} +``` + +**Required Fields**: +- `version`: Must be "v1" +- `username`: Username +- `totp_code`: Time-based One-Time Password code + +**Response Format** (Success - 200 OK): +```json +{ + "token": "authentication_token", + "expires": 1621234567 +} +``` + +**Response Fields**: +- `token`: Authentication token to be used for subsequent requests +- `expires`: Unix timestamp when the token expires + +**Error Responses**: +- 400 Bad Request: Invalid request format, missing required fields, or TOTP not enabled for user +- 401 Unauthorized: Invalid TOTP code +- 500 Internal Server Error: Server-side error + ### Database Credentials **Endpoint**: `/v1/credentials/database` (Not yet implemented) @@ -112,14 +151,34 @@ Common HTTP status codes: ## Authentication Flow +### Password Authentication Flow + 1. **Initial Authentication**: - Client sends username and password to `/v1/login/password` - Server validates credentials and returns a token + - The response includes a `totp_enabled` flag indicating whether TOTP is enabled for the user 2. **Subsequent Requests**: - Client uses the token for authentication by sending it to `/v1/login/token` - Server validates the token and issues a new token -3. **Token Expiration**: +### TOTP Authentication Flow + +1. **TOTP Verification** (separate from password authentication): + - Client sends username and TOTP code to `/v1/login/totp` + - Server validates the TOTP code and returns a token if valid + +### Token Management + +1. **Token Expiration**: - Tokens expire after 24 hours - - Clients should request a new token before expiration \ No newline at end of file + - Clients should request a new token before expiration + +### Multi-Factor Authentication + +For users with TOTP enabled, a complete multi-factor authentication flow would involve: +1. Authenticate with username and password using `/v1/login/password` +2. Check the `totp_enabled` flag in the response +3. If TOTP is enabled, prompt the user for their TOTP code +4. Verify the TOTP code using `/v1/login/totp` to get a second token +5. Use either token for subsequent requests (both are valid) diff --git a/docs/api.org b/docs/api.org index 1e57307..546cad5 100644 --- a/docs/api.org +++ b/docs/api.org @@ -91,10 +91,74 @@ MCIAS (Metacircular Identity and Access System) provides identity and authentica *** Database Credentials -*Endpoint*: =/v1/credentials/database= (Not yet implemented) +*Endpoint*: =GET /v1/database/credentials= *Description*: Retrieves database credentials for authorized users. +*Request Parameters*: +- =username=: Username of the authenticated user + +*Headers*: +- =Authorization=: Bearer token for authentication + +*Response Format* (Success - 200 OK): +#+begin_src json +{ + "host": "database_host", + "port": 5432, + "name": "database_name", + "user": "database_user", + "password": "database_password" +} +#+end_src + +*Error Responses*: +- 400 Bad Request: Invalid request format or missing required parameters +- 401 Unauthorized: Invalid or expired token +- 403 Forbidden: Insufficient permissions to access database credentials +- 500 Internal Server Error: Server-side error + +*** TOTP Authentication + +*Endpoint*: =POST /v1/login/totp= + +*Description*: Authenticates a user using TOTP (Time-based One-Time Password) in addition to username and password. + +*Request Format*: +#+begin_src json +{ + "version": "v1", + "login": { + "user": "username", + "password": "secret_password", + "totp": "123456" + } +} +#+end_src + +*Required Fields*: +- =version=: Must be "v1" +- =login.user=: Username +- =login.password=: User's password +- =login.totp=: 6-digit TOTP code from authenticator app + +*Response Format* (Success - 200 OK): +#+begin_src json +{ + "token": "authentication_token", + "expires": 1621234567 +} +#+end_src + +*Response Fields*: +- =token=: Authentication token to be used for subsequent requests +- =expires=: Unix timestamp when the token expires + +*Error Responses*: +- 400 Bad Request: Invalid request format or missing required fields +- 401 Unauthorized: Invalid username, password, or TOTP code +- 500 Internal Server Error: Server-side error + ** Error Handling All error responses follow a standard format: @@ -118,11 +182,20 @@ Common HTTP status codes: 1. *Initial Authentication*: - Client sends username and password to =/v1/login/password= - Server validates credentials and returns a token + - If TOTP is enabled for the user, authentication with password alone will fail -2. *Subsequent Requests*: +2. *TOTP Authentication*: + - For users with TOTP enabled, client sends username, password, and TOTP code to =/v1/login/totp= + - Server validates all credentials and returns a token + +3. *Subsequent Requests*: - Client uses the token for authentication by sending it to =/v1/login/token= - Server validates the token and issues a new token -3. *Token Expiration*: +4. *Token Expiration*: - Tokens expire after 24 hours - - Clients should request a new token before expiration \ No newline at end of file + - Clients should request a new token before expiration + +5. *Database Credential Access*: + - Client sends a GET request to =/v1/database/credentials= with the token in the Authorization header + - Server validates the token and returns the database credentials if the user has sufficient permissions diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..0e5f4de --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,130 @@ +# MCIAS Deployment Guide + +## Overview + +This document provides guidance on deploying the Metacircular Identity and Access System (MCIAS) in a production environment. MCIAS is designed to be deployed behind a reverse proxy that handles TLS termination and security headers. + +## Prerequisites + +- Linux server with systemd +- Nginx or another reverse proxy for TLS termination +- User and group `mcias` created on the system +- Go 1.23 or later for building from source + +## Installation + +1. Build the MCIAS binary: + ```bash + go build -o mcias ./cmd/mcias + ``` + +2. Create the installation directory: + ```bash + sudo mkdir -p /opt/mcias + ``` + +3. Copy the binary and service file: + ```bash + sudo cp mcias /opt/mcias/ + sudo cp mcias.service /etc/systemd/system/ + ``` + +4. Set appropriate permissions: + ```bash + sudo chown -R mcias:mcias /opt/mcias + sudo chmod 755 /opt/mcias/mcias + ``` + +5. Initialize the database: + ```bash + sudo -u mcias /opt/mcias/mcias init --db /opt/mcias/mcias.db + ``` + +6. Enable and start the service: + ```bash + sudo systemctl daemon-reload + sudo systemctl enable mcias + sudo systemctl start mcias + ``` + +## Reverse Proxy Configuration + +MCIAS is designed to run behind a reverse proxy that handles TLS termination and security headers. Below is an example Nginx configuration: + +```nginx +server { + listen 443 ssl http2; + server_name mcias.example.com; + + # SSL configuration + ssl_certificate /etc/letsencrypt/live/mcias.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/mcias.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305'; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_stapling on; + ssl_stapling_verify on; + + # Security headers + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Content-Security-Policy "default-src 'self';" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Proxy to MCIAS + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +# Redirect HTTP to HTTPS +server { + listen 80; + server_name mcias.example.com; + return 301 https://$host$request_uri; +} +``` + +## Security Considerations + +1. **TLS Configuration**: The reverse proxy should use modern TLS protocols (TLSv1.2 and TLSv1.3) and strong cipher suites. + +2. **Security Headers**: The reverse proxy should add security headers to all responses, as shown in the Nginx configuration example. + +3. **Firewall Configuration**: Configure your firewall to only allow connections to the MCIAS server from the reverse proxy. + +4. **Regular Updates**: Keep the MCIAS software, operating system, and reverse proxy up to date with security patches. + +5. **Monitoring**: Set up monitoring for the MCIAS service and review logs regularly for security events. + +## Logging + +MCIAS logs to the systemd journal by default. You can view logs using: + +```bash +sudo journalctl -u mcias +``` + +Security events are logged with the prefix `SECURITY_EVENT:` and are in JSON format for easy parsing. + +## Backup and Recovery + +Regularly back up the MCIAS database file (/opt/mcias/mcias.db) to ensure you can recover in case of data loss. + +Example backup script: + +```bash +#!/bin/bash +BACKUP_DIR="/var/backups/mcias" +TIMESTAMP=$(date +%Y%m%d%H%M%S) +mkdir -p $BACKUP_DIR +sqlite3 /opt/mcias/mcias.db ".backup $BACKUP_DIR/mcias_$TIMESTAMP.db" +``` \ No newline at end of file diff --git a/docs/installation.org b/docs/installation.org index 15013e1..fa724cf 100644 --- a/docs/installation.org +++ b/docs/installation.org @@ -31,7 +31,7 @@ go mod download MCIAS uses SQLite for data storage. To initialize the database: #+begin_src bash -go run main.go -init -db ./mcias.db +go run cmd/mcias/main.go init --db ./mcias.db #+end_src This command creates a new SQLite database file and initializes it with the schema defined in =schema.sql=. @@ -43,37 +43,76 @@ This command creates a new SQLite database file and initializes it with the sche To start the MCIAS server with default settings: #+begin_src bash -go run main.go -db ./mcias.db +go run cmd/mcias/main.go server --db ./mcias.db #+end_src By default, the server listens on port 8080. *** Configuration Options -MCIAS supports the following command-line options: +MCIAS supports the following command-line options for the server: -- =-db =: Path to the SQLite database file (default: =mcias.db=) -- =-addr
=: Address to listen on (default: =:8080=) -- =-init=: Initialize the database and exit +- =--db =: Path to the SQLite database file (default: =mcias.db=) +- =--addr
=: Address to listen on (default: =:8080=) Example with custom port: #+begin_src bash -go run main.go -db ./mcias.db -addr :9000 +go run cmd/mcias/main.go server --db ./mcias.db --addr :9000 +#+end_src + +** Managing Users and Authentication + +*** Adding a New User + +To add a new user to the system: + +#+begin_src bash +go run cmd/mcias/main.go user add --username --password +#+end_src + +*** Managing TOTP Authentication + +To enable TOTP for a user: + +#+begin_src bash +go run cmd/mcias/main.go totp enable --username +#+end_src + +This will generate a TOTP secret for the user and display it. The user should save this secret in their authenticator app. + +To add a TOTP token with QR code generation: + +#+begin_src bash +go run cmd/mcias/main.go totp add --username --qr-output +#+end_src + +To validate a TOTP code: + +#+begin_src bash +go run cmd/mcias/main.go totp validate --username --code #+end_src ** Building from Source -To build a binary: +To build the server binary: #+begin_src bash +cd cmd/mcias go build -o mcias #+end_src Then run the binary: #+begin_src bash -./mcias -db ./mcias.db +./mcias server --db ./mcias.db +#+end_src + +To build the client binary: + +#+begin_src bash +cd cmd/mcias-client +go build -o mcias-client #+end_src ** Development @@ -128,6 +167,32 @@ curl -X POST http://localhost:8080/v1/login/token \ }' #+end_src +*** Authentication with TOTP + +To authenticate a user with a password and TOTP code: + +#+begin_src bash +curl -X POST http://localhost:8080/v1/login/totp \ + -H "Content-Type: application/json" \ + -d '{ + "version": "v1", + "login": { + "user": "username", + "password": "password", + "totp": "123456" + } + }' +#+end_src + +*** Retrieving Database Credentials + +To retrieve database credentials: + +#+begin_src bash +curl -X GET "http://localhost:8080/v1/database/credentials?username=username" \ + -H "Authorization: Bearer your_token" +#+end_src + ** Troubleshooting *** Common Issues @@ -170,4 +235,4 @@ go run main.go -db ./mcias.db > mcias.log 2>&1 3. *User Management*: - Implement strong password policies - Regularly rotate tokens - - Monitor for suspicious authentication attempts \ No newline at end of file + - Monitor for suspicious authentication attempts diff --git a/docs/overview.org b/docs/overview.org index 8684468..853f76e 100644 --- a/docs/overview.org +++ b/docs/overview.org @@ -11,10 +11,10 @@ The system currently provides: 1. User password authentication 2. User token authentication 3. Database credential authentication +4. TOTP (Time-based One-Time Password) authentication Future planned features include: -1. TOTP (Time-based One-Time Password) authentication -2. Policy management for fine-grained access control +1. Policy management for fine-grained access control ** System Architecture @@ -57,7 +57,8 @@ CREATE TABLE users ( created integer, user text not null, password blob not null, - salt blob not null + salt blob not null, + totp_secret text ); #+end_src @@ -108,6 +109,24 @@ CREATE TABLE user_roles ( ); #+end_src +*** Permissions Tables +#+begin_src sql +CREATE TABLE permissions ( + id TEXT PRIMARY KEY, + resource TEXT NOT NULL, + action TEXT NOT NULL, + description TEXT +); + +CREATE TABLE role_permissions ( + id TEXT PRIMARY KEY, + rid TEXT NOT NULL, + pid TEXT NOT NULL, + FOREIGN KEY(rid) REFERENCES roles(id), + FOREIGN KEY(pid) REFERENCES permissions(id) +); +#+end_src + ** Security Considerations MCIAS implements several security best practices: @@ -130,4 +149,4 @@ MCIAS implements several security best practices: 4. *Database Security* - Parameterized queries to prevent SQL injection - - Foreign key constraints to maintain data integrity \ No newline at end of file + - Foreign key constraints to maintain data integrity diff --git a/go.mod b/go.mod index 818de97..5d066fa 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,15 @@ go 1.23.8 require github.com/gokyle/twofactor v1.0.1 require ( + github.com/Masterminds/squirrel v1.5.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/golang-migrate/migrate/v4 v4.18.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/mattn/go-sqlite3 v1.14.28 // indirect github.com/oklog/ulid/v2 v2.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect @@ -27,6 +30,7 @@ require ( golang.org/x/crypto v0.38.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.25.0 // indirect + golang.org/x/time v0.12.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 5da045f..f212265 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -16,6 +18,10 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= @@ -40,6 +46,7 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= @@ -55,6 +62,8 @@ golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mcias.service b/mcias.service new file mode 100644 index 0000000..5ea6153 --- /dev/null +++ b/mcias.service @@ -0,0 +1,25 @@ +[Unit] +Description=Metacircular Identity and Access System +After=network.target + +[Service] +Type=simple +User=mcias +Group=mcias +WorkingDirectory=/opt/mcias +ExecStart=/opt/mcias/mcias server --db /opt/mcias/mcias.db +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=mcias + +# Security settings +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +NoNewPrivileges=true +ReadWritePaths=/opt/mcias + +[Install] +WantedBy=multi-user.target \ No newline at end of file