diff --git a/.golangci.yml b/.golangci.yml deleted file mode 100644 index 961165a..0000000 --- a/.golangci.yml +++ /dev/null @@ -1,232 +0,0 @@ -# MCIAS golangci-lint configuration -# This is a strict configuration focused on security and code quality -# -# Usage: -# - Run all linters: golangci-lint run -# - Run specific linter: golangci-lint run --disable-all --enable=gosec -# - Run with specific configuration: golangci-lint run -c .golangci.yml -# -# This configuration enables a comprehensive set of linters to ensure: -# 1. Security best practices (gosec, errcheck, etc.) -# 2. Code quality and maintainability (gofmt, goimports, etc.) -# 3. Performance considerations (prealloc, etc.) -# 4. Error handling correctness (errcheck, errorlint, etc.) -# -# For more information about golangci-lint, visit: https://golangci-lint.run/ - -run: - # Timeout for running linters, default is 1m - timeout: 5m - # Include test files - tests: true - # Go version to use for analysis - go: "1.22" - -# Output configuration -output: - # Format: colored-line-number|line-number|json|tab|checkstyle|code-climate|junit-xml|github-actions - formats: - - format: colored-line-number - # Print lines of code with issue - print-issued-lines: true - # Print linter name in the end of issue text - print-linter-name: true - -# All available linters -linters: - enable-all: false - disable-all: true - enable: - # Default linters - - errcheck # Detect unchecked errors - - gosimple # Simplify code - - govet # Examine Go source code and reports suspicious constructs - - ineffassign # Detect ineffectual assignments - - staticcheck # Go static analysis - - typecheck # Like the front-end of a Go compiler - - unused # Check for unused constants, variables, functions and types - - # Additional linters for security and code quality - - asciicheck # Check that your code does not contain non-ASCII identifiers - - bodyclose # Checks whether HTTP response body is closed successfully - - cyclop # Check function and package cyclomatic complexity - - dupl # Code clone detection - - durationcheck # Check for two durations multiplied together - - errorlint # Find code that will cause problems with the error wrapping scheme - - exhaustive # Check exhaustiveness of enum switch statements - - copyloopvar # Check for pointers to enclosing loop variables (replaces exportloopref) - - forbidigo # Forbids identifiers - - funlen # Tool for detection of long functions - - goconst # Find repeated strings that could be replaced by a constant - - gocritic # Provides diagnostics that check for bugs, performance and style issues - - gocyclo # Calculate cyclomatic complexities of functions - - godot # Check if comments end in a period - - gofmt # Check whether code was gofmt-ed - - goimports # Check imports are formatted according to goimports - - mnd # Detect magic numbers (replaces gomnd) - - gosec # Inspects source code for security problems - - misspell # Find commonly misspelled English words - - nakedret # Find naked returns - - nestif # Reports deeply nested if statements - - noctx # Find sending HTTP request without context.Context - - nolintlint # Reports ill-formed or insufficient nolint directives - - prealloc # Find slice declarations that could potentially be preallocated - - predeclared # Find code that shadows predeclared identifiers - - revive # Fast, configurable, extensible, flexible, and beautiful linter for Go - - sqlclosecheck # Checks that sql.Rows and sql.Stmt are closed - - stylecheck # Stylecheck is a replacement for golint - - thelper # Detect golang test helpers without t.Helper() call - - tparallel # Detects inappropriate usage of t.Parallel() - - unconvert # Remove unnecessary type conversions - - unparam # Find unused function parameters - - wastedassign # Find wasted assignment statements - - whitespace # Tool for detection of leading and trailing whitespace - -# Linter settings -linters-settings: - errcheck: - # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. - check-type-assertions: true - # Report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`. - check-blank: true - - funlen: - # Checks the number of lines in a function. - lines: 100 - # Checks the number of statements in a function. - statements: 50 - - gocyclo: - # Minimal code complexity to report. - min-complexity: 15 - - cyclop: - # The maximal code complexity to report. - max-complexity: 15 - # The maximal average package complexity. - package-average: 10.0 - - mnd: - # List of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description. - checks: - - argument - - case - - condition - - operation - - return - - assign - - forbidigo: - # Forbid the following identifiers - forbid: - - ^print$ - - ^println$ - # Exclude godoc examples from forbidigo checks - exclude_godoc_examples: true - - govet: - # Enable all analyzers. - enable-all: true - # Disable specific analyzers. - disable: - - fieldalignment # Too strict for now - # Settings per analyzer. - settings: - shadow: - # Whether to be strict about shadowing; can be noisy. - strict: true - - revive: - # Maximum number of open files at the same time. - max-open-files: 2048 - # Minimal confidence for issues, default is 0.8. - confidence: 0.8 - # Enable all available rules. - enable-all-rules: true - # Disabled rules. - rules: - - name: line-length-limit - disabled: true - - staticcheck: - # https://staticcheck.io/docs/options#checks - checks: ["all"] - - stylecheck: - # https://staticcheck.io/docs/options#checks - checks: ["all"] - - gosec: - # To select a subset of rules to run. - # Available rules: https://github.com/securego/gosec#available-rules - includes: - - G101 # Look for hard coded credentials - - G102 # Bind to all interfaces - - G103 # Audit the use of unsafe block - - G104 # Audit errors not checked - - G106 # Audit the use of ssh.InsecureIgnoreHostKey - - G107 # Url provided to HTTP request as taint input - - G108 # Profiling endpoint automatically exposed - - G109 # Potential Integer overflow made by strconv.Atoi result conversion to int16/32 - - G110 # Potential DoS vulnerability via decompression bomb - - G111 # Potential directory traversal - - G112 # Potential slowloris attack - - G113 # Usage of Rat.SetString in math/big - - G114 # Use of net/http serve function that has no support for setting timeouts - - G201 # SQL query construction using format string - - G202 # SQL query construction using string concatenation - - G203 # Use of unescaped data in HTML templates - - G204 # Audit use of command execution - - G301 # Poor file permissions used when creating a directory - - G302 # Poor file permissions used when creation of file - - G303 # Creating tempfile using a predictable path - - G304 # File path provided as taint input - - G305 # File traversal when extracting zip/tar archive - - G306 # Poor file permissions used when writing to a file - - G307 # Deferring a method which returns an error - - G401 # Detect the usage of weak crypto algorithms - - G402 # Look for bad TLS connection settings - - G403 # Ensure minimum RSA key length of 2048 bits - - G404 # Insecure random number source (rand) - - G501 # Import blocklist: crypto/md5 - - G502 # Import blocklist: crypto/des - - G503 # Import blocklist: crypto/rc4 - - G504 # Import blocklist: net/http/cgi - - G505 # Import blocklist: crypto/sha1 - - G601 # Implicit memory aliasing of items from a range statement - - G602 # Slice access out of bounds - -# Issues configuration -issues: - # Maximum count of issues with the same text. - max-same-issues: 3 - - # Maximum issues count per one linter. - max-issues-per-linter: 50 - - # Fix found issues (if it's supported by the linter). - fix: false - - # Exclude some directories from linting - exclude-dirs: - - vendor - - # Exclude some files from linting - exclude-files: - - ".*\\.pb\\.go$" - - ".*\\.gen\\.go$" - - # Exclude specific linting rules for specific files - exclude-rules: - # Exclude some linters from running on tests files. - - path: _test\.go - linters: - - gocyclo - - errcheck - - dupl - - gosec - - funlen - - thelper # Many test helpers don't need t.Helper() - - noctx # Context is often not needed in tests - - cyclop # Test functions can be more complex - - nestif # Test functions often have nested if statements diff --git a/.junie/guidelines.md b/.junie/guidelines.md index f0357bb..664f72a 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -3,6 +3,49 @@ MCIAS is the metacircular identity and access system, providing identity and authentication across the metacircular projects. +The Metacircular Identity and Access System (MCIAS) provides standard +tools for user and access management among metacircular and wntrmute +systems. + +Build an authentication service written in Go that I can use with other +apps that I write. + +## Specifications + +- Applications should be able to either do an interactive login, using a + username/password (and potentially a TOTP), or present a token. +- Applications should be able to renew the token, which would nominally + expire after some period (defaulting to maybe 30 days). +- There are two kinds of users: human and system accounts. +- System accounts can only present a token; they have a single token + associated with that account at a time. +- User accounts have roles associated with them. +- Users with the admin role can issue tokens for any app, or users with + the role named the same as a service account can issue tokens for that + service account. +- Admin users can also revoke tokens for a service account. +- Service accounts (and users with the a role named the same as the + service account) can also retrieve Postgres database credentials for + the service account. + +## Technical details + +- User passwords will be stored using scrypt. +- The service account tokens and user/password authentication can be + used to obtain a JWT, if that is appropriate. +- All authentication events should be logged. +- This service should use the packages contained in + git.wntrmute.dev/kyle/goutils for logging etc. + +## Interfaces + +- The primary interface will be an REST API over HTTPS. TLS security is + critical for this. +- There should be two command line tools associated with MCIAS: + - mciassrv is the authentication server. + - mciasctl is the tool for admins to create and manage accounts, issue + or revoke tokens, and manage postgres database credentials. + ## Structure + The system should be runnable through a cobra CLI tool, with diff --git a/api/auth.go b/api/auth.go deleted file mode 100644 index 6e7ba41..0000000 --- a/api/auth.go +++ /dev/null @@ -1,556 +0,0 @@ -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" -) - -type LoginRequest struct { - Version string `json:"version"` - Login data.Login `json:"login"` -} - -type TokenResponse struct { - Token string `json:"token"` - Expires int64 `json:"expires"` -} - -type TOTPVerifyRequest struct { - Version string `json:"version"` - Username string `json:"username"` - TOTPCode string `json:"totp_code"` -} - -type ErrorResponse struct { - Error string `json:"error"` -} - -type DatabaseCredentials struct { - Host string `json:"host"` - Port int `json:"port"` - Name string `json:"name"` - User string `json:"user"` - Password string `json:"password"` -} - -func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { - var req LoginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - s.sendError(w, "Invalid request format", http.StatusBadRequest) - return - } - - if req.Version != "v1" || req.Login.User == "" || req.Login.Password == "" { - s.sendError(w, "Invalid login request", http.StatusBadRequest) - return - } - - user, err := s.getUserByUsername(req.Login.User) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - s.sendError(w, "Invalid username or password", http.StatusUnauthorized) - } else { - s.Logger.Printf("Database error: %v", err) - s.sendError(w, "Internal server error", http.StatusInternalServerError) - } - return - } - - // Check password only - if !user.CheckPassword(&req.Login) { - s.sendError(w, "Invalid username or password", 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(response); err != nil { - s.Logger.Printf("Error encoding response: %v", err) - } -} - -func (s *Server) handleTokenLogin(w http.ResponseWriter, r *http.Request) { - var req LoginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - s.sendError(w, "Invalid request format", http.StatusBadRequest) - return - } - - if req.Version != "v1" || req.Login.User == "" || req.Login.Token == "" { - s.sendError(w, "Invalid login request", http.StatusBadRequest) - return - } - - // Verify the token is valid - _, err := s.verifyToken(req.Login.User, req.Login.Token) - if err != nil { - s.sendError(w, "Invalid or expired token", http.StatusUnauthorized) - return - } - - // Renew the existing token instead of creating a new one - expires, err := s.renewToken(req.Login.User, req.Login.Token) - if err != nil { - s.Logger.Printf("Token renewal error: %v", err) - s.sendError(w, "Internal server error", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(TokenResponse{ - Token: req.Login.Token, - Expires: expires, - }); err != nil { - s.Logger.Printf("Error encoding response: %v", err) - } -} - -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 := fmt.Sprintf("E%000d", 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) - - 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) { - // 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) - if err != nil { - return nil, err - } - - // Set TOTP secret if it exists - if totpSecret.Valid { - user.TOTPSecret = totpSecret.String - } - - // 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 - } - defer rows.Close() - - var roles []string - for rows.Next() { - var role string - if err := rows.Scan(&role); err != nil { - return nil, err - } - roles = append(roles, role) - } - user.Roles = roles - - return user, nil -} - -func (s *Server) createToken(userID string) (string, int64, error) { - // 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() - tokenID := ulid.Make().String() - - // 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 - } - - return token, expires, nil -} - -func (s *Server) verifyToken(username, token string) (string, error) { - // 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, args...).Scan(&userID, &expires) - if err != nil { - return "", err - } - - if expires > 0 && expires < time.Now().Unix() { - return "", errors.New("token expired") - } - - return userID, nil -} - -func (s *Server) renewToken(username, token string) (int64, error) { - // First, verify the token exists and get the token ID - // 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, args...).Scan(&tokenID) - if err != nil { - return 0, err - } - - // Update the token's expiry time - expires := time.Now().Add(24 * time.Hour).Unix() - - // 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 - } - - return expires, nil -} - -func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request) { - var req TOTPVerifyRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - s.sendError(w, "Invalid request format", http.StatusBadRequest) - return - } - - if req.Version != "v1" || req.Username == "" || req.TOTPCode == "" { - s.sendError(w, "Invalid TOTP verification request", http.StatusBadRequest) - return - } - - user, err := s.getUserByUsername(req.Username) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - s.sendError(w, "User not found", http.StatusUnauthorized) - } else { - s.Logger.Printf("Database error: %v", err) - s.sendError(w, "Internal server error", http.StatusInternalServerError) - } - return - } - - // 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 - } - - // 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 - } - - // TOTP code is valid, create a token - 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{ - Token: token, - Expires: expires, - }); err != nil { - s.Logger.Printf("Error encoding response: %v", err) - } -} - -func (s *Server) handleDatabaseCredentials(w http.ResponseWriter, r *http.Request) { - // Extract authorization header - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - s.sendError(w, "Authorization header required", http.StatusUnauthorized) - return - } - - // Check if it's a Bearer token - parts := strings.Split(authHeader, " ") - if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { - s.sendError(w, "Invalid authorization format", http.StatusUnauthorized) - return - } - - token := parts[1] - username := r.URL.Query().Get("username") - if username == "" { - s.sendError(w, "Username parameter required", http.StatusBadRequest) - return - } - - // Verify the token - userID, err := s.verifyToken(username, token) - if err != nil { - s.sendError(w, "Invalid or expired token", http.StatusUnauthorized) - return - } - - // Check if user has permission to read database credentials - user, err := s.getUserByUsername(username) - if err != nil { - s.Logger.Printf("Database error: %v", err) - s.sendError(w, "Internal server error", http.StatusInternalServerError) - return - } - - hasPermission, err := user.HasPermission(s.Auth, "database_credentials", "read") - if err != nil { - s.Logger.Printf("Permission check error: %v", err) - s.sendError(w, "Internal server error", http.StatusInternalServerError) - return - } - - if !hasPermission { - s.sendError(w, "Insufficient permissions: requires database_credentials:read permission", http.StatusForbidden) - return - } - - // Get database ID from query parameter if provided - databaseID := r.URL.Query().Get("database_id") - - // Build the query to retrieve databases the user has access to - queryBuilder := squirrel.Select("d.id", "d.host", "d.port", "d.name", "d.user", "d.password"). - From("database d") - - // If the user is an admin, they can see all databases - isAdmin := false - for _, role := range user.Roles { - if role == "admin" { - isAdmin = true - break - } - } - - if !isAdmin { - // Non-admin users can only see databases they're explicitly associated with - queryBuilder = queryBuilder.Join("database_users du ON d.id = du.db_id"). - Where(squirrel.Eq{"du.uid": userID}) - } - - // If a specific database ID was requested, filter by that - if databaseID != "" { - queryBuilder = queryBuilder.Where(squirrel.Eq{"d.id": databaseID}) - } - - query, args, err := queryBuilder.ToSql() - if err != nil { - s.Logger.Printf("Query building error: %v", err) - s.sendError(w, "Internal server error", http.StatusInternalServerError) - return - } - - rows, err := s.DB.Query(query, args...) - if err != nil { - s.Logger.Printf("Database error: %v", err) - s.sendError(w, "Internal server error", http.StatusInternalServerError) - return - } - defer rows.Close() - - var databases []DatabaseCredentials - for rows.Next() { - var id string - var creds DatabaseCredentials - err = rows.Scan(&id, &creds.Host, &creds.Port, &creds.Name, &creds.User, &creds.Password) - if err != nil { - s.Logger.Printf("Row scanning error: %v", err) - s.sendError(w, "Internal server error", http.StatusInternalServerError) - return - } - databases = append(databases, creds) - } - - if err = rows.Err(); err != nil { - s.Logger.Printf("Rows iteration error: %v", err) - s.sendError(w, "Internal server error", http.StatusInternalServerError) - return - } - - if len(databases) == 0 { - s.sendError(w, "No database credentials found", http.StatusNotFound) - return - } - - // If a specific database was requested, return just that one - if databaseID != "" && len(databases) == 1 { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(databases[0]); err != nil { - s.Logger.Printf("Error encoding response: %v", err) - } - return - } - - // Otherwise return all accessible databases - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(databases); err != nil { - s.Logger.Printf("Error encoding response: %v", err) - } -} diff --git a/api/auth_test.go b/api/auth_test.go deleted file mode 100644 index 366c29d..0000000 --- a/api/auth_test.go +++ /dev/null @@ -1,568 +0,0 @@ -package api - -import ( - "bytes" - "database/sql" - "encoding/json" - "log" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "git.wntrmute.dev/kyle/mcias/data" - _ "github.com/mattn/go-sqlite3" -) - -func setupTestDB(t *testing.T) *sql.DB { - db, err := sql.Open("sqlite3", ":memory:") - if err != nil { - t.Fatalf("Failed to open test database: %v", err) - } - - schema, err := os.ReadFile("../database/schema.sql") - if err != nil { - t.Fatalf("Failed to read schema: %v", err) - } - - if _, err := db.Exec(string(schema)); err != nil { - t.Fatalf("Failed to initialize test database: %v", err) - } - - return db -} - -func createTestUser(t *testing.T, db *sql.DB) *data.User { - user := &data.User{} - login := &data.Login{ - User: "testuser", - Password: "testpassword", - } - - if err := user.Register(login); err != nil { - t.Fatalf("Failed to register test user: %v", err) - } - - query := `INSERT INTO users (id, created, user, password, salt, totp_secret) VALUES (?, ?, ?, ?, ?, ?)` - _, err := db.Exec(query, user.ID, user.Created, user.User, user.Password, user.Salt, nil) - if err != nil { - t.Fatalf("Failed to insert test user: %v", err) - } - - return user -} - -func TestPasswordLogin(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - user := createTestUser(t, db) - - logger := log.New(os.Stdout, "TEST: ", log.LstdFlags) - server := NewServer(db, logger) - loginReq := LoginRequest{ - Version: "v1", - Login: data.Login{ - User: user.User, - Password: "testpassword", - }, - } - - body, err := json.Marshal(loginReq) - if err != nil { - t.Fatalf("Failed to marshal request: %v", err) - } - - req := httptest.NewRequest("POST", "/v1/login/password", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - - recorder := httptest.NewRecorder() - server.handlePasswordLogin(recorder, req) - - if recorder.Code != http.StatusOK { - t.Errorf("Expected status code %d, got %d", http.StatusOK, recorder.Code) - } - - var response TokenResponse - if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { - t.Fatalf("Failed to decode response: %v", err) - } - - if response.Token == "" { - t.Error("Expected token in response, got empty string") - } - - now := time.Now().Unix() - if response.Expires <= now { - t.Errorf("Expected token expiration in the future, got %d (now: %d)", response.Expires, now) - } -} - -func TestTokenLogin(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - user := createTestUser(t, db) - - logger := log.New(os.Stdout, "TEST: ", log.LstdFlags) - server := NewServer(db, logger) - - token := "testtoken123456" - initialExpires := time.Now().Add(1 * time.Hour).Unix() // Set initial expiry to 1 hour from now - - tokenID := "token123" - query := `INSERT INTO tokens (id, uid, token, expires) VALUES (?, ?, ?, ?)` - _, err := db.Exec(query, tokenID, user.ID, token, initialExpires) - if err != nil { - t.Fatalf("Failed to insert test token: %v", err) - } - - loginReq := LoginRequest{ - Version: "v1", - Login: data.Login{ - User: user.User, - Token: token, - }, - } - - body, err := json.Marshal(loginReq) - if err != nil { - t.Fatalf("Failed to marshal request: %v", err) - } - - req := httptest.NewRequest("POST", "/v1/login/token", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - - recorder := httptest.NewRecorder() - server.handleTokenLogin(recorder, req) - - if recorder.Code != http.StatusOK { - t.Errorf("Expected status code %d, got %d", http.StatusOK, recorder.Code) - } - - var response TokenResponse - if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { - t.Fatalf("Failed to decode response: %v", err) - } - - // Verify that the same token is returned - if response.Token != token { - t.Errorf("Expected the same token '%s', got '%s'", token, response.Token) - } - - // Verify that the expiry has been renewed (should be later than the initial expiry) - if response.Expires <= initialExpires { - t.Errorf("Expected renewed expiry to be later than initial expiry %d, got %d", initialExpires, response.Expires) - } - - now := time.Now().Unix() - if response.Expires <= now { - t.Errorf("Expected token expiration in the future, got %d (now: %d)", response.Expires, now) - } - - // Verify that the token in the database has been updated - var dbExpires int64 - err = db.QueryRow("SELECT expires FROM tokens WHERE id = ?", tokenID).Scan(&dbExpires) - if err != nil { - t.Fatalf("Failed to query token from database: %v", err) - } - - if dbExpires != response.Expires { - t.Errorf("Database expiry %d does not match response expiry %d", dbExpires, response.Expires) - } -} - -func TestInvalidPasswordLogin(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - user := createTestUser(t, db) - - logger := log.New(os.Stdout, "TEST: ", log.LstdFlags) - server := NewServer(db, logger) - - loginReq := LoginRequest{ - Version: "v1", - Login: data.Login{ - User: user.User, - Password: "wrongpassword", - }, - } - - body, err := json.Marshal(loginReq) - if err != nil { - t.Fatalf("Failed to marshal request: %v", err) - } - - req := httptest.NewRequest("POST", "/v1/login/password", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - - recorder := httptest.NewRecorder() - server.handlePasswordLogin(recorder, req) - - if recorder.Code != http.StatusUnauthorized { - t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, recorder.Code) - } -} - -func TestInvalidTokenLogin(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - user := createTestUser(t, db) - - logger := log.New(os.Stdout, "TEST: ", log.LstdFlags) - server := NewServer(db, logger) - - loginReq := LoginRequest{ - Version: "v1", - Login: data.Login{ - User: user.User, - Token: "invalidtoken", - }, - } - - body, err := json.Marshal(loginReq) - if err != nil { - t.Fatalf("Failed to marshal request: %v", err) - } - - req := httptest.NewRequest("POST", "/v1/login/token", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - - recorder := httptest.NewRecorder() - server.handleTokenLogin(recorder, req) - - if recorder.Code != http.StatusUnauthorized { - t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, recorder.Code) - } -} - -func TestTOTPLogin(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - // Create a user with TOTP enabled - user := createTestUser(t, db) - - // Generate a TOTP secret for the user - secret, err := user.GenerateTOTPSecret() - if err != nil { - t.Fatalf("Failed to generate TOTP secret: %v", err) - } - - // Update the user in the database with the TOTP secret - _, err = db.Exec("UPDATE users SET totp_secret = ? WHERE id = ?", secret, user.ID) - if err != nil { - t.Fatalf("Failed to update user with TOTP secret: %v", err) - } - - // Generate a valid TOTP code - valid, err := user.ValidateTOTPCode("123456") - if err != nil { - t.Fatalf("Failed to validate TOTP code: %v", err) - } - t.Logf("TOTP validation result: %v", valid) - - // Try to login without a TOTP code - logger := log.New(os.Stdout, "TEST: ", log.LstdFlags) - server := NewServer(db, logger) - - loginReq := LoginRequest{ - Version: "v1", - Login: data.Login{ - User: user.User, - Password: "testpassword", - }, - } - - body, err := json.Marshal(loginReq) - if err != nil { - t.Fatalf("Failed to marshal request: %v", err) - } - - req := httptest.NewRequest("POST", "/v1/login/password", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - - recorder := httptest.NewRecorder() - server.handlePasswordLogin(recorder, req) - - // Should get an unauthorized response with a message about TOTP being required - if recorder.Code != http.StatusUnauthorized { - t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, recorder.Code) - } - - var errorResp ErrorResponse - if err := json.NewDecoder(recorder.Body).Decode(&errorResp); err != nil { - t.Fatalf("Failed to decode error response: %v", err) - } - - if errorResp.Error != "TOTP code required" { - t.Errorf("Expected error message 'TOTP code required', got '%s'", errorResp.Error) - } - - // Now try to login with a TOTP code - // Note: In a real test, we would generate a valid TOTP code, but for this test - // we'll just use a hardcoded value since we can't easily generate a valid code - // without the actual TOTP algorithm implementation. - loginReq.Login.TOTPCode = "123456" - - body, err = json.Marshal(loginReq) - if err != nil { - t.Fatalf("Failed to marshal request: %v", err) - } - - req = httptest.NewRequest("POST", "/v1/login/password", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - - recorder = httptest.NewRecorder() - server.handlePasswordLogin(recorder, req) - - // The test will likely fail here since we're using a hardcoded TOTP code, - // but the test structure is correct. In a real environment with a proper - // TOTP implementation, this would work. - t.Logf("Login with TOTP code status: %d", recorder.Code) -} - -func createTestAdminUser(t *testing.T, db *sql.DB) *data.User { - user := createTestUser(t, db) - - // Use the existing admin role from schema.sql - var roleID string - err := db.QueryRow("SELECT id FROM roles WHERE role = 'admin'").Scan(&roleID) - if err != nil { - t.Fatalf("Failed to get admin role ID: %v", err) - } - - // Assign admin role to user - userRoleID := "ur123" - _, err = db.Exec("INSERT INTO user_roles (id, uid, rid) VALUES (?, ?, ?)", userRoleID, user.ID, roleID) - if err != nil { - t.Fatalf("Failed to assign admin role to user: %v", err) - } - - user.Roles = []string{"admin"} - return user -} - -func createTestDBOperatorUser(t *testing.T, db *sql.DB) *data.User { - // Create a new user - user := &data.User{} - login := &data.Login{ - User: "dboperator", - Password: "testpassword", - } - - if err := user.Register(login); err != nil { - t.Fatalf("Failed to register test user: %v", err) - } - - query := `INSERT INTO users (id, created, user, password, salt) VALUES (?, ?, ?, ?, ?)` - _, err := db.Exec(query, user.ID, user.Created, user.User, user.Password, user.Salt) - if err != nil { - t.Fatalf("Failed to insert test user: %v", err) - } - - // Use the existing db_operator role from schema.sql - var roleID string - err = db.QueryRow("SELECT id FROM roles WHERE role = 'db_operator'").Scan(&roleID) - if err != nil { - t.Fatalf("Failed to get db_operator role ID: %v", err) - } - - // Assign db_operator role to user - userRoleID := "ur456" - _, err = db.Exec("INSERT INTO user_roles (id, uid, rid) VALUES (?, ?, ?)", userRoleID, user.ID, roleID) - if err != nil { - t.Fatalf("Failed to assign db_operator role to user: %v", err) - } - - user.Roles = []string{"db_operator"} - return user -} - -func insertTestDatabaseCredentials(t *testing.T, db *sql.DB) { - query := `INSERT INTO database (id, host, port, name, user, password) - VALUES (?, ?, ?, ?, ?, ?)` - _, err := db.Exec(query, "db123", "localhost", 5432, "testdb", "postgres", "securepassword") - if err != nil { - t.Fatalf("Failed to insert test database credentials: %v", err) - } -} - -func TestDatabaseCredentialsAdmin(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - user := createTestAdminUser(t, db) - insertTestDatabaseCredentials(t, db) - - logger := log.New(os.Stdout, "TEST: ", log.LstdFlags) - server := NewServer(db, logger) - - token := "testtoken123456" - expires := time.Now().Add(24 * time.Hour).Unix() - - tokenID := "token123" - query := `INSERT INTO tokens (id, uid, token, expires) VALUES (?, ?, ?, ?)` - _, err := db.Exec(query, tokenID, user.ID, token, expires) - if err != nil { - t.Fatalf("Failed to insert test token: %v", err) - } - - req := httptest.NewRequest("GET", "/v1/database/credentials?username="+user.User, nil) - req.Header.Set("Authorization", "Bearer "+token) - - recorder := httptest.NewRecorder() - server.handleDatabaseCredentials(recorder, req) - - if recorder.Code != http.StatusOK { - t.Errorf("Expected status code %d, got %d", http.StatusOK, recorder.Code) - } - - var response DatabaseCredentials - if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { - t.Fatalf("Failed to decode response: %v", err) - } - - if response.Host != "localhost" { - t.Errorf("Expected host 'localhost', got '%s'", response.Host) - } - if response.Port != 5432 { - t.Errorf("Expected port 5432, got %d", response.Port) - } - if response.Name != "testdb" { - t.Errorf("Expected database name 'testdb', got '%s'", response.Name) - } - if response.User != "postgres" { - t.Errorf("Expected user 'postgres', got '%s'", response.User) - } - if response.Password != "securepassword" { - t.Errorf("Expected password 'securepassword', got '%s'", response.Password) - } -} - -func TestDatabaseCredentialsDBOperator(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - user := createTestDBOperatorUser(t, db) - insertTestDatabaseCredentials(t, db) - - logger := log.New(os.Stdout, "TEST: ", log.LstdFlags) - server := NewServer(db, logger) - - token := "dboptoken123456" - expires := time.Now().Add(24 * time.Hour).Unix() - - tokenID := "token456" - query := `INSERT INTO tokens (id, uid, token, expires) VALUES (?, ?, ?, ?)` - _, err := db.Exec(query, tokenID, user.ID, token, expires) - if err != nil { - t.Fatalf("Failed to insert test token: %v", err) - } - - req := httptest.NewRequest("GET", "/v1/database/credentials?username="+user.User, nil) - req.Header.Set("Authorization", "Bearer "+token) - - recorder := httptest.NewRecorder() - server.handleDatabaseCredentials(recorder, req) - - if recorder.Code != http.StatusOK { - t.Errorf("Expected status code %d, got %d", http.StatusOK, recorder.Code) - } - - var response DatabaseCredentials - if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { - t.Fatalf("Failed to decode response: %v", err) - } - - if response.Host != "localhost" { - t.Errorf("Expected host 'localhost', got '%s'", response.Host) - } - if response.Port != 5432 { - t.Errorf("Expected port 5432, got %d", response.Port) - } - if response.Name != "testdb" { - t.Errorf("Expected database name 'testdb', got '%s'", response.Name) - } - if response.User != "postgres" { - t.Errorf("Expected user 'postgres', got '%s'", response.User) - } - if response.Password != "securepassword" { - t.Errorf("Expected password 'securepassword', got '%s'", response.Password) - } -} - -func TestDatabaseCredentialsUnauthorized(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - // Create a regular user with the 'user' role - user := &data.User{} - login := &data.Login{ - User: "regularuser", - Password: "testpassword", - } - - if err := user.Register(login); err != nil { - t.Fatalf("Failed to register test user: %v", err) - } - - query := `INSERT INTO users (id, created, user, password, salt) VALUES (?, ?, ?, ?, ?)` - _, err := db.Exec(query, user.ID, user.Created, user.User, user.Password, user.Salt) - if err != nil { - t.Fatalf("Failed to insert test user: %v", err) - } - - // Use the existing user role from schema.sql - var roleID string - err = db.QueryRow("SELECT id FROM roles WHERE role = 'user'").Scan(&roleID) - if err != nil { - t.Fatalf("Failed to get user role ID: %v", err) - } - - // Assign user role to user - userRoleID := "ur789" - _, err = db.Exec("INSERT INTO user_roles (id, uid, rid) VALUES (?, ?, ?)", userRoleID, user.ID, roleID) - if err != nil { - t.Fatalf("Failed to assign user role to user: %v", err) - } - - insertTestDatabaseCredentials(t, db) - - logger := log.New(os.Stdout, "TEST: ", log.LstdFlags) - server := NewServer(db, logger) - - token := "usertoken123456" - expires := time.Now().Add(24 * time.Hour).Unix() - - tokenID := "token789" - tokenQuery := `INSERT INTO tokens (id, uid, token, expires) VALUES (?, ?, ?, ?)` - _, err = db.Exec(tokenQuery, tokenID, user.ID, token, expires) - if err != nil { - t.Fatalf("Failed to insert test token: %v", err) - } - - req := httptest.NewRequest("GET", "/v1/database/credentials?username="+user.User, nil) - req.Header.Set("Authorization", "Bearer "+token) - - recorder := httptest.NewRecorder() - server.handleDatabaseCredentials(recorder, req) - - if recorder.Code != http.StatusForbidden { - t.Errorf("Expected status code %d, got %d", http.StatusForbidden, recorder.Code) - } - - // Check that the error message mentions the required permission - var errResp ErrorResponse - if err := json.NewDecoder(recorder.Body).Decode(&errResp); err != nil { - t.Fatalf("Failed to decode error response: %v", err) - } - - expectedErrMsg := "Insufficient permissions: requires database_credentials:read permission" - if errResp.Error != expectedErrMsg { - t.Errorf("Expected error message '%s', got '%s'", expectedErrMsg, errResp.Error) - } -} diff --git a/api/server.go b/api/server.go deleted file mode 100644 index e8b35ed..0000000 --- a/api/server.go +++ /dev/null @@ -1,217 +0,0 @@ -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 - 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), - RateLimiter: rateLimiter, - } - - // Start a goroutine to clean up old clients - go func() { - for { - time.Sleep(1 * time.Hour) - rateLimiter.CleanupClients() - } - }() - - s.registerRoutes() - - return s -} - -func (s *Server) registerRoutes() { - s.Router.HandleFunc("POST /v1/login/password", s.handlePasswordLogin) - s.Router.HandleFunc("POST /v1/login/token", s.handleTokenLogin) - s.Router.HandleFunc("POST /v1/login/totp", s.handleTOTPVerify) - 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/client/README.org b/client/README.org deleted file mode 100644 index 5b4f9c6..0000000 --- a/client/README.org +++ /dev/null @@ -1,177 +0,0 @@ -#+TITLE: MCIAS Client SDK - -The MCIAS Client SDK provides a Go client for interacting with the Metacircular Identity and Access System (MCIAS). It allows applications to authenticate users and retrieve database credentials from an MCIAS server. - -* Installation - -#+BEGIN_SRC bash -go get git.wntrmute.dev/kyle/mcias/client -#+END_SRC - -* Usage - -** Creating a Client - -#+BEGIN_SRC go -import "git.wntrmute.dev/kyle/mcias/client" - -// Create a client with default settings (connects to http://localhost:8080) -c := client.NewClient() - -// Create a client with custom settings -c := client.NewClient( - client.WithBaseURL("https://mcias.example.com"), - client.WithUsername("username"), - client.WithToken("existing-token"), -) -#+END_SRC - -** Authentication - -*** Password Authentication - -#+BEGIN_SRC go -ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) -defer cancel() - -tokenResp, err := c.LoginWithPassword(ctx, "username", "password") -if err != nil { - log.Fatalf("Failed to login: %v", err) -} - -fmt.Printf("Authenticated with token: %s\n", tokenResp.Token) -fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) - -// Check if TOTP verification is required -if tokenResp.TOTPEnabled { - fmt.Println("TOTP verification required") - // See TOTP Verification section -} -#+END_SRC - -*** Token Authentication - -#+BEGIN_SRC go -ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) -defer cancel() - -tokenResp, err := c.LoginWithToken(ctx, "username", "existing-token") -if err != nil { - log.Fatalf("Failed to login with token: %v", err) -} - -fmt.Printf("Authenticated with token: %s\n", tokenResp.Token) -fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) -#+END_SRC - -*** TOTP Verification - -If TOTP is enabled for a user, you'll need to verify a TOTP code after password authentication: - -#+BEGIN_SRC go -ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) -defer cancel() - -totpResp, err := c.VerifyTOTP(ctx, "username", "123456") // Replace with actual TOTP code -if err != nil { - log.Fatalf("Failed to verify TOTP: %v", err) -} - -fmt.Printf("TOTP verified, token: %s\n", totpResp.Token) -fmt.Printf("Token expires at: %s\n", time.Unix(totpResp.Expires, 0).Format(time.RFC3339)) -#+END_SRC - -** Retrieving Database Credentials - -Once authenticated, you can retrieve database credentials: - -#+BEGIN_SRC go -ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) -defer cancel() - -dbCreds, err := c.GetDatabaseCredentials(ctx) -if err != nil { - log.Fatalf("Failed to get database credentials: %v", err) -} - -fmt.Printf("Database Host: %s\n", dbCreds.Host) -fmt.Printf("Database Port: %d\n", dbCreds.Port) -fmt.Printf("Database Name: %s\n", dbCreds.Name) -fmt.Printf("Database User: %s\n", dbCreds.User) -fmt.Printf("Database Password: %s\n", dbCreds.Password) -#+END_SRC - -* Complete Example - -Here's a complete example showing the authentication flow and database credential retrieval: - -#+BEGIN_SRC go -package main - -import ( - "context" - "fmt" - "log" - "time" - - "git.wntrmute.dev/kyle/mcias/client" -) - -func main() { - // Create a new client - c := client.NewClient() - - // Create a context with timeout - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Authenticate with username and password - tokenResp, err := c.LoginWithPassword(ctx, "username", "password") - if err != nil { - log.Fatalf("Failed to login: %v", err) - } - - fmt.Printf("Authenticated with token: %s\n", tokenResp.Token) - fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) - - // If TOTP is enabled, verify the TOTP code - if tokenResp.TOTPEnabled { - fmt.Println("TOTP is enabled, please enter your TOTP code") - var totpCode string - fmt.Scanln(&totpCode) - - totpResp, err := c.VerifyTOTP(ctx, "username", totpCode) - if err != nil { - log.Fatalf("Failed to verify TOTP: %v", err) - } - - fmt.Printf("TOTP verified, new token: %s\n", totpResp.Token) - fmt.Printf("Token expires at: %s\n", time.Unix(totpResp.Expires, 0).Format(time.RFC3339)) - } - - // Get database credentials - dbCreds, err := c.GetDatabaseCredentials(ctx) - if err != nil { - log.Fatalf("Failed to get database credentials: %v", err) - } - - fmt.Printf("Database Host: %s\n", dbCreds.Host) - fmt.Printf("Database Port: %d\n", dbCreds.Port) - fmt.Printf("Database Name: %s\n", dbCreds.Name) - fmt.Printf("Database User: %s\n", dbCreds.User) - fmt.Printf("Database Password: %s\n", dbCreds.Password) -} -#+END_SRC - -* Error Handling - -All methods return errors that should be checked. The errors include detailed information about what went wrong, including API error messages when available. - -* Configuration Options - -The client can be configured with the following options: - -- =WithBaseURL(baseURL string)=: Sets the base URL of the MCIAS server (default: "http://localhost:8080") -- =WithHTTPClient(httpClient *http.Client)=: Sets a custom HTTP client (default: http.Client with 10s timeout) -- =WithToken(token string)=: Sets an authentication token -- =WithUsername(username string)=: Sets a username \ No newline at end of file diff --git a/client/auth.go b/client/auth.go deleted file mode 100644 index 4e1f10e..0000000 --- a/client/auth.go +++ /dev/null @@ -1,212 +0,0 @@ -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" -) - -// LoginParams represents the parameters for login. -type LoginParams struct { - User string `json:"user"` - Password string `json:"password,omitempty"` - Token string `json:"token,omitempty"` - TOTPCode string `json:"totp_code,omitempty"` -} - -// LoginRequest represents a login request to the MCIAS API. -type LoginRequest struct { - Version string `json:"version"` - Login LoginParams `json:"login"` -} - -// TOTPVerifyRequest represents a TOTP verification request. -type TOTPVerifyRequest struct { - Version string `json:"version"` - Username string `json:"username"` - TOTPCode string `json:"totp_code"` -} - -// TokenResponse represents the response from a login or TOTP verification request. -type TokenResponse struct { - Token string `json:"token"` - Expires int64 `json:"expires"` - TOTPEnabled bool `json:"totp_enabled,omitempty"` -} - -// ErrorResponse represents an error response from the API. -type ErrorResponse struct { - Error string `json:"error"` - ErrorCode string `json:"error_code,omitempty"` -} - -// LoginWithPassword authenticates with the MCIAS server using a username and password. -// If TOTP is enabled for the user, the TOTPEnabled field in the response will be true, -// and the client will need to call VerifyTOTP to complete authentication. -func (c *Client) LoginWithPassword(ctx context.Context, username, password string) (*TokenResponse, error) { - loginReq := LoginRequest{ - Version: "v1", - Login: LoginParams{ - User: username, - Password: password, - }, - } - - jsonData, err := json.Marshal(loginReq) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - - url := fmt.Sprintf("%s/v1/login/password", c.BaseURL) - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil { - return nil, fmt.Errorf("API error: %s (code: %s)", errResp.Error, errResp.ErrorCode) - } - return nil, fmt.Errorf("API error: %s", resp.Status) - } - - var tokenResp TokenResponse - if err := json.Unmarshal(body, &tokenResp); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - // Update client with authentication info - c.Token = tokenResp.Token - c.Username = username - - return &tokenResp, nil -} - -// LoginWithToken authenticates with the MCIAS server using a token. -func (c *Client) LoginWithToken(ctx context.Context, username, token string) (*TokenResponse, error) { - loginReq := LoginRequest{ - Version: "v1", - Login: LoginParams{ - User: username, - Token: token, - }, - } - - jsonData, err := json.Marshal(loginReq) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - - url := fmt.Sprintf("%s/v1/login/token", c.BaseURL) - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil { - return nil, fmt.Errorf("API error: %s (code: %s)", errResp.Error, errResp.ErrorCode) - } - return nil, fmt.Errorf("API error: %s", resp.Status) - } - - var tokenResp TokenResponse - if err := json.Unmarshal(body, &tokenResp); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - // Update client with authentication info - c.Token = tokenResp.Token - c.Username = username - - return &tokenResp, nil -} - -// VerifyTOTP verifies a TOTP code for a user. -func (c *Client) VerifyTOTP(ctx context.Context, username, totpCode string) (*TokenResponse, error) { - totpReq := TOTPVerifyRequest{ - Version: "v1", - Username: username, - TOTPCode: totpCode, - } - - jsonData, err := json.Marshal(totpReq) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - - url := fmt.Sprintf("%s/v1/login/totp", c.BaseURL) - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil { - return nil, fmt.Errorf("API error: %s (code: %s)", errResp.Error, errResp.ErrorCode) - } - return nil, fmt.Errorf("API error: %s", resp.Status) - } - - var tokenResp TokenResponse - if err := json.Unmarshal(body, &tokenResp); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - // Update client with authentication info - c.Token = tokenResp.Token - c.Username = username - - return &tokenResp, nil -} - -// IsTokenExpired checks if the token is expired. -func (c *Client) IsTokenExpired(expiryTime int64) bool { - return expiryTime > 0 && expiryTime < time.Now().Unix() -} diff --git a/client/client.go b/client/client.go deleted file mode 100644 index db7465a..0000000 --- a/client/client.go +++ /dev/null @@ -1,60 +0,0 @@ -// Package client provides a Go client for interacting with the MCIAS (Metacircular Identity and Access System). -package client - -import ( - "net/http" - "time" -) - -// Client encapsulates the connection details and authentication state needed to interact with the MCIAS API. -type Client struct { - BaseURL string - HTTPClient *http.Client - Token string - Username string -} - -type Option func(*Client) - -func WithBaseURL(baseURL string) Option { - return func(c *Client) { - c.BaseURL = baseURL - } -} - -func WithHTTPClient(httpClient *http.Client) Option { - return func(c *Client) { - c.HTTPClient = httpClient - } -} - -func WithToken(token string) Option { - return func(c *Client) { - c.Token = token - } -} - -func WithUsername(username string) Option { - return func(c *Client) { - c.Username = username - } -} - -func NewClient(options ...Option) *Client { - client := &Client{ - BaseURL: "http://localhost:8080", - HTTPClient: &http.Client{ - Timeout: 10 * time.Second, - }, - } - - for _, option := range options { - option(client) - } - - return client -} - -func (c *Client) IsAuthenticated() bool { - return c.Token != "" -} diff --git a/client/client_test.go b/client/client_test.go deleted file mode 100644 index 12147c8..0000000 --- a/client/client_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package client - -import ( - "net/http" - "testing" - "time" -) - -func TestNewClient(t *testing.T) { - // Test default client - client := NewClient() - if client.BaseURL != "http://localhost:8080" { - t.Errorf("Expected BaseURL to be http://localhost:8080, got %s", client.BaseURL) - } - if client.HTTPClient == nil { - t.Error("Expected HTTPClient to be initialized") - } - if client.Token != "" { - t.Errorf("Expected Token to be empty, got %s", client.Token) - } - if client.Username != "" { - t.Errorf("Expected Username to be empty, got %s", client.Username) - } - - // Test client with options - customHTTPClient := &http.Client{Timeout: 5 * time.Second} - client = NewClient( - WithBaseURL("https://mcias.example.com"), - WithHTTPClient(customHTTPClient), - WithToken("test-token"), - WithUsername("test-user"), - ) - if client.BaseURL != "https://mcias.example.com" { - t.Errorf("Expected BaseURL to be https://mcias.example.com, got %s", client.BaseURL) - } - if client.HTTPClient != customHTTPClient { - t.Error("Expected HTTPClient to be the custom client") - } - if client.Token != "test-token" { - t.Errorf("Expected Token to be test-token, got %s", client.Token) - } - if client.Username != "test-user" { - t.Errorf("Expected Username to be test-user, got %s", client.Username) - } -} - -func TestIsAuthenticated(t *testing.T) { - // Test unauthenticated client - client := NewClient() - if client.IsAuthenticated() { - t.Error("Expected IsAuthenticated to return false for client without token") - } - - // Test authenticated client - client = NewClient(WithToken("test-token")) - if !client.IsAuthenticated() { - t.Error("Expected IsAuthenticated to return true for client with token") - } -} - -func TestIsTokenExpired(t *testing.T) { - client := NewClient() - - // Test expired token - pastTime := time.Now().Add(-1 * time.Hour).Unix() - if !client.IsTokenExpired(pastTime) { - t.Error("Expected IsTokenExpired to return true for past time") - } - - // Test valid token - futureTime := time.Now().Add(1 * time.Hour).Unix() - if client.IsTokenExpired(futureTime) { - t.Error("Expected IsTokenExpired to return false for future time") - } - - // Test zero time (no expiry) - if client.IsTokenExpired(0) { - t.Error("Expected IsTokenExpired to return false for zero time") - } -} diff --git a/client/database.go b/client/database.go deleted file mode 100644 index 5586be1..0000000 --- a/client/database.go +++ /dev/null @@ -1,157 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" -) - -// DatabaseCredentials represents the database connection credentials. -type DatabaseCredentials struct { - Host string `json:"host"` - Port int `json:"port"` - Name string `json:"name"` - User string `json:"user"` - Password string `json:"password"` -} - -// GetDatabaseCredentials retrieves database credentials from the MCIAS server. -// If databaseID is provided, it returns credentials for that specific database. -// If databaseID is empty, it returns the first database the user has access to. -// This method requires the client to be authenticated (have a valid token). -func (c *Client) GetDatabaseCredentials(ctx context.Context, databaseID string) (*DatabaseCredentials, error) { - if !c.IsAuthenticated() { - return nil, fmt.Errorf("client is not authenticated, call LoginWithPassword or LoginWithToken first") - } - - if c.Username == "" { - return nil, fmt.Errorf("username is not set, call LoginWithPassword or LoginWithToken first") - } - - // Build the URL with query parameters - baseURL := fmt.Sprintf("%s/v1/database/credentials", c.BaseURL) - params := url.Values{} - params.Add("username", c.Username) - if databaseID != "" { - params.Add("database_id", databaseID) - } - requestURL := fmt.Sprintf("%s?%s", baseURL, params.Encode()) - - // Create the request - req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Add authorization header - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token)) - - // Send the request - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - // Read the response body - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - // Check for errors - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil { - return nil, fmt.Errorf("API error: %s (code: %s)", errResp.Error, errResp.ErrorCode) - } - return nil, fmt.Errorf("API error: %s", resp.Status) - } - - // Try to parse as a single database first (when a specific database_id is requested) - var creds DatabaseCredentials - if err := json.Unmarshal(body, &creds); err == nil { - // Successfully parsed as a single database - return &creds, nil - } - - // If that fails, try to parse as an array of databases - var credsList []DatabaseCredentials - if err := json.Unmarshal(body, &credsList); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - // If we got an empty list, return an error - if len(credsList) == 0 { - return nil, fmt.Errorf("no database credentials found") - } - - // Return the first database in the list - return &credsList[0], nil -} - -// GetDatabaseCredentialsList retrieves all database credentials the user has access to. -// This method requires the client to be authenticated (have a valid token). -func (c *Client) GetDatabaseCredentialsList(ctx context.Context) ([]DatabaseCredentials, error) { - if !c.IsAuthenticated() { - return nil, fmt.Errorf("client is not authenticated, call LoginWithPassword or LoginWithToken first") - } - - if c.Username == "" { - return nil, fmt.Errorf("username is not set, call LoginWithPassword or LoginWithToken first") - } - - // Build the URL with query parameters - baseURL := fmt.Sprintf("%s/v1/database/credentials", c.BaseURL) - params := url.Values{} - params.Add("username", c.Username) - requestURL := fmt.Sprintf("%s?%s", baseURL, params.Encode()) - - // Create the request - req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Add authorization header - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token)) - - // Send the request - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - // Read the response body - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - // Check for errors - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil { - return nil, fmt.Errorf("API error: %s (code: %s)", errResp.Error, errResp.ErrorCode) - } - return nil, fmt.Errorf("API error: %s", resp.Status) - } - - // Try to parse as an array of databases - var credsList []DatabaseCredentials - if err := json.Unmarshal(body, &credsList); err != nil { - // If that fails, try to parse as a single database - var creds DatabaseCredentials - if err := json.Unmarshal(body, &creds); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - // Return as a single-item list - return []DatabaseCredentials{creds}, nil - } - - return credsList, nil -} diff --git a/client/example_test.go b/client/example_test.go deleted file mode 100644 index 702ff28..0000000 --- a/client/example_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package client_test - -import ( - "context" - "fmt" - "time" - - "git.wntrmute.dev/kyle/mcias/client" -) - -func Example() { - c := client.NewClient() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - tokenResp, err := c.LoginWithPassword(ctx, "username", "password") - if err != nil { - fmt.Println("Failed to login:", err) - return - } - - fmt.Printf("Authenticated with token: %s\n", tokenResp.Token) - fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) - - if tokenResp.TOTPEnabled { - fmt.Println("TOTP is enabled, please enter your TOTP code") - totpCode := "123456" // In a real application, this would be user input - - totpResp, err := c.VerifyTOTP(ctx, "username", totpCode) - if err != nil { - fmt.Println("Failed to verify TOTP:", err) - return - } - - fmt.Printf("TOTP verified, new token: %s\n", totpResp.Token) - fmt.Printf("Token expires at: %s\n", time.Unix(totpResp.Expires, 0).Format(time.RFC3339)) - } - - dbCreds, err := c.GetDatabaseCredentials(ctx, "") - if err != nil { - fmt.Println("Failed to get database credentials:", err) - return - } - - fmt.Printf("Database Host: %s\n", dbCreds.Host) - fmt.Printf("Database Port: %d\n", dbCreds.Port) - fmt.Printf("Database Name: %s\n", dbCreds.Name) - fmt.Printf("Database User: %s\n", dbCreds.User) - fmt.Printf("Database Password: %s\n", dbCreds.Password) - - tokenClient := client.NewClient() - tokenResp, err = tokenClient.LoginWithToken(ctx, "username", "existing-token") - if err != nil { - fmt.Println("Failed to login with token:", err) - return - } - - fmt.Printf("Authenticated with token: %s\n", tokenResp.Token) - fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) - - // Output: - // Authenticated with token: token - // Token expires at: 2023-01-01T00:00:00Z - // Database Host: db.example.com - // Database Port: 5432 - // Database Name: mydb - // Database User: dbuser - // Database Password: dbpass - // Authenticated with token: token - // Token expires at: 2023-01-01T00:00:00Z -} - -func ExampleClient_LoginWithPassword() { - c := client.NewClient( - client.WithBaseURL("https://mcias.example.com"), - ) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - tokenResp, err := c.LoginWithPassword(ctx, "username", "password") - if err != nil { - fmt.Println("Failed to login:", err) - return - } - - fmt.Printf("Authenticated with token: %s\n", tokenResp.Token) - fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) - - if tokenResp.TOTPEnabled { - fmt.Println("TOTP verification required") - } - - // Output: - // Authenticated with token: token - // Token expires at: 2023-01-01T00:00:00Z -} - -func ExampleClient_LoginWithToken() { - c := client.NewClient() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - tokenResp, err := c.LoginWithToken(ctx, "username", "existing-token") - if err != nil { - fmt.Println("Failed to login with token:", err) - return - } - - fmt.Printf("Authenticated with token: %s\n", tokenResp.Token) - fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) - - // Output: - // Authenticated with token: token - // Token expires at: 2023-01-01T00:00:00Z -} - -func ExampleClient_VerifyTOTP() { - c := client.NewClient() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - totpResp, err := c.VerifyTOTP(ctx, "username", "123456") - if err != nil { - fmt.Println("Failed to verify TOTP:", err) - return - } - - fmt.Printf("TOTP verified, token: %s\n", totpResp.Token) - fmt.Printf("Token expires at: %s\n", time.Unix(totpResp.Expires, 0).Format(time.RFC3339)) - - // Output: - // TOTP verified, token: token - // Token expires at: 2023-01-01T00:00:00Z -} - -func ExampleClient_GetDatabaseCredentials() { - c := client.NewClient( - client.WithUsername("username"), - client.WithToken("existing-token"), - ) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - databaseID := "db123" - dbCreds, err := c.GetDatabaseCredentials(ctx, databaseID) - if err != nil { - fmt.Println("Failed to get database credentials:", err) - return - } - - fmt.Printf("Database Host: %s\n", dbCreds.Host) - fmt.Printf("Database Port: %d\n", dbCreds.Port) - fmt.Printf("Database Name: %s\n", dbCreds.Name) - fmt.Printf("Database User: %s\n", dbCreds.User) - fmt.Printf("Database Password: %s\n", dbCreds.Password) - - // Output: - // Database Host: db.example.com - // Database Port: 5432 - // Database Name: mydb - // Database User: dbuser - // Database Password: dbpass -} - -func ExampleClient_GetDatabaseCredentialsList() { - c := client.NewClient( - client.WithUsername("username"), - client.WithToken("existing-token"), - ) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - dbCredsList, err := c.GetDatabaseCredentialsList(ctx) - if err != nil { - fmt.Println("Failed to get database credentials list:", err) - return - } - - fmt.Printf("Number of databases: %d\n", len(dbCredsList)) - - for i, creds := range dbCredsList { - fmt.Printf("Database %d:\n", i+1) - fmt.Printf(" Host: %s\n", creds.Host) - fmt.Printf(" Port: %d\n", creds.Port) - fmt.Printf(" Name: %s\n", creds.Name) - fmt.Printf(" User: %s\n", creds.User) - fmt.Printf(" Password: %s\n", creds.Password) - } - - // Output: - // Number of databases: 2 - // Database 1: - // Host: db1.example.com - // Port: 5432 - // Name: mydb1 - // User: dbuser1 - // Password: dbpass1 - // Database 2: - // Host: db2.example.com - // Port: 5432 - // Name: mydb2 - // User: dbuser2 - // Password: dbpass2 -} diff --git a/cmd/mcias-client/database.go b/cmd/mcias-client/database.go deleted file mode 100644 index 02c0261..0000000 --- a/cmd/mcias-client/database.go +++ /dev/null @@ -1,185 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "os" - "time" - - "git.wntrmute.dev/kyle/mcias/client" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var ( - dbUsername string - dbToken string - useStored bool - databaseID string - outputJSON bool -) - -var databaseCmd = &cobra.Command{ - Use: "database", - Short: "Manage database credentials", - Long: `Commands for managing database credentials in the MCIAS system.`, -} - -var getCredentialsCmd = &cobra.Command{ - Use: "credentials", - Short: "Get database credentials", - Long: `Retrieve database credentials from the MCIAS system. -This command requires authentication with a username and token. -If database-id is provided, it returns credentials for that specific database. -If database-id is not provided, it returns the first database the user has access to.`, - Run: func(_ *cobra.Command, args []string) { - getCredentials() - }, -} - -var listCredentialsCmd = &cobra.Command{ - Use: "list", - Short: "List all accessible database credentials", - Long: `List all database credentials the user has access to. -This command requires authentication with a username and token.`, - Run: func(_ *cobra.Command, args []string) { - listCredentials() - }, -} - -func init() { - rootCmd.AddCommand(databaseCmd) - databaseCmd.AddCommand(getCredentialsCmd) - databaseCmd.AddCommand(listCredentialsCmd) - - // Flags for getCredentialsCmd - getCredentialsCmd.Flags().StringVarP(&dbUsername, "username", "u", "", "Username for authentication") - getCredentialsCmd.Flags().StringVarP(&dbToken, "token", "t", "", "Authentication token") - getCredentialsCmd.Flags().BoolVarP(&useStored, "use-stored", "s", false, "Use stored token from previous login") - getCredentialsCmd.Flags().StringVarP(&databaseID, "database-id", "d", "", "ID of the specific database to retrieve") - getCredentialsCmd.Flags().BoolVarP(&outputJSON, "json", "j", false, "Output in JSON format") - - // Flags for listCredentialsCmd - listCredentialsCmd.Flags().StringVarP(&dbUsername, "username", "u", "", "Username for authentication") - listCredentialsCmd.Flags().StringVarP(&dbToken, "token", "t", "", "Authentication token") - listCredentialsCmd.Flags().BoolVarP(&useStored, "use-stored", "s", false, "Use stored token from previous login") - listCredentialsCmd.Flags().BoolVarP(&outputJSON, "json", "j", false, "Output in JSON format") - - // Make username required only if not using stored token - getCredentialsCmd.MarkFlagsMutuallyExclusive("token", "use-stored") - listCredentialsCmd.MarkFlagsMutuallyExclusive("token", "use-stored") -} - -// createClient creates and configures a new MCIAS client -func createClient() *client.Client { - // If using stored token, load it from the token file - if useStored { - tokenInfo, err := loadToken() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading token: %v\n", err) - os.Exit(1) - } - dbUsername = tokenInfo.Username - dbToken = tokenInfo.Token - } - - // Validate required parameters - if dbUsername == "" { - fmt.Fprintf(os.Stderr, "Error: username is required\n") - os.Exit(1) - } - - if dbToken == "" { - fmt.Fprintf(os.Stderr, "Error: token is required (either provide --token or use --use-stored)\n") - os.Exit(1) - } - - serverAddr := viper.GetString("server") - if serverAddr == "" { - serverAddr = "http://localhost:8080" - } - - // Create a new client with the appropriate options - c := client.NewClient( - client.WithBaseURL(serverAddr), - client.WithUsername(dbUsername), - client.WithToken(dbToken), - ) - - return c -} - -func getCredentials() { - // Create a new client - c := createClient() - - // Create a context with timeout - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Get database credentials - creds, err := c.GetDatabaseCredentials(ctx, databaseID) - if err != nil { - fmt.Fprintf(os.Stderr, "Error retrieving database credentials: %v\n", err) - os.Exit(1) - } - - // Output in JSON format if requested - if outputJSON { - jsonData, err := json.MarshalIndent(creds, "", " ") - if err != nil { - fmt.Fprintf(os.Stderr, "Error formatting JSON: %v\n", err) - os.Exit(1) - } - fmt.Println(string(jsonData)) - return - } - - // Output in human-readable format - fmt.Println("Database Credentials:") - fmt.Printf("Host: %s\n", creds.Host) - fmt.Printf("Port: %d\n", creds.Port) - fmt.Printf("Name: %s\n", creds.Name) - fmt.Printf("User: %s\n", creds.User) - fmt.Printf("Password: %s\n", creds.Password) -} - -func listCredentials() { - // Create a new client - c := createClient() - - // Create a context with timeout - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Get all database credentials - credsList, err := c.GetDatabaseCredentialsList(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "Error retrieving database credentials: %v\n", err) - os.Exit(1) - } - - // Output in JSON format if requested - if outputJSON { - jsonData, err := json.MarshalIndent(credsList, "", " ") - if err != nil { - fmt.Fprintf(os.Stderr, "Error formatting JSON: %v\n", err) - os.Exit(1) - } - fmt.Println(string(jsonData)) - return - } - - // Output in human-readable format - fmt.Printf("Found %d database(s):\n\n", len(credsList)) - for i, creds := range credsList { - fmt.Printf("Database #%d:\n", i+1) - fmt.Printf(" Host: %s\n", creds.Host) - fmt.Printf(" Port: %d\n", creds.Port) - fmt.Printf(" Name: %s\n", creds.Name) - fmt.Printf(" User: %s\n", creds.User) - fmt.Printf(" Password: %s\n", creds.Password) - fmt.Println() - } -} diff --git a/cmd/mcias-client/login.go b/cmd/mcias-client/login.go deleted file mode 100644 index 2ff1019..0000000 --- a/cmd/mcias-client/login.go +++ /dev/null @@ -1,368 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "time" - - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var ( - username string - password string - token string - totpCode string -) - -type LoginRequest struct { - Version string `json:"version"` - Login LoginParams `json:"login"` -} - -type TOTPVerifyRequest struct { - Version string `json:"version"` - Username string `json:"username"` - TOTPCode string `json:"totp_code"` -} - -type LoginParams struct { - User string `json:"user"` - Password string `json:"password,omitempty"` - Token string `json:"token,omitempty"` - TOTPCode string `json:"totp_code,omitempty"` -} - -type TokenResponse struct { - Token string `json:"token"` - Expires int64 `json:"expires"` -} - -type ErrorResponse struct { - Error string `json:"error"` -} - -type TokenInfo struct { - Username string `json:"username"` - Token string `json:"token"` - Expires int64 `json:"expires"` -} - -var loginCmd = &cobra.Command{ - Use: "login", - Short: "Login to the MCIAS server", - Long: `Login to the MCIAS server using either a username/password or a token.`, -} - -var passwordLoginCmd = &cobra.Command{ - Use: "password", - Short: "Login with username and password", - Long: `Login to the MCIAS server using a username and password.`, - Run: func(cmd *cobra.Command, args []string) { - loginWithPassword() - }, -} - -var tokenLoginCmd = &cobra.Command{ - Use: "token", - Short: "Login with a token", - Long: `Login to the MCIAS server using a token.`, - Run: func(cmd *cobra.Command, args []string) { - loginWithToken() - }, -} - -var totpVerifyCmd = &cobra.Command{ - Use: "totp", - Short: "Verify TOTP code", - Long: `Verify a TOTP code after password authentication.`, - Run: func(cmd *cobra.Command, args []string) { - verifyTOTP() - }, -} - -func init() { - rootCmd.AddCommand(loginCmd) - loginCmd.AddCommand(passwordLoginCmd) - loginCmd.AddCommand(tokenLoginCmd) - loginCmd.AddCommand(totpVerifyCmd) - - // TOTP verification flags - totpVerifyCmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication") - totpVerifyCmd.Flags().StringVarP(&totpCode, "code", "c", "", "TOTP code to verify") - if err := totpVerifyCmd.MarkFlagRequired("username"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err) - } - if err := totpVerifyCmd.MarkFlagRequired("code"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking code flag as required: %v\n", err) - } - - // Password login flags - passwordLoginCmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication") - passwordLoginCmd.Flags().StringVarP(&password, "password", "p", "", "Password for authentication") - passwordLoginCmd.Flags().StringVarP(&totpCode, "totp", "t", "", "TOTP code (if enabled)") - if err := passwordLoginCmd.MarkFlagRequired("username"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err) - } - if err := passwordLoginCmd.MarkFlagRequired("password"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking password flag as required: %v\n", err) - } - - // Token login flags - tokenLoginCmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication") - tokenLoginCmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token") - if err := tokenLoginCmd.MarkFlagRequired("username"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err) - } - if err := tokenLoginCmd.MarkFlagRequired("token"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking token flag as required: %v\n", err) - } -} - -func loginWithPassword() { - serverAddr := viper.GetString("server") - if serverAddr == "" { - serverAddr = "http://localhost:8080" - } - - url := fmt.Sprintf("%s/v1/login/password", serverAddr) - - loginReq := LoginRequest{ - Version: "v1", - Login: LoginParams{ - User: username, - Password: password, - TOTPCode: totpCode, - }, - } - - jsonData, err := json.Marshal(loginReq) - if err != nil { - fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err) - os.Exit(1) - } - - // Create a context with timeout - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err) - os.Exit(1) - } - - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to send request: %v\n", err) - os.Exit(1) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to read response: %v\n", err) - os.Exit(1) - } - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil { - fmt.Fprintf(os.Stderr, "Error: %s\n", errResp.Error) - } else { - fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Status) - } - os.Exit(1) - } - - var tokenResp TokenResponse - if unmarshalErr := json.Unmarshal(body, &tokenResp); unmarshalErr != nil { - fmt.Fprintf(os.Stderr, "Failed to parse response: %v\n", unmarshalErr) - os.Exit(1) - } - - // Save the token to the token file - tokenInfo := TokenInfo{ - Username: username, - Token: tokenResp.Token, - Expires: tokenResp.Expires, - } - - if err := saveToken(tokenInfo); err != nil { - fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err) - // Continue anyway, as we can still display the token - } - - fmt.Println("Login successful!") - fmt.Printf("Token: %s\n", tokenResp.Token) - fmt.Printf("Expires: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) -} - -func verifyTOTP() { - serverAddr := viper.GetString("server") - if serverAddr == "" { - serverAddr = "http://localhost:8080" - } - - url := fmt.Sprintf("%s/v1/login/totp", serverAddr) - - totpReq := TOTPVerifyRequest{ - Version: "v1", - Username: username, - TOTPCode: totpCode, - } - - jsonData, err := json.Marshal(totpReq) - if err != nil { - fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err) - os.Exit(1) - } - - // Create a context with timeout - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err) - os.Exit(1) - } - - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to send request: %v\n", err) - os.Exit(1) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to read response: %v\n", err) - os.Exit(1) - } - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil { - fmt.Fprintf(os.Stderr, "Error: %s\n", errResp.Error) - } else { - fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Status) - } - os.Exit(1) - } - - var tokenResp TokenResponse - if unmarshalErr := json.Unmarshal(body, &tokenResp); unmarshalErr != nil { - fmt.Fprintf(os.Stderr, "Failed to parse response: %v\n", unmarshalErr) - os.Exit(1) - } - - // Save the token to the token file - tokenInfo := TokenInfo{ - Username: username, - Token: tokenResp.Token, - Expires: tokenResp.Expires, - } - - if err := saveToken(tokenInfo); err != nil { - fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err) - // Continue anyway, as we can still display the token - } - - fmt.Println("TOTP verification successful!") - fmt.Printf("Token: %s\n", tokenResp.Token) - fmt.Printf("Expires: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) -} - -func loginWithToken() { - serverAddr := viper.GetString("server") - if serverAddr == "" { - serverAddr = "http://localhost:8080" - } - - url := fmt.Sprintf("%s/v1/login/token", serverAddr) - - loginReq := LoginRequest{ - Version: "v1", - Login: LoginParams{ - User: username, - Token: token, - }, - } - - jsonData, err := json.Marshal(loginReq) - if err != nil { - fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err) - os.Exit(1) - } - - // Create a context with timeout - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err) - os.Exit(1) - } - - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to send request: %v\n", err) - os.Exit(1) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to read response: %v\n", err) - os.Exit(1) - } - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil { - fmt.Fprintf(os.Stderr, "Error: %s\n", errResp.Error) - } else { - fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Status) - } - os.Exit(1) - } - - var tokenResp TokenResponse - if unmarshalErr := json.Unmarshal(body, &tokenResp); unmarshalErr != nil { - fmt.Fprintf(os.Stderr, "Failed to parse response: %v\n", unmarshalErr) - os.Exit(1) - } - - // Save the token to the token file - tokenInfo := TokenInfo{ - Username: username, - Token: tokenResp.Token, - Expires: tokenResp.Expires, - } - - if err := saveToken(tokenInfo); err != nil { - fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err) - // Continue anyway, as we can still display the token - } - - fmt.Println("Token login successful!") - fmt.Printf("Token: %s\n", tokenResp.Token) - fmt.Printf("Expires: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) -} diff --git a/cmd/mcias-client/main.go b/cmd/mcias-client/main.go deleted file mode 100644 index a26b076..0000000 --- a/cmd/mcias-client/main.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -import ( - "fmt" - "os" -) - -func main() { - if err := Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} \ No newline at end of file diff --git a/cmd/mcias-client/root.go b/cmd/mcias-client/root.go deleted file mode 100644 index 3d88363..0000000 --- a/cmd/mcias-client/root.go +++ /dev/null @@ -1,78 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var ( - cfgFile string - serverAddr string - tokenFile string - - rootCmd = &cobra.Command{ - Use: "mcias-client", - Short: "MCIAS Client - Command line client for the Metacircular Identity and Access System", - Long: `MCIAS Client is a command line tool for interacting with the MCIAS server. -It provides access to the MCIAS API endpoints for authentication and resource access. - -It currently supports the following operations: -1. User password authentication -2. User token authentication -3. Database credential retrieval`, - } -) - -func Execute() error { - return rootCmd.Execute() -} - -// setupRootCommand initializes the root command and its flags -func setupRootCommand() { - cobra.OnInitialize(initConfig) - - rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.mcias-client.yaml)") - rootCmd.PersistentFlags().StringVar(&serverAddr, "server", "http://localhost:8080", "MCIAS server address") - rootCmd.PersistentFlags().StringVar(&tokenFile, "token-file", "", "File to store authentication token (default is $HOME/.mcias-token)") - - if err := viper.BindPFlag("server", rootCmd.PersistentFlags().Lookup("server")); err != nil { - fmt.Fprintf(os.Stderr, "Error binding server flag: %v\n", err) - } - if err := viper.BindPFlag("token-file", rootCmd.PersistentFlags().Lookup("token-file")); err != nil { - fmt.Fprintf(os.Stderr, "Error binding token-file flag: %v\n", err) - } -} - -func initConfig() { - if cfgFile != "" { - viper.SetConfigFile(cfgFile) - } else { - home, err := os.UserHomeDir() - cobra.CheckErr(err) - - viper.AddConfigPath(home) - viper.SetConfigType("yaml") - viper.SetConfigName(".mcias-client") - } - - viper.AutomaticEnv() - - if err := viper.ReadInConfig(); err == nil { - fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) - } - - // Set default token file if not specified - if viper.GetString("token-file") == "" { - home, err := os.UserHomeDir() - if err == nil { - viper.Set("token-file", fmt.Sprintf("%s/.mcias-token", home)) - } - } -} - -func init() { - setupRootCommand() -} \ No newline at end of file diff --git a/cmd/mcias-client/util.go b/cmd/mcias-client/util.go deleted file mode 100644 index e596568..0000000 --- a/cmd/mcias-client/util.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "time" - - "github.com/spf13/viper" -) - -// loadToken loads the token from the token file -func loadToken() (*TokenInfo, error) { - tokenFilePath := viper.GetString("token-file") - if tokenFilePath == "" { - home, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("error getting home directory: %w", err) - } - tokenFilePath = fmt.Sprintf("%s/.mcias-token", home) - } - - data, err := os.ReadFile(tokenFilePath) - if err != nil { - return nil, fmt.Errorf("error reading token file: %w", err) - } - - var tokenInfo TokenInfo - if err := json.Unmarshal(data, &tokenInfo); err != nil { - return nil, fmt.Errorf("error parsing token file: %w", err) - } - - // Check if token is expired - if tokenInfo.Expires > 0 && tokenInfo.Expires < time.Now().Unix() { - return nil, fmt.Errorf("token has expired, please login again") - } - - return &tokenInfo, nil -} - -// saveToken saves the token to the token file -func saveToken(tokenInfo TokenInfo) error { - tokenFilePath := viper.GetString("token-file") - if tokenFilePath == "" { - home, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("error getting home directory: %w", err) - } - tokenFilePath = fmt.Sprintf("%s/.mcias-token", home) - } - - jsonData, err := json.Marshal(tokenInfo) - if err != nil { - return fmt.Errorf("error encoding token: %w", err) - } - - if err := os.WriteFile(tokenFilePath, jsonData, 0600); err != nil { - return fmt.Errorf("error saving token to file: %w", err) - } - - fmt.Printf("Token saved to %s\n", tokenFilePath) - return nil -} \ No newline at end of file diff --git a/cmd/mcias/database.go b/cmd/mcias/database.go deleted file mode 100644 index 046d1b0..0000000 --- a/cmd/mcias/database.go +++ /dev/null @@ -1,149 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "time" - - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var ( - dbUsername string - dbToken string -) - -type DatabaseCredentials struct { - Host string `json:"host"` - Port int `json:"port"` - Name string `json:"name"` - User string `json:"user"` - Password string `json:"password"` -} - -type ErrorResponse struct { - Error string `json:"error"` -} - -var databaseCmd = &cobra.Command{ - Use: "database", - Short: "Manage database credentials", - Long: `Commands for managing database credentials in the MCIAS system.`, -} - -var addDBUserCmd = &cobra.Command{ - Use: "add-user [database-id] [user-id]", - Short: "Associate a user with a database", - Long: `Associate a user with a database, allowing them to read its credentials.`, - Args: cobra.ExactArgs(2), - Run: func(cmd *cobra.Command, args []string) { - addUserToDatabase(args[0], args[1]) - }, -} - -var removeDBUserCmd = &cobra.Command{ - Use: "remove-user [database-id] [user-id]", - Short: "Remove a user's association with a database", - Long: `Remove a user's association with a database, preventing them from reading its credentials.`, - Args: cobra.ExactArgs(2), - Run: func(_ *cobra.Command, args []string) { - removeUserFromDatabase(args[0], args[1]) - }, -} - -var listDBUsersCmd = &cobra.Command{ - Use: "list-users [database-id]", - Short: "List users associated with a database", - Long: `List all users who have access to read the credentials of a specific database.`, - Args: cobra.ExactArgs(1), - Run: func(_ *cobra.Command, args []string) { - listDatabaseUsers(args[0]) - }, -} - -var getCredentialsCmd = &cobra.Command{ - Use: "credentials", - Short: "Get database credentials", - Long: `Retrieve database credentials from the MCIAS system. -This command requires authentication with a username and token.`, - Run: func(_ *cobra.Command, args []string) { - getCredentials() - }, -} - -func init() { - rootCmd.AddCommand(databaseCmd) - databaseCmd.AddCommand(getCredentialsCmd) - databaseCmd.AddCommand(addDBUserCmd) - databaseCmd.AddCommand(removeDBUserCmd) - databaseCmd.AddCommand(listDBUsersCmd) - - getCredentialsCmd.Flags().StringVarP(&dbUsername, "username", "u", "", "Username for authentication") - getCredentialsCmd.Flags().StringVarP(&dbToken, "token", "t", "", "Authentication token") - if err := getCredentialsCmd.MarkFlagRequired("username"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err) - } - if err := getCredentialsCmd.MarkFlagRequired("token"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking token flag as required: %v\n", err) - } -} - -func getCredentials() { - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - serverAddr := viper.GetString("server") - if serverAddr == "" { - serverAddr = "http://localhost:8080" - } - - url := fmt.Sprintf("%s/v1/database/credentials?username=%s", serverAddr, dbUsername) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - logger.Fatalf("Failed to create request: %v", err) - } - - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", dbToken)) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - logger.Fatalf("Failed to send request: %v", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - logger.Fatalf("Failed to read response: %v", err) - } - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil { - logger.Fatalf("Error: %s", errResp.Error) - } else { - logger.Fatalf("Error: %s", resp.Status) - } - } - - var creds DatabaseCredentials - if unmarshalErr := json.Unmarshal(body, &creds); unmarshalErr != nil { - logger.Fatalf("Failed to parse response: %v", unmarshalErr) - } - - fmt.Println("Database Credentials:") - fmt.Printf("Host: %s\n", creds.Host) - fmt.Printf("Port: %d\n", creds.Port) - fmt.Printf("Name: %s\n", creds.Name) - fmt.Printf("User: %s\n", creds.User) - fmt.Printf("Password: %s\n", creds.Password) -} diff --git a/cmd/mcias/database_users.go b/cmd/mcias/database_users.go deleted file mode 100644 index 71c5293..0000000 --- a/cmd/mcias/database_users.go +++ /dev/null @@ -1,321 +0,0 @@ -package main - -import ( - "database/sql" - "fmt" - "log" - "os" - - "github.com/Masterminds/squirrel" - "github.com/oklog/ulid/v2" - "github.com/spf13/cobra" -) - -var ( - dbUserCmd = &cobra.Command{ - Use: "db-user", - Short: "Manage database user associations", - Long: `Commands for managing which users can access which database credentials.`, - } - - addDbUserCmd = &cobra.Command{ - Use: "add [database-id] [user-id]", - Short: "Associate a user with a database", - Long: `Associate a user with a database, allowing them to read its credentials.`, - Args: cobra.ExactArgs(2), - Run: func(cmd *cobra.Command, args []string) { - addUserToDatabase(args[0], args[1]) - }, - } - - removeDbUserCmd = &cobra.Command{ - Use: "remove [database-id] [user-id]", - Short: "Remove a user's association with a database", - Long: `Remove a user's association with a database, preventing them from reading its credentials.`, - Args: cobra.ExactArgs(2), - Run: func(cmd *cobra.Command, args []string) { - removeUserFromDatabase(args[0], args[1]) - }, - } - - listDbUsersCmd = &cobra.Command{ - Use: "list [database-id]", - Short: "List users associated with a database", - Long: `List all users who have access to read the credentials of a specific database.`, - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - listDatabaseUsers(args[0]) - }, - } - - listUserDbsCmd = &cobra.Command{ - Use: "list-user-dbs [user-id]", - Short: "List databases a user can access", - Long: `List all databases that a specific user has access to.`, - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - listUserDatabases(args[0]) - }, - } -) - -// nolint:gochecknoinits // This is a standard pattern in Cobra applications -func init() { - rootCmd.AddCommand(dbUserCmd) - dbUserCmd.AddCommand(addDbUserCmd) - dbUserCmd.AddCommand(removeDbUserCmd) - dbUserCmd.AddCommand(listDbUsersCmd) - dbUserCmd.AddCommand(listUserDbsCmd) -} - -// addUserToDatabase associates a user with a database, allowing them to read its credentials -func addUserToDatabase(databaseID, userID string) { - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - // Open the database - db, err := openDatabase(dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - // Check if the database exists - exists, err := checkDatabaseExists(db, databaseID) - if err != nil { - logger.Fatalf("Failed to check if database exists: %v", err) - } - if !exists { - logger.Fatalf("Database with ID %s does not exist", databaseID) - } - - // Check if the user exists - exists, err = checkUserExists(db, userID) - if err != nil { - logger.Fatalf("Failed to check if user exists: %v", err) - } - if !exists { - logger.Fatalf("User with ID %s does not exist", userID) - } - - // Check if the association already exists - exists, err = checkAssociationExists(db, databaseID, userID) - if err != nil { - logger.Fatalf("Failed to check if association exists: %v", err) - } - if exists { - logger.Printf("User %s already has access to database %s", userID, databaseID) - return - } - - // Create a new association - id := ulid.Make().String() - query, args, err := squirrel.Insert("database_users"). - Columns("id", "db_id", "uid"). - Values(id, databaseID, userID). - ToSql() - if err != nil { - logger.Fatalf("Failed to build query: %v", err) - } - - _, err = db.Exec(query, args...) - if err != nil { - logger.Fatalf("Failed to add user to database: %v", err) - } - - logger.Printf("User %s now has access to database %s", userID, databaseID) -} - -// removeUserFromDatabase removes a user's association with a database -func removeUserFromDatabase(databaseID, userID string) { - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - // Open the database - db, err := openDatabase(dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - // Check if the association exists - exists, err := checkAssociationExists(db, databaseID, userID) - if err != nil { - logger.Fatalf("Failed to check if association exists: %v", err) - } - if !exists { - logger.Printf("User %s does not have access to database %s", userID, databaseID) - return - } - - // Remove the association - query, args, err := squirrel.Delete("database_users"). - Where(squirrel.Eq{"db_id": databaseID, "uid": userID}). - ToSql() - if err != nil { - logger.Fatalf("Failed to build query: %v", err) - } - - _, err = db.Exec(query, args...) - if err != nil { - logger.Fatalf("Failed to remove user from database: %v", err) - } - - logger.Printf("User %s no longer has access to database %s", userID, databaseID) -} - -// listDatabaseUsers lists all users who have access to a specific database -func listDatabaseUsers(databaseID string) { - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - // Open the database - db, err := openDatabase(dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - // Check if the database exists - exists, err := checkDatabaseExists(db, databaseID) - if err != nil { - logger.Fatalf("Failed to check if database exists: %v", err) - } - if !exists { - logger.Fatalf("Database with ID %s does not exist", databaseID) - } - - // Get the database name for display - var dbName string - err = db.QueryRow("SELECT name FROM database WHERE id = ?", databaseID).Scan(&dbName) - if err != nil { - logger.Fatalf("Failed to get database name: %v", err) - } - - // Query all users who have access to this database - query, args, err := squirrel.Select("u.id", "u.user"). - From("users u"). - Join("database_users du ON u.id = du.uid"). - Where(squirrel.Eq{"du.db_id": databaseID}). - OrderBy("u.user"). - ToSql() - if err != nil { - logger.Fatalf("Failed to build query: %v", err) - } - - rows, err := db.Query(query, args...) - if err != nil { - logger.Fatalf("Failed to query users: %v", err) - } - defer rows.Close() - - fmt.Printf("Users with access to database '%s' (ID: %s):\n\n", dbName, databaseID) - - count := 0 - for rows.Next() { - var id, username string - if err := rows.Scan(&id, &username); err != nil { - logger.Fatalf("Failed to scan row: %v", err) - } - fmt.Printf(" %s (ID: %s)\n", username, id) - count++ - } - - if count == 0 { - fmt.Println(" No users have access to this database") - } else { - fmt.Printf("\nTotal: %d user(s)\n", count) - } -} - -// listUserDatabases lists all databases a specific user has access to -func listUserDatabases(userID string) { - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - // Open the database - db, err := openDatabase(dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - // Check if the user exists - exists, err := checkUserExists(db, userID) - if err != nil { - logger.Fatalf("Failed to check if user exists: %v", err) - } - if !exists { - logger.Fatalf("User with ID %s does not exist", userID) - } - - // Get the username for display - var username string - err = db.QueryRow("SELECT user FROM users WHERE id = ?", userID).Scan(&username) - if err != nil { - logger.Fatalf("Failed to get username: %v", err) - } - - // Query all databases this user has access to - query, args, err := squirrel.Select("d.id", "d.name", "d.host"). - From("database d"). - Join("database_users du ON d.id = du.db_id"). - Where(squirrel.Eq{"du.uid": userID}). - OrderBy("d.name"). - ToSql() - if err != nil { - logger.Fatalf("Failed to build query: %v", err) - } - - rows, err := db.Query(query, args...) - if err != nil { - logger.Fatalf("Failed to query databases: %v", err) - } - defer rows.Close() - - fmt.Printf("Databases accessible by user '%s' (ID: %s):\n\n", username, userID) - - count := 0 - for rows.Next() { - var id, name, host string - if err := rows.Scan(&id, &name, &host); err != nil { - logger.Fatalf("Failed to scan row: %v", err) - } - fmt.Printf(" %s (%s) (ID: %s)\n", name, host, id) - count++ - } - - if count == 0 { - fmt.Println(" This user does not have access to any databases") - } else { - fmt.Printf("\nTotal: %d database(s)\n", count) - } -} - -// Helper functions - -// checkDatabaseExists checks if a database with the given ID exists -func checkDatabaseExists(db *sql.DB, databaseID string) (bool, error) { - var count int - err := db.QueryRow("SELECT COUNT(*) FROM database WHERE id = ?", databaseID).Scan(&count) - if err != nil { - return false, err - } - return count > 0, nil -} - -// checkUserExists checks if a user with the given ID exists -func checkUserExists(db *sql.DB, userID string) (bool, error) { - var count int - err := db.QueryRow("SELECT COUNT(*) FROM users WHERE id = ?", userID).Scan(&count) - if err != nil { - return false, err - } - return count > 0, nil -} - -// checkAssociationExists checks if a user is already associated with a database -func checkAssociationExists(db *sql.DB, databaseID, userID string) (bool, error) { - var count int - err := db.QueryRow("SELECT COUNT(*) FROM database_users WHERE db_id = ? AND uid = ?", databaseID, userID).Scan(&count) - if err != nil { - return false, err - } - return count > 0, nil -} diff --git a/cmd/mcias/init.go b/cmd/mcias/init.go deleted file mode 100644 index ce4e0f2..0000000 --- a/cmd/mcias/init.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "database/sql" - "log" - "os" - - "git.wntrmute.dev/kyle/mcias/database" - _ "github.com/mattn/go-sqlite3" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var ( - schemaFile string -) - -var initCmd = &cobra.Command{ - Use: "init", - Short: "Initialize the MCIAS database", - Long: `Initialize the MCIAS database with the default schema or a custom schema file. -This command will create a new database file if it doesn't exist, -or initialize an existing database.`, - Run: func(cmd *cobra.Command, args []string) { - initializeDatabase() - }, -} - -func init() { - rootCmd.AddCommand(initCmd) - initCmd.Flags().StringVarP(&schemaFile, "schema", "s", "", "Path to a custom schema file (default: embedded schema)") -} - -func initializeDatabase() { - dbPath := viper.GetString("db") - - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - var schemaSQL string - if schemaFile != "" { - // Use custom schema file if provided - schemaBytes, err := os.ReadFile(schemaFile) - if err != nil { - logger.Fatalf("Failed to read custom schema file: %v", err) - } - schemaSQL = string(schemaBytes) - } else { - // Use embedded default schema - var err error - schemaSQL, err = database.DefaultSchema() - if err != nil { - logger.Fatalf("Failed to load default schema: %v", err) - } - } - - _, err = db.Exec(schemaSQL) - if err != nil { - logger.Fatalf("Failed to initialize database: %v", err) - } - - logger.Println("Database initialized successfully") -} diff --git a/cmd/mcias/main.go b/cmd/mcias/main.go deleted file mode 100644 index 7cdf8ed..0000000 --- a/cmd/mcias/main.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -import ( - "fmt" - "os" -) - -func main() { - if err := Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} diff --git a/cmd/mcias/migrate.go b/cmd/mcias/migrate.go deleted file mode 100644 index dd825b0..0000000 --- a/cmd/mcias/migrate.go +++ /dev/null @@ -1,174 +0,0 @@ -package main - -import ( - "database/sql" - "errors" - "fmt" - "log" - "os" - "path/filepath" - - "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database/sqlite3" - _ "github.com/golang-migrate/migrate/v4/source/file" - _ "github.com/mattn/go-sqlite3" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var ( - migrationsDir string - steps int -) - -var migrateCmd = &cobra.Command{ - Use: "migrate", - Short: "Manage database migrations", - Long: `Commands for managing database migrations in the MCIAS system.`, -} - -var migrateUpCmd = &cobra.Command{ - Use: "up [steps]", - Short: "Apply migrations", - Long: `Apply all or a specific number of migrations. -If steps is not provided, all pending migrations will be applied.`, - Run: func(cmd *cobra.Command, args []string) { - runMigration("up", steps) - }, -} - -var migrateDownCmd = &cobra.Command{ - Use: "down [steps]", - Short: "Revert migrations", - Long: `Revert all or a specific number of migrations. -If steps is not provided, all applied migrations will be reverted.`, - Run: func(cmd *cobra.Command, args []string) { - runMigration("down", steps) - }, -} - -var migrateVersionCmd = &cobra.Command{ - Use: "version", - Short: "Show current migration version", - Long: `Display the current migration version of the database.`, - Run: func(cmd *cobra.Command, args []string) { - showMigrationVersion() - }, -} - -func init() { - rootCmd.AddCommand(migrateCmd) - migrateCmd.AddCommand(migrateUpCmd) - migrateCmd.AddCommand(migrateDownCmd) - migrateCmd.AddCommand(migrateVersionCmd) - - migrateCmd.PersistentFlags().StringVarP(&migrationsDir, "migrations", "m", "database/migrations", "Directory containing migration files") - migrateCmd.PersistentFlags().IntVarP(&steps, "steps", "s", 0, "Number of migrations to apply or revert (0 means all)") -} - -func runMigration(direction string, steps int) { - dbPath := viper.GetString("db") - logger := log.New(os.Stdout, "MCIAS Migration: ", log.LstdFlags) - - absPath, err := filepath.Abs(migrationsDir) - if err != nil { - logger.Fatalf("Failed to get absolute path for migrations directory: %v", err) - } - - if _, err := os.Stat(absPath); os.IsNotExist(err) { - logger.Fatalf("Migrations directory does not exist: %s", absPath) - } - - db, err := openDatabase(dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - driver, err := sqlite3.WithInstance(db, &sqlite3.Config{}) - if err != nil { - logger.Fatalf("Failed to create migration driver: %v", err) - } - - m, err := migrate.NewWithDatabaseInstance( - fmt.Sprintf("file://%s", absPath), - "sqlite3", driver) - if err != nil { - logger.Fatalf("Failed to create migration instance: %v", err) - } - - if direction == "up" { - if steps > 0 { - err = m.Steps(steps) - } else { - err = m.Up() - } - if err != nil && !errors.Is(err, migrate.ErrNoChange) { - logger.Fatalf("Failed to apply migrations: %v", err) - } - logger.Println("Migrations applied successfully") - } else if direction == "down" { - if steps > 0 { - err = m.Steps(-steps) - } else { - err = m.Down() - } - if err != nil && !errors.Is(err, migrate.ErrNoChange) { - logger.Fatalf("Failed to revert migrations: %v", err) - } - logger.Println("Migrations reverted successfully") - } -} - -func showMigrationVersion() { - dbPath := viper.GetString("db") - logger := log.New(os.Stdout, "MCIAS Migration: ", log.LstdFlags) - - db, err := openDatabase(dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - driver, err := sqlite3.WithInstance(db, &sqlite3.Config{}) - if err != nil { - logger.Fatalf("Failed to create migration driver: %v", err) - } - - absPath, err := filepath.Abs(migrationsDir) - if err != nil { - logger.Fatalf("Failed to get absolute path for migrations directory: %v", err) - } - - m, err := migrate.NewWithDatabaseInstance( - fmt.Sprintf("file://%s", absPath), - "sqlite3", driver) - if err != nil { - logger.Fatalf("Failed to create migration instance: %v", err) - } - - version, dirty, err := m.Version() - if err != nil { - if errors.Is(err, migrate.ErrNilVersion) { - logger.Println("No migrations have been applied yet") - return - } - logger.Fatalf("Failed to get migration version: %v", err) - } - - logger.Printf("Current migration version: %d (dirty: %t)", version, dirty) -} - -func openDatabase(dbPath string) (*sql.DB, error) { - dbDir := filepath.Dir(dbPath) - if err := os.MkdirAll(dbDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create database directory: %w", err) - } - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) - } - - return db, nil -} diff --git a/cmd/mcias/permission.go b/cmd/mcias/permission.go deleted file mode 100644 index a948600..0000000 --- a/cmd/mcias/permission.go +++ /dev/null @@ -1,230 +0,0 @@ -package main - -import ( - "database/sql" - "errors" - "fmt" - "log" - "os" - "strings" - - "github.com/oklog/ulid/v2" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var ( - permissionRole string - permissionResource string - permissionAction string -) - -var permissionCmd = &cobra.Command{ - Use: "permission", - Short: "Manage permissions", - Long: `Commands for managing permissions in the MCIAS system.`, -} - -var listPermissionsCmd = &cobra.Command{ - Use: "list", - Short: "List all permissions", - Long: `List all permissions in the MCIAS system.`, - Run: func(cmd *cobra.Command, args []string) { - listPermissions() - }, -} - -var grantPermissionCmd = &cobra.Command{ - Use: "grant", - Short: "Grant a permission to a role", - Long: `Grant a permission to a role in the MCIAS system.`, - Run: func(cmd *cobra.Command, args []string) { - grantPermission() - }, -} - -var revokePermissionCmd = &cobra.Command{ - Use: "revoke", - Short: "Revoke a permission from a role", - Long: `Revoke a permission from a role in the MCIAS system.`, - Run: func(cmd *cobra.Command, args []string) { - revokePermission() - }, -} - -// nolint:gochecknoinits // This is a standard pattern in Cobra applications -func init() { - rootCmd.AddCommand(permissionCmd) - permissionCmd.AddCommand(listPermissionsCmd) - permissionCmd.AddCommand(grantPermissionCmd) - permissionCmd.AddCommand(revokePermissionCmd) - - grantPermissionCmd.Flags().StringVar(&permissionRole, "role", "", "Name of the role to grant the permission to") - grantPermissionCmd.Flags().StringVar(&permissionResource, "resource", "", "Resource for the permission") - grantPermissionCmd.Flags().StringVar(&permissionAction, "action", "", "Action for the permission") - if err := grantPermissionCmd.MarkFlagRequired("role"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking role flag as required: %v\n", err) - } - if err := grantPermissionCmd.MarkFlagRequired("resource"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking resource flag as required: %v\n", err) - } - if err := grantPermissionCmd.MarkFlagRequired("action"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking action flag as required: %v\n", err) - } - - revokePermissionCmd.Flags().StringVar(&permissionRole, "role", "", "Name of the role to revoke the permission from") - revokePermissionCmd.Flags().StringVar(&permissionResource, "resource", "", "Resource for the permission") - revokePermissionCmd.Flags().StringVar(&permissionAction, "action", "", "Action for the permission") - if err := revokePermissionCmd.MarkFlagRequired("role"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking role flag as required: %v\n", err) - } - if err := revokePermissionCmd.MarkFlagRequired("resource"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking resource flag as required: %v\n", err) - } - if err := revokePermissionCmd.MarkFlagRequired("action"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking action flag as required: %v\n", err) - } -} - -func listPermissions() { - dbPath := viper.GetString("db") - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - rows, err := db.Query("SELECT id, resource, action, description FROM permissions ORDER BY resource, action") - if err != nil { - logger.Fatalf("Failed to query permissions: %v", err) - } - defer rows.Close() - - fmt.Printf("%-24s %-20s %-15s %-30s\n", "ID", "RESOURCE", "ACTION", "DESCRIPTION") - fmt.Println(strings.Repeat("-", 90)) - for rows.Next() { - var id, resource, action, description string - if scanErr := rows.Scan(&id, &resource, &action, &description); scanErr != nil { - logger.Fatalf("Failed to scan permission row: %v", scanErr) - } - fmt.Printf("%-24s %-20s %-15s %-30s\n", id, resource, action, description) - } - - if rowErr := rows.Err(); rowErr != nil { - logger.Fatalf("Error iterating permission rows: %v", rowErr) - } -} - -func grantPermission() { - dbPath := viper.GetString("db") - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - // Get role ID - var roleID string - err = db.QueryRow("SELECT id FROM roles WHERE role = ?", permissionRole).Scan(&roleID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - logger.Fatalf("Role %s not found", permissionRole) - } - logger.Fatalf("Failed to get role ID: %v", err) - } - - // Get permission ID - var permissionID string - err = db.QueryRow("SELECT id FROM permissions WHERE resource = ? AND action = ?", - permissionResource, permissionAction).Scan(&permissionID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - logger.Fatalf("Permission with resource '%s' and action '%s' not found", - permissionResource, permissionAction) - } - logger.Fatalf("Failed to get permission ID: %v", err) - } - - // Check if role already has this permission - var count int - err = db.QueryRow("SELECT COUNT(*) FROM role_permissions WHERE rid = ? AND pid = ?", - roleID, permissionID).Scan(&count) - if err != nil { - logger.Fatalf("Failed to check if role has permission: %v", err) - } - if count > 0 { - logger.Fatalf("Role %s already has permission %s:%s", - permissionRole, permissionResource, permissionAction) - } - - // Generate a new ID for the role-permission relationship - id := ulid.Make().String() - - // Grant permission to role - _, err = db.Exec("INSERT INTO role_permissions (id, rid, pid) VALUES (?, ?, ?)", - id, roleID, permissionID) - if err != nil { - logger.Fatalf("Failed to grant permission: %v", err) - } - - fmt.Printf("Permission %s:%s granted to role %s successfully\n", - permissionResource, permissionAction, permissionRole) -} - -func revokePermission() { - dbPath := viper.GetString("db") - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - // Get role ID - var roleID string - err = db.QueryRow("SELECT id FROM roles WHERE role = ?", permissionRole).Scan(&roleID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - logger.Fatalf("Role %s not found", permissionRole) - } - logger.Fatalf("Failed to get role ID: %v", err) - } - - // Get permission ID - var permissionID string - err = db.QueryRow("SELECT id FROM permissions WHERE resource = ? AND action = ?", - permissionResource, permissionAction).Scan(&permissionID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - logger.Fatalf("Permission with resource '%s' and action '%s' not found", - permissionResource, permissionAction) - } - logger.Fatalf("Failed to get permission ID: %v", err) - } - - // Check if role has this permission - var count int - err = db.QueryRow("SELECT COUNT(*) FROM role_permissions WHERE rid = ? AND pid = ?", - roleID, permissionID).Scan(&count) - if err != nil { - logger.Fatalf("Failed to check if role has permission: %v", err) - } - if count == 0 { - logger.Fatalf("Role %s does not have permission %s:%s", - permissionRole, permissionResource, permissionAction) - } - - // Revoke permission from role - _, err = db.Exec("DELETE FROM role_permissions WHERE rid = ? AND pid = ?", roleID, permissionID) - if err != nil { - logger.Fatalf("Failed to revoke permission: %v", err) - } - - fmt.Printf("Permission %s:%s revoked from role %s successfully\n", - permissionResource, permissionAction, permissionRole) -} diff --git a/cmd/mcias/role.go b/cmd/mcias/role.go deleted file mode 100644 index fc679f9..0000000 --- a/cmd/mcias/role.go +++ /dev/null @@ -1,256 +0,0 @@ -package main - -import ( - "database/sql" - "errors" - "fmt" - "log" - "os" - "strings" - - "github.com/oklog/ulid/v2" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var ( - roleName string - roleUser string -) - -var roleCmd = &cobra.Command{ - Use: "role", - Short: "Manage roles", - Long: `Commands for managing roles in the MCIAS system.`, -} - -var addRoleCmd = &cobra.Command{ - Use: "add", - Short: "Add a new role", - Long: `Add a new role to the MCIAS system.`, - Run: func(cmd *cobra.Command, args []string) { - addRole() - }, -} - -var listRolesCmd = &cobra.Command{ - Use: "list", - Short: "List all roles", - Long: `List all roles in the MCIAS system.`, - Run: func(cmd *cobra.Command, args []string) { - listRoles() - }, -} - -var assignRoleCmd = &cobra.Command{ - Use: "assign", - Short: "Assign a role to a user", - Long: `Assign a role to a user in the MCIAS system.`, - Run: func(cmd *cobra.Command, args []string) { - assignRole() - }, -} - -var revokeRoleCmd = &cobra.Command{ - Use: "revoke", - Short: "Revoke a role from a user", - Long: `Revoke a role from a user in the MCIAS system.`, - Run: func(cmd *cobra.Command, args []string) { - revokeRole() - }, -} - -func init() { - rootCmd.AddCommand(roleCmd) - roleCmd.AddCommand(addRoleCmd) - roleCmd.AddCommand(listRolesCmd) - roleCmd.AddCommand(assignRoleCmd) - roleCmd.AddCommand(revokeRoleCmd) - - addRoleCmd.Flags().StringVar(&roleName, "name", "", "Name of the role") - if err := addRoleCmd.MarkFlagRequired("name"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking name flag as required: %v\n", err) - } - - assignRoleCmd.Flags().StringVar(&roleUser, "user", "", "Username to assign the role to") - assignRoleCmd.Flags().StringVar(&roleName, "role", "", "Name of the role to assign") - if err := assignRoleCmd.MarkFlagRequired("user"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking user flag as required: %v\n", err) - } - if err := assignRoleCmd.MarkFlagRequired("role"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking role flag as required: %v\n", err) - } - - revokeRoleCmd.Flags().StringVar(&roleUser, "user", "", "Username to revoke the role from") - revokeRoleCmd.Flags().StringVar(&roleName, "role", "", "Name of the role to revoke") - if err := revokeRoleCmd.MarkFlagRequired("user"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking user flag as required: %v\n", err) - } - if err := revokeRoleCmd.MarkFlagRequired("role"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking role flag as required: %v\n", err) - } -} - -func addRole() { - dbPath := viper.GetString("db") - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - // Check if role already exists - var count int - err = db.QueryRow("SELECT COUNT(*) FROM roles WHERE role = ?", roleName).Scan(&count) - if err != nil { - logger.Fatalf("Failed to check if role exists: %v", err) - } - if count > 0 { - logger.Fatalf("Role %s already exists", roleName) - } - - // Generate a new ID for the role - id := ulid.Make().String() - - // Insert the new role - _, err = db.Exec("INSERT INTO roles (id, role) VALUES (?, ?)", id, roleName) - if err != nil { - logger.Fatalf("Failed to insert role: %v", err) - } - - fmt.Printf("Role %s added successfully with ID %s\n", roleName, id) -} - -func listRoles() { - dbPath := viper.GetString("db") - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - rows, err := db.Query("SELECT id, role FROM roles ORDER BY role") - if err != nil { - logger.Fatalf("Failed to query roles: %v", err) - } - defer rows.Close() - - fmt.Printf("%-24s %-30s\n", "ID", "ROLE") - fmt.Println(strings.Repeat("-", 55)) - for rows.Next() { - var id, role string - if err := rows.Scan(&id, &role); err != nil { - logger.Fatalf("Failed to scan role row: %v", err) - } - fmt.Printf("%-24s %-30s\n", id, role) - } - - if err := rows.Err(); err != nil { - logger.Fatalf("Error iterating role rows: %v", err) - } -} - -func assignRole() { - dbPath := viper.GetString("db") - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - // Get user ID - var userID string - err = db.QueryRow("SELECT id FROM users WHERE user = ?", roleUser).Scan(&userID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - logger.Fatalf("User %s not found", roleUser) - } - logger.Fatalf("Failed to get user ID: %v", err) - } - - // Get role ID - var roleID string - err = db.QueryRow("SELECT id FROM roles WHERE role = ?", roleName).Scan(&roleID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - logger.Fatalf("Role %s not found", roleName) - } - logger.Fatalf("Failed to get role ID: %v", err) - } - - // Check if user already has this role - var count int - err = db.QueryRow("SELECT COUNT(*) FROM user_roles WHERE uid = ? AND rid = ?", userID, roleID).Scan(&count) - if err != nil { - logger.Fatalf("Failed to check if user has role: %v", err) - } - if count > 0 { - logger.Fatalf("User %s already has role %s", roleUser, roleName) - } - - // Generate a new ID for the user-role relationship - id := ulid.Make().String() - - // Assign role to user - _, err = db.Exec("INSERT INTO user_roles (id, uid, rid) VALUES (?, ?, ?)", id, userID, roleID) - if err != nil { - logger.Fatalf("Failed to assign role: %v", err) - } - - fmt.Printf("Role %s assigned to user %s successfully\n", roleName, roleUser) -} - -func revokeRole() { - dbPath := viper.GetString("db") - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - // Get user ID - var userID string - err = db.QueryRow("SELECT id FROM users WHERE user = ?", roleUser).Scan(&userID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - logger.Fatalf("User %s not found", roleUser) - } - logger.Fatalf("Failed to get user ID: %v", err) - } - - // Get role ID - var roleID string - err = db.QueryRow("SELECT id FROM roles WHERE role = ?", roleName).Scan(&roleID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - logger.Fatalf("Role %s not found", roleName) - } - logger.Fatalf("Failed to get role ID: %v", err) - } - - // Check if user has this role - var count int - err = db.QueryRow("SELECT COUNT(*) FROM user_roles WHERE uid = ? AND rid = ?", userID, roleID).Scan(&count) - if err != nil { - logger.Fatalf("Failed to check if user has role: %v", err) - } - if count == 0 { - logger.Fatalf("User %s does not have role %s", roleUser, roleName) - } - - // Revoke role from user - _, err = db.Exec("DELETE FROM user_roles WHERE uid = ? AND rid = ?", userID, roleID) - if err != nil { - logger.Fatalf("Failed to revoke role: %v", err) - } - - fmt.Printf("Role %s revoked from user %s successfully\n", roleName, roleUser) -} diff --git a/cmd/mcias/root.go b/cmd/mcias/root.go deleted file mode 100644 index 86ae64a..0000000 --- a/cmd/mcias/root.go +++ /dev/null @@ -1,71 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var ( - cfgFile string - dbPath string - addr string - - rootCmd = &cobra.Command{ - Use: "mcias", - Short: "MCIAS - Metacircular Identity and Access System", - Long: `MCIAS is the metacircular identity and access system, -providing identity and authentication across metacircular projects. - -It currently provides the following across metacircular services: -1. User password authentication -2. User token authentication -3. Database credential authentication`, - } -) - -func Execute() error { - // Setup commands and flags - setupRootCommand() - setupTOTPCommands() - // The migrate command is already set up in its init function - - // Execute the root command - return rootCmd.Execute() -} - -// setupRootCommand initializes the root command and its flags -func setupRootCommand() { - cobra.OnInitialize(initConfig) - - rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.mcias.yaml)") - rootCmd.PersistentFlags().StringVar(&dbPath, "db", "mcias.db", "Path to SQLite database file") - rootCmd.PersistentFlags().StringVar(&addr, "addr", ":5000", "Address to listen on") - if err := viper.BindPFlag("db", rootCmd.PersistentFlags().Lookup("db")); err != nil { - fmt.Fprintf(os.Stderr, "Error binding db flag: %v\n", err) - } - if err := viper.BindPFlag("addr", rootCmd.PersistentFlags().Lookup("addr")); err != nil { - fmt.Fprintf(os.Stderr, "Error binding addr flag: %v\n", err) - } -} - -func initConfig() { - if cfgFile != "" { - viper.SetConfigFile(cfgFile) - } else { - home, err := os.UserHomeDir() - cobra.CheckErr(err) - - viper.AddConfigPath(home) - viper.SetConfigType("yaml") - viper.SetConfigName(".mcias") - } - - viper.AutomaticEnv() - - if err := viper.ReadInConfig(); err == nil { - fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) - } -} diff --git a/cmd/mcias/server.go b/cmd/mcias/server.go deleted file mode 100644 index 7adda4a..0000000 --- a/cmd/mcias/server.go +++ /dev/null @@ -1,46 +0,0 @@ -package main - -import ( - "database/sql" - "log" - "os" - - "git.wntrmute.dev/kyle/mcias/api" - _ "github.com/mattn/go-sqlite3" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var serverCmd = &cobra.Command{ - Use: "server", - Short: "Start the MCIAS server", - Long: `Start the MCIAS server which provides authentication services. -The server will listen on the specified address and connect to the -specified database.`, - Run: func(cmd *cobra.Command, args []string) { - runServer() - }, -} - -func init() { - rootCmd.AddCommand(serverCmd) -} - -func runServer() { - dbPath := viper.GetString("db") - addr := viper.GetString("addr") - - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - server := api.NewServer(db, logger) - logger.Printf("Starting MCIAS server on %s", addr) - if err := server.Start(addr); err != nil { - logger.Fatalf("Server error: %v", err) - } -} diff --git a/cmd/mcias/token.go b/cmd/mcias/token.go deleted file mode 100644 index e113d9b..0000000 --- a/cmd/mcias/token.go +++ /dev/null @@ -1,154 +0,0 @@ -package main - -import ( - "crypto/rand" - "database/sql" - "encoding/hex" - "errors" - "fmt" - "log" - "os" - "strings" - "time" - - _ "github.com/mattn/go-sqlite3" - "github.com/oklog/ulid/v2" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var ( - tokenUsername string - tokenDuration int64 -) - -var tokenCmd = &cobra.Command{ - Use: "token", - Short: "Manage tokens", - Long: `Commands for managing authentication tokens in the MCIAS system.`, -} - -var addTokenCmd = &cobra.Command{ - Use: "add", - Short: "Add a new token for a user", - Long: `Add a new authentication token for a user in the MCIAS system. -This command requires a username and optionally a duration in hours.`, - Run: func(cmd *cobra.Command, args []string) { - addToken() - }, -} - -var listTokensCmd = &cobra.Command{ - Use: "list", - Short: "List all tokens", - Long: `List all authentication tokens in the MCIAS system.`, - Run: func(cmd *cobra.Command, args []string) { - listTokens() - }, -} - -func init() { - rootCmd.AddCommand(tokenCmd) - tokenCmd.AddCommand(addTokenCmd) - tokenCmd.AddCommand(listTokensCmd) - - addTokenCmd.Flags().StringVarP(&tokenUsername, "username", "u", "", "Username to create token for") - addTokenCmd.Flags().Int64VarP(&tokenDuration, "duration", "d", 24, "Token duration in hours (default 24)") - if err := addTokenCmd.MarkFlagRequired("username"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err) - } -} - -func addToken() { - dbPath := viper.GetString("db") - - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - var userID string - err = db.QueryRow("SELECT id FROM users WHERE user = ?", tokenUsername).Scan(&userID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - logger.Fatalf("User %s does not exist", tokenUsername) - } - logger.Fatalf("Failed to check if user exists: %v", err) - } - - // 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() - - query := `INSERT INTO tokens (id, uid, token, expires) VALUES (?, ?, ?, ?)` - tokenID := ulid.Make().String() - _, err = db.Exec(query, tokenID, userID, token, expires) - if err != nil { - logger.Fatalf("Failed to insert token into database: %v", err) - } - - expiresTime := time.Unix(expires, 0).Format(time.RFC3339) - - fmt.Printf("Token created successfully for user %s\n", tokenUsername) - fmt.Printf("Token: %s\n", token) - fmt.Printf("Expires: %s\n", expiresTime) -} - -func listTokens() { - dbPath := viper.GetString("db") - - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - query := ` - SELECT t.id, t.token, t.expires, u.user - FROM tokens t - JOIN users u ON t.uid = u.id - ORDER BY t.expires DESC - ` - rows, err := db.Query(query) - if err != nil { - logger.Fatalf("Failed to query tokens: %v", err) - } - defer rows.Close() - - fmt.Printf("%-24s %-30s %-20s %-20s %-10s\n", "ID", "TOKEN", "USERNAME", "EXPIRES", "STATUS") - fmt.Println(strings.Repeat("-", 100)) - - now := time.Now().Unix() - for rows.Next() { - var id, token, username string - var expires int64 - if err := rows.Scan(&id, &token, &expires, &username); err != nil { - logger.Fatalf("Failed to scan token row: %v", err) - } - - expiresTime := time.Unix(expires, 0).Format(time.RFC3339) - - status := "ACTIVE" - if expires > 0 && expires < now { - status = "EXPIRED" - } - - fmt.Printf("%-24s %-30s %-20s %-20s %-10s\n", id, token, username, expiresTime, status) - } - - if err := rows.Err(); err != nil { - logger.Fatalf("Error iterating token rows: %v", err) - } -} diff --git a/cmd/mcias/totp.go b/cmd/mcias/totp.go deleted file mode 100644 index 2215026..0000000 --- a/cmd/mcias/totp.go +++ /dev/null @@ -1,272 +0,0 @@ -package main - -import ( - "database/sql" - "errors" - "fmt" - "log" - "os" - - "git.wntrmute.dev/kyle/mcias/data" - _ "github.com/mattn/go-sqlite3" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -const ( - userQuery = `SELECT id, created, user, password, salt, totp_secret FROM users WHERE user = ?` -) - -var ( - totpUsername string - totpCode string - qrCodeOutput string - issuer string -) - -var totpCmd = &cobra.Command{ - Use: "totp", - Short: "Manage TOTP authentication", - Long: `Commands for managing TOTP (Time-based One-Time Password) authentication in the MCIAS system.`, -} - -var enableTOTPCmd = &cobra.Command{ - Use: "enable", - Short: "Enable TOTP for a user", - Long: `Enable TOTP (Time-based One-Time Password) authentication for a user in the MCIAS system. -This command requires a username.`, - Run: func(cmd *cobra.Command, args []string) { - enableTOTP() - }, -} - -var validateTOTPCmd = &cobra.Command{ - Use: "validate", - Short: "Validate a TOTP code", - Long: `Validate a TOTP code for a user in the MCIAS system. -This command requires a username and a TOTP code.`, - Run: func(cmd *cobra.Command, args []string) { - validateTOTP() - }, -} - -var addTOTPCmd = &cobra.Command{ - Use: "add", - Short: "Add a new TOTP token for a user", - Long: `Add a new TOTP (Time-based One-Time Password) token for a user in the MCIAS system. -This command requires a username. It will emit the secret, and optionally output a QR code image file.`, - Run: func(cmd *cobra.Command, args []string) { - addTOTP() - }, -} - -func setupTOTPCommands() { - rootCmd.AddCommand(totpCmd) - totpCmd.AddCommand(enableTOTPCmd) - totpCmd.AddCommand(validateTOTPCmd) - totpCmd.AddCommand(addTOTPCmd) - - enableTOTPCmd.Flags().StringVarP(&totpUsername, "username", "u", "", "Username to enable TOTP for") - if err := enableTOTPCmd.MarkFlagRequired("username"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err) - } - - validateTOTPCmd.Flags().StringVarP(&totpUsername, "username", "u", "", "Username to validate TOTP code for") - validateTOTPCmd.Flags().StringVarP(&totpCode, "code", "c", "", "TOTP code to validate") - if err := validateTOTPCmd.MarkFlagRequired("username"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err) - } - if err := validateTOTPCmd.MarkFlagRequired("code"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking code flag as required: %v\n", err) - } - - addTOTPCmd.Flags().StringVarP(&totpUsername, "username", "u", "", "Username to add TOTP token for") - addTOTPCmd.Flags().StringVarP(&qrCodeOutput, "qr-output", "q", "", "Path to save QR code image (optional)") - addTOTPCmd.Flags().StringVarP(&issuer, "issuer", "i", "MCIAS", "Issuer name for TOTP token (optional)") - if err := addTOTPCmd.MarkFlagRequired("username"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err) - } -} - -func enableTOTP() { - dbPath := viper.GetString("db") - - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - var userID string - var created int64 - var username string - var password, salt []byte - var totpSecret sql.NullString - - err = db.QueryRow(userQuery, totpUsername).Scan(&userID, &created, &username, &password, &salt, &totpSecret) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - logger.Fatalf("User %s does not exist", totpUsername) - } - logger.Fatalf("Failed to get user: %v", err) - } - - // Check if TOTP is already enabled - if totpSecret.Valid && totpSecret.String != "" { - logger.Fatalf("TOTP is already enabled for user %s", totpUsername) - } - - // Create a user object - user := &data.User{ - ID: userID, - Created: created, - User: username, - Password: password, - Salt: salt, - } - - // Generate a TOTP secret - secret, err := user.GenerateTOTPSecret() - if err != nil { - logger.Fatalf("Failed to generate TOTP secret: %v", err) - } - - // Update the user in the database - updateQuery := `UPDATE users SET totp_secret = ? WHERE id = ?` - _, err = db.Exec(updateQuery, secret, userID) - if err != nil { - logger.Fatalf("Failed to update user: %v", err) - } - - fmt.Printf("TOTP enabled for user %s\n", totpUsername) - fmt.Printf("Secret: %s\n", secret) - fmt.Println("Please save this secret in your authenticator app.") - fmt.Println("You will need to provide a TOTP code when logging in.") -} - -func validateTOTP() { - dbPath := viper.GetString("db") - - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - // Get the user from the database - var userID string - var created int64 - var username string - var password, salt []byte - var totpSecret sql.NullString - - err = db.QueryRow(userQuery, totpUsername).Scan(&userID, &created, &username, &password, &salt, &totpSecret) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - logger.Fatalf("User %s does not exist", totpUsername) - } - logger.Fatalf("Failed to get user: %v", err) - } - - // Check if TOTP is enabled - if !totpSecret.Valid || totpSecret.String == "" { - logger.Fatalf("TOTP is not enabled for user %s", totpUsername) - } - - // Create a user object - user := &data.User{ - ID: userID, - Created: created, - User: username, - Password: password, - Salt: salt, - TOTPSecret: totpSecret.String, - } - - // Validate the TOTP code - valid, err := user.ValidateTOTPCode(totpCode) - if err != nil { - logger.Fatalf("Failed to validate TOTP code: %v", err) - } - - // Close the database before potentially exiting - db.Close() - - if valid { - fmt.Println("TOTP code is valid") - } else { - fmt.Println("TOTP code is invalid") - os.Exit(1) - } -} - -func addTOTP() { - dbPath := viper.GetString("db") - - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - // Get the user from the database - var userID string - var created int64 - var username string - var password, salt []byte - var totpSecret sql.NullString - - err = db.QueryRow(userQuery, totpUsername).Scan(&userID, &created, &username, &password, &salt, &totpSecret) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - logger.Fatalf("User %s does not exist", totpUsername) - } - logger.Fatalf("Failed to get user: %v", err) - } - - // Check if TOTP is already enabled - if totpSecret.Valid && totpSecret.String != "" { - logger.Fatalf("TOTP is already enabled for user %s", totpUsername) - } - - // Create a user object - user := &data.User{ - ID: userID, - Created: created, - User: username, - Password: password, - Salt: salt, - } - - // Generate a TOTP secret - secret, err := user.GenerateTOTPSecret() - if err != nil { - logger.Fatalf("Failed to generate TOTP secret: %v", err) - } - - // Update the user in the database - updateQuery := `UPDATE users SET totp_secret = ? WHERE id = ?` - _, err = db.Exec(updateQuery, secret, userID) - if err != nil { - logger.Fatalf("Failed to update user: %v", err) - } - - fmt.Printf("TOTP token added for user %s\n", totpUsername) - fmt.Printf("Secret: %s\n", secret) - fmt.Println("Please save this secret in your authenticator app.") - - // Generate QR code if output path is specified - if qrCodeOutput != "" { - err = data.GenerateTOTPQRCode(secret, username, issuer, qrCodeOutput) - if err != nil { - logger.Fatalf("Failed to generate QR code: %v", err) - } - fmt.Printf("QR code saved to %s\n", qrCodeOutput) - } -} diff --git a/cmd/mcias/user.go b/cmd/mcias/user.go deleted file mode 100644 index 3e39156..0000000 --- a/cmd/mcias/user.go +++ /dev/null @@ -1,134 +0,0 @@ -package main - -import ( - "database/sql" - "fmt" - "log" - "os" - "strings" - "time" - - "git.wntrmute.dev/kyle/mcias/data" - _ "github.com/mattn/go-sqlite3" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var ( - username string - password string -) - -var userCmd = &cobra.Command{ - Use: "user", - Short: "Manage users", - Long: `Commands for managing users in the MCIAS system.`, -} - -var addUserCmd = &cobra.Command{ - Use: "add", - Short: "Add a new user", - Long: `Add a new user to the MCIAS system. -This command requires a username and password.`, - Run: func(cmd *cobra.Command, args []string) { - addUser() - }, -} - -var listUsersCmd = &cobra.Command{ - Use: "list", - Short: "List all users", - Long: `List all users in the MCIAS system.`, - Run: func(cmd *cobra.Command, args []string) { - listUsers() - }, -} - -func init() { - rootCmd.AddCommand(userCmd) - userCmd.AddCommand(addUserCmd) - userCmd.AddCommand(listUsersCmd) - - addUserCmd.Flags().StringVarP(&username, "username", "u", "", "Username for the new user") - addUserCmd.Flags().StringVarP(&password, "password", "p", "", "Password for the new user") - if err := addUserCmd.MarkFlagRequired("username"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err) - } - if err := addUserCmd.MarkFlagRequired("password"); err != nil { - fmt.Fprintf(os.Stderr, "Error marking password flag as required: %v\n", err) - } -} - -func addUser() { - dbPath := viper.GetString("db") - - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - var count int - err = db.QueryRow("SELECT COUNT(*) FROM users WHERE user = ?", username).Scan(&count) - if err != nil { - logger.Fatalf("Failed to check if user exists: %v", err) - } - if count > 0 { - logger.Fatalf("User %s already exists", username) - } - - user := &data.User{} - login := &data.Login{ - User: username, - Password: password, - } - - if err := user.Register(login); err != nil { - logger.Fatalf("Failed to register user: %v", err) - } - - query := `INSERT INTO users (id, created, user, password, salt) VALUES (?, ?, ?, ?, ?)` - _, err = db.Exec(query, user.ID, user.Created, user.User, user.Password, user.Salt) - if err != nil { - logger.Fatalf("Failed to insert user into database: %v", err) - } - - fmt.Printf("User %s added successfully with ID %s\n", user.User, user.ID) -} - -func listUsers() { - dbPath := viper.GetString("db") - - logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags) - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - logger.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - rows, err := db.Query("SELECT id, created, user FROM users ORDER BY user") - if err != nil { - logger.Fatalf("Failed to query users: %v", err) - } - defer rows.Close() - - fmt.Printf("%-24s %-30s %-20s\n", "ID", "USERNAME", "CREATED") - fmt.Println(strings.Repeat("-", 76)) - for rows.Next() { - var id string - var created int64 - var username string - if err := rows.Scan(&id, &created, &username); err != nil { - logger.Fatalf("Failed to scan user row: %v", err) - } - createdTime := time.Unix(created, 0).Format(time.RFC3339) - fmt.Printf("%-24s %-30s %-20s\n", id, username, createdTime) - } - - if err := rows.Err(); err != nil { - logger.Fatalf("Error iterating user rows: %v", err) - } -} diff --git a/data/auth.go b/data/auth.go deleted file mode 100644 index 79eea5b..0000000 --- a/data/auth.go +++ /dev/null @@ -1,183 +0,0 @@ -package data - -import ( - "database/sql" - "fmt" - - "github.com/oklog/ulid/v2" -) - -const ( - // Constants for error messages - errScanPermission = "failed to scan permission: %w" - errIteratePermissions = "error iterating permissions: %w" - - // Constants for comparison - zeroCount = 0 -) - -// Permission represents a system permission. -type Permission struct { - ID string - Resource string - Action string - Description string -} - -// AuthorizationService provides methods for checking user permissions. -type AuthorizationService struct { - db *sql.DB -} - -// NewAuthorizationService creates a new authorization service. -func NewAuthorizationService(db *sql.DB) *AuthorizationService { - return &AuthorizationService{db: db} -} - -// UserHasPermission checks if a user has a specific permission for a resource and action -func (a *AuthorizationService) UserHasPermission(userID, resource, action string) (bool, error) { - query := ` - SELECT COUNT(*) FROM permissions p - JOIN role_permissions rp ON p.id = rp.pid - JOIN user_roles ur ON rp.rid = ur.rid - WHERE ur.uid = ? AND p.resource = ? AND p.action = ? - ` - - var count int - err := a.db.QueryRow(query, userID, resource, action).Scan(&count) - if err != nil { - return false, fmt.Errorf("failed to check user permission: %w", err) - } - - return count > zeroCount, nil -} - -// GetUserPermissions returns all permissions for a user based on their roles. -func (a *AuthorizationService) GetUserPermissions(userID string) ([]Permission, error) { - query := ` - SELECT DISTINCT p.id, p.resource, p.action, p.description FROM permissions p - JOIN role_permissions rp ON p.id = rp.pid - JOIN user_roles ur ON rp.rid = ur.rid - WHERE ur.uid = ? - ` - - rows, err := a.db.Query(query, userID) - if err != nil { - return nil, fmt.Errorf("failed to get user permissions: %w", err) - } - defer rows.Close() - - var permissions []Permission - for rows.Next() { - var perm Permission - if scanErr := rows.Scan(&perm.ID, &perm.Resource, &perm.Action, &perm.Description); scanErr != nil { - return nil, fmt.Errorf(errScanPermission, scanErr) - } - permissions = append(permissions, perm) - } - - if rowErr := rows.Err(); rowErr != nil { - return nil, fmt.Errorf(errIteratePermissions, rowErr) - } - - return permissions, nil -} - -// GetRolePermissions returns all permissions for a specific role. -func (a *AuthorizationService) GetRolePermissions(roleID string) ([]Permission, error) { - query := ` - SELECT p.id, p.resource, p.action, p.description FROM permissions p - JOIN role_permissions rp ON p.id = rp.pid - WHERE rp.rid = ? - ` - - rows, err := a.db.Query(query, roleID) - if err != nil { - return nil, fmt.Errorf("failed to get role permissions: %w", err) - } - defer rows.Close() - - var permissions []Permission - for rows.Next() { - var perm Permission - if scanErr := rows.Scan(&perm.ID, &perm.Resource, &perm.Action, &perm.Description); scanErr != nil { - return nil, fmt.Errorf(errScanPermission, scanErr) - } - permissions = append(permissions, perm) - } - - if rowErr := rows.Err(); rowErr != nil { - return nil, fmt.Errorf(errIteratePermissions, rowErr) - } - - return permissions, nil -} - -// GrantPermissionToRole grants a permission to a role -func (a *AuthorizationService) GrantPermissionToRole(roleID, permissionID string) error { - // Check if the role-permission relationship already exists - checkQuery := `SELECT COUNT(*) FROM role_permissions WHERE rid = ? AND pid = ?` - var count int - err := a.db.QueryRow(checkQuery, roleID, permissionID).Scan(&count) - if err != nil { - return fmt.Errorf("failed to check role permission: %w", err) - } - - if count > 0 { - return nil // Permission already granted - } - - // Generate a new ID for the role-permission relationship - id := GenerateID() - - // Insert the new role-permission relationship - insertQuery := `INSERT INTO role_permissions (id, rid, pid) VALUES (?, ?, ?)` - _, err = a.db.Exec(insertQuery, id, roleID, permissionID) - if err != nil { - return fmt.Errorf("failed to grant permission to role: %w", err) - } - - return nil -} - -// RevokePermissionFromRole revokes a permission from a role -func (a *AuthorizationService) RevokePermissionFromRole(roleID, permissionID string) error { - query := `DELETE FROM role_permissions WHERE rid = ? AND pid = ?` - _, err := a.db.Exec(query, roleID, permissionID) - if err != nil { - return fmt.Errorf("failed to revoke permission from role: %w", err) - } - - return nil -} - -// GetAllPermissions returns all permissions in the system. -func (a *AuthorizationService) GetAllPermissions() ([]Permission, error) { - query := `SELECT id, resource, action, description FROM permissions` - - rows, err := a.db.Query(query) - if err != nil { - return nil, fmt.Errorf("failed to get permissions: %w", err) - } - defer rows.Close() - - var permissions []Permission - for rows.Next() { - var perm Permission - if scanErr := rows.Scan(&perm.ID, &perm.Resource, &perm.Action, &perm.Description); scanErr != nil { - return nil, fmt.Errorf(errScanPermission, scanErr) - } - permissions = append(permissions, perm) - } - - if rowErr := rows.Err(); rowErr != nil { - return nil, fmt.Errorf(errIteratePermissions, rowErr) - } - - return permissions, nil -} - -// GenerateID generates a unique ID for database records -func GenerateID() string { - return ulid.Make().String() -} diff --git a/data/auth_test.go b/data/auth_test.go deleted file mode 100644 index f0cf35c..0000000 --- a/data/auth_test.go +++ /dev/null @@ -1,223 +0,0 @@ -package data - -import ( - "database/sql" - "os" - "testing" - - _ "github.com/mattn/go-sqlite3" -) - -func setupTestDB(t *testing.T) (*sql.DB, func()) { - // Create a temporary database for testing - db, err := sql.Open("sqlite3", ":memory:") - if err != nil { - t.Fatalf("Failed to open in-memory database: %v", err) - } - - // Read the schema file - schemaBytes, err := os.ReadFile("../database/schema.sql") - if err != nil { - t.Fatalf("Failed to read schema file: %v", err) - } - schema := string(schemaBytes) - - // Execute the schema - _, err = db.Exec(schema) - if err != nil { - t.Fatalf("Failed to execute schema: %v", err) - } - - // Create test data - setupTestData(t, db) - - // Return the database and a cleanup function - return db, func() { - db.Close() - } -} - -func setupTestData(t *testing.T, db *sql.DB) { - // Create test users - _, err := db.Exec(`INSERT INTO users (id, created, user, password, salt) VALUES - ('user1', 1622505600, 'testadmin', 'dummy', 'dummy'), - ('user2', 1622505600, 'testoperator', 'dummy', 'dummy'), - ('user3', 1622505600, 'testuser', 'dummy', 'dummy')`) - if err != nil { - t.Fatalf("Failed to insert test users: %v", err) - } - - // Create test roles (these should already exist from schema.sql) - // But we'll check and insert if needed - var count int - err = db.QueryRow("SELECT COUNT(*) FROM roles WHERE role = 'admin'").Scan(&count) - if err != nil { - t.Fatalf("Failed to check roles: %v", err) - } - if count == 0 { - _, err = db.Exec(`INSERT INTO roles (id, role) VALUES - ('role_admin', 'admin'), - ('role_db_operator', 'db_operator'), - ('role_user', 'user')`) - if err != nil { - t.Fatalf("Failed to insert test roles: %v", err) - } - } - - // Assign roles to users - _, err = db.Exec(`INSERT INTO user_roles (id, uid, rid) VALUES - ('ur1', 'user1', 'role_admin'), - ('ur2', 'user2', 'role_db_operator'), - ('ur3', 'user3', 'role_user')`) - if err != nil { - t.Fatalf("Failed to assign roles to users: %v", err) - } -} - -func TestUserHasPermission(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - authService := NewAuthorizationService(db) - - tests := []struct { - name string - userID string - resource string - action string - want bool - }{ - { - name: "Admin has database read permission", - userID: "user1", - resource: "database_credentials", - action: "read", - want: true, - }, - { - name: "Admin has database write permission", - userID: "user1", - resource: "database_credentials", - action: "write", - want: true, - }, - { - name: "DB Operator has database read permission", - userID: "user2", - resource: "database_credentials", - action: "read", - want: true, - }, - { - name: "DB Operator does not have database write permission", - userID: "user2", - resource: "database_credentials", - action: "write", - want: false, - }, - { - name: "Regular user does not have database read permission", - userID: "user3", - resource: "database_credentials", - action: "read", - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := authService.UserHasPermission(tt.userID, tt.resource, tt.action) - if err != nil { - t.Errorf("AuthorizationService.UserHasPermission() error = %v", err) - return - } - if got != tt.want { - t.Errorf("AuthorizationService.UserHasPermission() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestGetUserPermissions(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - authService := NewAuthorizationService(db) - - t.Run("Admin has all permissions", func(t *testing.T) { - permissions, err := authService.GetUserPermissions("user1") - if err != nil { - t.Errorf("AuthorizationService.GetUserPermissions() error = %v", err) - return - } - - // Admin should have 4 permissions - if len(permissions) != 4 { - t.Errorf("Admin should have 4 permissions, got %d", len(permissions)) - } - - // Check for specific permissions - hasDBRead := false - hasDBWrite := false - for _, p := range permissions { - if p.Resource == "database_credentials" && p.Action == "read" { - hasDBRead = true - } - if p.Resource == "database_credentials" && p.Action == "write" { - hasDBWrite = true - } - } - - if !hasDBRead { - t.Errorf("Admin should have database_credentials:read permission") - } - if !hasDBWrite { - t.Errorf("Admin should have database_credentials:write permission") - } - }) - - t.Run("DB Operator has limited permissions", func(t *testing.T) { - permissions, err := authService.GetUserPermissions("user2") - if err != nil { - t.Errorf("AuthorizationService.GetUserPermissions() error = %v", err) - return - } - - // DB Operator should have 1 permission - if len(permissions) != 1 { - t.Errorf("DB Operator should have 1 permission, got %d", len(permissions)) - } - - // Check for specific permissions - hasDBRead := false - hasDBWrite := false - for _, p := range permissions { - if p.Resource == "database_credentials" && p.Action == "read" { - hasDBRead = true - } - if p.Resource == "database_credentials" && p.Action == "write" { - hasDBWrite = true - } - } - - if !hasDBRead { - t.Errorf("DB Operator should have database_credentials:read permission") - } - if hasDBWrite { - t.Errorf("DB Operator should not have database_credentials:write permission") - } - }) - - t.Run("Regular user has no permissions", func(t *testing.T) { - permissions, err := authService.GetUserPermissions("user3") - if err != nil { - t.Errorf("AuthorizationService.GetUserPermissions() error = %v", err) - return - } - - // Regular user should have 0 permissions - if len(permissions) != 0 { - t.Errorf("Regular user should have 0 permissions, got %d", len(permissions)) - } - }) -} diff --git a/data/rand.go b/data/rand.go deleted file mode 100644 index 4b8468e..0000000 --- a/data/rand.go +++ /dev/null @@ -1,15 +0,0 @@ -package data - -import "crypto/rand" - -const saltLength = 32 - -func Salt() ([]byte, error) { - salt := make([]byte, saltLength) - _, err := rand.Read(salt) - if err != nil { - return nil, err - } - - return salt, nil -} diff --git a/data/totp.go b/data/totp.go deleted file mode 100644 index e775b50..0000000 --- a/data/totp.go +++ /dev/null @@ -1,125 +0,0 @@ -package data - -import ( - "crypto/hmac" - "crypto/rand" - - // #nosec G505 - SHA1 is used here because TOTP (RFC 6238) specifically uses HMAC-SHA1 - // as the default algorithm, and many authenticator apps still use it. - // In the future, we should consider supporting stronger algorithms like SHA256 or SHA512. - "crypto/sha1" - "encoding/base32" - "encoding/binary" - "fmt" - "image/png" - "os" - "strings" - "time" - - "rsc.io/qr" -) - -const ( - // TOTPTimeStep is the time step in seconds for TOTP. - TOTPTimeStep = 30 - // TOTPDigits is the number of digits in a TOTP code. - TOTPDigits = 6 - // TOTPModulo is the modulo value for truncating the TOTP hash. - TOTPModulo = 1000000 - // TOTPTimeWindow is the number of time steps to check before and after the current time. - TOTPTimeWindow = 1 - - // Constants for TOTP calculation - timeBytesLength = 8 - dynamicTruncationMask = 0x0F - truncationModulusMask = 0x7FFFFFFF -) - -// GenerateRandomBase32 generates a random base32 encoded string of the specified length. -func GenerateRandomBase32(length int) (string, error) { - // Generate random bytes - randomBytes := make([]byte, length) - _, err := rand.Read(randomBytes) - if err != nil { - return "", err - } - - // Encode to base32 - encoder := base32.StdEncoding.WithPadding(base32.NoPadding) - encoded := encoder.EncodeToString(randomBytes) - - // Convert to uppercase and remove any padding - return strings.ToUpper(encoded), nil -} - -// ValidateTOTP validates a TOTP code against a secret -func ValidateTOTP(secret, code string) bool { - // Get current time step - currentTime := time.Now().Unix() / TOTPTimeStep - - // Try the time window (allow for time skew) - for i := -TOTPTimeWindow; i <= TOTPTimeWindow; i++ { - if calculateTOTP(secret, currentTime+int64(i)) == code { - return true - } - } - - return false -} - -// calculateTOTP calculates the TOTP code for a given secret and time -func calculateTOTP(secret string, timeCounter int64) string { - // Decode the secret from base32 - encoder := base32.StdEncoding.WithPadding(base32.NoPadding) - secretBytes, err := encoder.DecodeString(strings.ToUpper(secret)) - if err != nil { - return "" - } - - // Convert time counter to bytes (big endian) - timeBytes := make([]byte, timeBytesLength) - binary.BigEndian.PutUint64(timeBytes, uint64(timeCounter)) - - // Calculate HMAC-SHA1 - h := hmac.New(sha1.New, secretBytes) - h.Write(timeBytes) - hash := h.Sum(nil) - - // Dynamic truncation - offset := hash[len(hash)-1] & dynamicTruncationMask - truncatedHash := binary.BigEndian.Uint32(hash[offset:offset+4]) & truncationModulusMask - otp := truncatedHash % TOTPModulo - - // Format as a 6-digit string with leading zeros - return fmt.Sprintf("%0*d", TOTPDigits, otp) -} - -// GenerateTOTPQRCode generates a QR code for a TOTP secret and saves it to a file -func GenerateTOTPQRCode(secret, username, issuer, outputPath string) error { - // Format the TOTP URI according to the KeyURI format - // https://github.com/google/google-authenticator/wiki/Key-Uri-Format - uri := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d", - issuer, username, secret, issuer, TOTPDigits, TOTPTimeStep) - - // Generate QR code - code, err := qr.Encode(uri, qr.M) - if err != nil { - return fmt.Errorf("failed to generate QR code: %w", err) - } - - // Create output file - file, err := os.Create(outputPath) - if err != nil { - return fmt.Errorf("failed to create output file: %w", err) - } - defer file.Close() - - // Write QR code as PNG - img := code.Image() - err = png.Encode(file, img) - if err != nil { - return fmt.Errorf("failed to write QR code to file: %w", err) - } - - return nil -} diff --git a/data/totp_test.go b/data/totp_test.go deleted file mode 100644 index 0176981..0000000 --- a/data/totp_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package data - -import ( - "fmt" - "testing" - - "github.com/gokyle/twofactor" -) - -func TestTOTPBasic(t *testing.T) { - // This test verifies that we can import and use the twofactor package. - totp := twofactor.TOTP{} - fmt.Printf("TOTP: %+v\n", totp) -} diff --git a/data/user.go b/data/user.go deleted file mode 100644 index 2a12f48..0000000 --- a/data/user.go +++ /dev/null @@ -1,143 +0,0 @@ -package data - -import ( - "crypto/subtle" - "errors" - "fmt" - "time" - - "github.com/oklog/ulid/v2" - "golang.org/x/crypto/scrypt" -) - -const ( - scryptN = 32768 - scryptR = 8 - scryptP = 2 - - // Constants for derived key length and comparison - derivedKeyLength = 32 - validCompareResult = 1 - - // Empty string constant - emptyString = "" - - // TOTP secret length in bytes (160 bits) - totpSecretLength = 20 -) - -type User struct { - ID string - Created int64 - User string - Password []byte - Salt []byte - TOTPSecret string - Roles []string -} - -// HasRole checks if the user has a specific role -func (u *User) HasRole(role string) bool { - for _, r := range u.Roles { - if r == role { - return true - } - } - return false -} - -// HasPermission checks if the user has a specific permission using the authorization service -func (u *User) HasPermission(authService *AuthorizationService, resource, action string) (bool, error) { - return authService.UserHasPermission(u.ID, resource, action) -} - -// GetPermissions returns all permissions for the user using the authorization service -func (u *User) GetPermissions(authService *AuthorizationService) ([]Permission, error) { - return authService.GetUserPermissions(u.ID) -} - -type Login struct { - User string `json:"user"` - Password string `json:"password,omitempty"` - Token string `json:"token,omitempty"` - TOTPCode string `json:"totp_code,omitempty"` -} - -func derive(password string, salt []byte) ([]byte, error) { - return scrypt.Key([]byte(password), salt, scryptN, scryptR, scryptP, derivedKeyLength) -} - -// CheckPassword verifies only the username and password, without TOTP verification -func (u *User) CheckPassword(login *Login) bool { - if u.User != login.User { - return false - } - - derived, err := derive(login.Password, u.Salt) - if err != nil { - return false - } - - 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 { - // Only check username and password, TOTP verification is now a separate flow - return u.CheckPassword(login) -} - -func (u *User) Register(login *Login) error { - var err error - - if u.User != emptyString && u.User != login.User { - return errors.New("invalid user") - } - - if u.ID == emptyString { - u.ID = ulid.Make().String() - } - - u.User = login.User - u.Salt, err = Salt() - if err != nil { - return fmt.Errorf("failed to register user: %w", err) - } - - u.Password, err = derive(login.Password, u.Salt) - if err != nil { - return fmt.Errorf("key derivation failed: %w", err) - } - - u.Created = time.Now().Unix() - return nil -} - -// GenerateTOTPSecret generates a new TOTP secret for the user -func (u *User) GenerateTOTPSecret() (string, error) { - // Generate a random secret - secret, err := GenerateRandomBase32(totpSecretLength) - if err != nil { - return emptyString, fmt.Errorf("failed to generate TOTP secret: %w", err) - } - - u.TOTPSecret = secret - return u.TOTPSecret, nil -} - -// ValidateTOTPCode validates a TOTP code against the user's TOTP secret -func (u *User) ValidateTOTPCode(code string) (bool, error) { - if u.TOTPSecret == emptyString { - return false, errors.New("TOTP not enabled for user") - } - - // Use the twofactor package to validate the code - valid := ValidateTOTP(u.TOTPSecret, code) - return valid, nil -} - -// HasTOTP returns true if TOTP is enabled for the user -func (u *User) HasTOTP() bool { - return u.TOTPSecret != emptyString -} diff --git a/data/user_test.go b/data/user_test.go deleted file mode 100644 index 36697c8..0000000 --- a/data/user_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package data - -import ( - "testing" -) - -func TestUserRegisterAndCheck(t *testing.T) { - user := &User{} - login := &Login{ - User: "testuser", - Password: "testpassword", - } - - err := user.Register(login) - if err != nil { - t.Fatalf("Failed to register user: %v", err) - } - - if user.ID == "" { - t.Error("Expected user ID to be set, got empty string") - } - if user.User != "testuser" { - t.Errorf("Expected username 'testuser', got '%s'", user.User) - } - if len(user.Password) == 0 { - t.Error("Expected password to be hashed and set, got empty slice") - } - if len(user.Salt) == 0 { - t.Error("Expected salt to be set, got empty slice") - } - - correctLogin := &Login{ - User: "testuser", - Password: "testpassword", - } - if !user.Check(correctLogin) { - t.Error("Check failed for correct password") - } - - incorrectLogin := &Login{ - User: "testuser", - Password: "wrongpassword", - } - if user.Check(incorrectLogin) { - t.Error("Check passed for incorrect password") - } - - wrongUserLogin := &Login{ - User: "wronguser", - Password: "testpassword", - } - if user.Check(wrongUserLogin) { - t.Error("Check passed for incorrect username") - } -} - -func TestSalt(t *testing.T) { - salt, err := Salt() - if err != nil { - t.Fatalf("Failed to generate salt: %v", err) - } - - if len(salt) != saltLength { - t.Errorf("Expected salt length %d, got %d", saltLength, len(salt)) - } - - salt2, err := Salt() - if err != nil { - t.Fatalf("Failed to generate second salt: %v", err) - } - - // Check that salts are different (extremely unlikely to be the same) - different := false - for i := 0; i < len(salt); i++ { - if salt[i] != salt2[i] { - different = true - break - } - } - if !different { - t.Error("Expected different salts, got identical values") - } -} diff --git a/database/migrations/000001_initial_schema.down.sql b/database/migrations/000001_initial_schema.down.sql deleted file mode 100644 index 37f8e07..0000000 --- a/database/migrations/000001_initial_schema.down.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Drop tables in reverse order of creation to avoid foreign key constraints -DROP TABLE IF EXISTS role_permissions; -DROP TABLE IF EXISTS permissions; -DROP TABLE IF EXISTS user_roles; -DROP TABLE IF EXISTS roles; -DROP TABLE IF EXISTS registrations; -DROP TABLE IF EXISTS database; -DROP TABLE IF EXISTS tokens; -DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/database/migrations/000001_initial_schema.up.sql b/database/migrations/000001_initial_schema.up.sql deleted file mode 100644 index 6461f93..0000000 --- a/database/migrations/000001_initial_schema.up.sql +++ /dev/null @@ -1,84 +0,0 @@ -CREATE TABLE users ( - id text primary key, - created integer, - user text not null, - password blob not null, - salt blob not null, - totp_secret text -); - -CREATE TABLE tokens ( - id text primary key, - uid text not null, - token text not null, - expires integer default 0, - FOREIGN KEY(uid) REFERENCES user(id) -); - -CREATE TABLE database ( - id text primary key, - host text not null, - port integer default 5432, - name text not null, - user text not null, - password text not null -); - -CREATE TABLE registrations ( - id text primary key, - code text not null -); - -CREATE TABLE roles ( - id text primary key, - role text not null -); - -CREATE TABLE user_roles ( - id text primary key, - uid text not null, - rid text not null, - FOREIGN KEY(uid) REFERENCES user(id), - FOREIGN KEY(rid) REFERENCES roles(id) -); - --- Add permissions table -CREATE TABLE permissions ( - id TEXT PRIMARY KEY, - resource TEXT NOT NULL, - action TEXT NOT NULL, - description TEXT -); - --- Link roles to permissions -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) -); - --- Add default permissions -INSERT INTO permissions (id, resource, action, description) VALUES - ('perm_db_read', 'database_credentials', 'read', 'Read database credentials'), - ('perm_db_write', 'database_credentials', 'write', 'Modify database credentials'), - ('perm_user_manage', 'users', 'manage', 'Manage user accounts'), - ('perm_token_manage', 'tokens', 'manage', 'Manage authentication tokens'); - --- Add default roles -INSERT INTO roles (id, role) VALUES - ('role_admin', 'admin'), - ('role_db_operator', 'db_operator'), - ('role_user', 'user'); - --- Grant permissions to admin role -INSERT INTO role_permissions (id, rid, pid) VALUES - ('rp_admin_db_read', 'role_admin', 'perm_db_read'), - ('rp_admin_db_write', 'role_admin', 'perm_db_write'), - ('rp_admin_user_manage', 'role_admin', 'perm_user_manage'), - ('rp_admin_token_manage', 'role_admin', 'perm_token_manage'); - --- Grant database access to db_operator role -INSERT INTO role_permissions (id, rid, pid) VALUES - ('rp_dbop_db_read', 'role_db_operator', 'perm_db_read'); \ No newline at end of file diff --git a/database/migrations/000002_add_database_users.down.sql b/database/migrations/000002_add_database_users.down.sql deleted file mode 100644 index 08f70c4..0000000 --- a/database/migrations/000002_add_database_users.down.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Drop the database_users table and its indexes -DROP INDEX IF EXISTS idx_database_users_db_id; -DROP INDEX IF EXISTS idx_database_users_uid; -DROP TABLE IF EXISTS database_users; \ No newline at end of file diff --git a/database/migrations/000002_add_database_users.up.sql b/database/migrations/000002_add_database_users.up.sql deleted file mode 100644 index 8f551f4..0000000 --- a/database/migrations/000002_add_database_users.up.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Add database_users table to associate users with databases -CREATE TABLE database_users ( - id TEXT PRIMARY KEY, - uid TEXT NOT NULL, - db_id TEXT NOT NULL, - FOREIGN KEY(uid) REFERENCES users(id), - FOREIGN KEY(db_id) REFERENCES database(id) -); - --- Add index for faster lookups -CREATE INDEX idx_database_users_uid ON database_users(uid); -CREATE INDEX idx_database_users_db_id ON database_users(db_id); diff --git a/database/schema.go b/database/schema.go deleted file mode 100644 index d5b230c..0000000 --- a/database/schema.go +++ /dev/null @@ -1,18 +0,0 @@ -// Package database provides database-related functionality for the MCIAS system. -package database - -import ( - "embed" -) - -//go:embed schema.sql -var schemaFS embed.FS - -// DefaultSchema returns the default database schema as a string. -func DefaultSchema() (string, error) { - schemaBytes, err := schemaFS.ReadFile("schema.sql") - if err != nil { - return "", err - } - return string(schemaBytes), nil -} diff --git a/database/schema.sql b/database/schema.sql deleted file mode 100644 index f89bebb..0000000 --- a/database/schema.sql +++ /dev/null @@ -1,84 +0,0 @@ -CREATE TABLE users ( - id text primary key, - created integer, - user text not null, - password blob not null, - salt blob not null, - totp_secret text -); - -CREATE TABLE tokens ( - id text primary key, - uid text not null, - token text not null, - expires integer default 0, - FOREIGN KEY(uid) REFERENCES user(id) -); - -CREATE TABLE database ( - id text primary key, - host text not null, - port integer default 5432, - name text not null, - user text not null, - password text not null -); - -CREATE TABLE registrations ( - id text primary key, - code text not null -); - -CREATE TABLE roles ( - id text primary key, - role text not null -); - -CREATE TABLE user_roles ( - id text primary key, - uid text not null, - rid text not null, - FOREIGN KEY(uid) REFERENCES user(id), - FOREIGN KEY(rid) REFERENCES roles(id) -); - --- Add permissions table -CREATE TABLE permissions ( - id TEXT PRIMARY KEY, - resource TEXT NOT NULL, - action TEXT NOT NULL, - description TEXT -); - --- Link roles to permissions -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) -); - --- Add default permissions -INSERT INTO permissions (id, resource, action, description) VALUES - ('perm_db_read', 'database_credentials', 'read', 'Read database credentials'), - ('perm_db_write', 'database_credentials', 'write', 'Modify database credentials'), - ('perm_user_manage', 'users', 'manage', 'Manage user accounts'), - ('perm_token_manage', 'tokens', 'manage', 'Manage authentication tokens'); - --- Add default roles -INSERT INTO roles (id, role) VALUES - ('role_admin', 'admin'), - ('role_db_operator', 'db_operator'), - ('role_user', 'user'); - --- Grant permissions to admin role -INSERT INTO role_permissions (id, rid, pid) VALUES - ('rp_admin_db_read', 'role_admin', 'perm_db_read'), - ('rp_admin_db_write', 'role_admin', 'perm_db_write'), - ('rp_admin_user_manage', 'role_admin', 'perm_user_manage'), - ('rp_admin_token_manage', 'role_admin', 'perm_token_manage'); - --- Grant database access to db_operator role -INSERT INTO role_permissions (id, rid, pid) VALUES - ('rp_dbop_db_read', 'role_db_operator', 'perm_db_read'); diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index b866186..0000000 --- a/docs/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# MCIAS Documentation - -Welcome to the MCIAS (Metacircular Identity and Access System) documentation. This directory contains comprehensive documentation for understanding, installing, and using the MCIAS system. - -## Documentation Index - -### [Overview](overview.md) -- Project overview -- System architecture -- Database schema -- Security considerations - -### [API Documentation](api.md) -- API endpoints -- Request/response formats -- Error handling -- Authentication flow - -### [Installation and Usage Guide](installation.md) -- Prerequisites -- Installation steps -- Running the server -- Configuration options -- Building from source -- Development practices -- API usage examples -- Troubleshooting -- Security best practices - -## Additional Resources - -- [Project Repository](https://git.wntrmute.dev/kyle/mcias) -- [Schema File](../schema.sql) - Database schema definition - -## Contributing to Documentation - -When contributing to this documentation, please follow these guidelines: - -1. Use Markdown format for all documentation files -2. Keep the documentation up-to-date with code changes -3. Include examples where appropriate -4. Organize content logically with clear headings -5. Use code blocks with syntax highlighting for code examples \ No newline at end of file diff --git a/docs/README.org b/docs/README.org deleted file mode 100644 index 02307cf..0000000 --- a/docs/README.org +++ /dev/null @@ -1,46 +0,0 @@ -#+title: MCIAS Documentation -#+created: <2025-05-09 Fri 13:42> - -* MCIAS Documentation - -Welcome to the MCIAS (Metacircular Identity and Access System) documentation. This directory contains comprehensive documentation for understanding, installing, and using the MCIAS system. - -* Documentation Index - -** [[file:overview.org][Overview]] -- Project overview -- System architecture -- Database schema -- Security considerations - -** [[file:api.org][API Documentation]] -- API endpoints -- Request/response formats -- Error handling -- Authentication flow - -** [[file:installation.org][Installation and Usage Guide]] -- Prerequisites -- Installation steps -- Running the server -- Configuration options -- Building from source -- Development practices -- API usage examples -- Troubleshooting -- Security best practices - -* Additional Resources - -- [[https://git.wntrmute.dev/kyle/mcias][Project Repository]] -- [[file:../schema.sql][Schema File]] - Database schema definition - -* Contributing to Documentation - -When contributing to this documentation, please follow these guidelines: - -1. Use org-mode format for all documentation files -2. Keep the documentation up-to-date with code changes -3. Include examples where appropriate -4. Organize content logically with clear headings -5. Use code blocks with syntax highlighting for code examples \ No newline at end of file diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index f8e8ead..0000000 --- a/docs/api.md +++ /dev/null @@ -1,184 +0,0 @@ -# MCIAS API Documentation - -## Overview - -MCIAS (Metacircular Identity and Access System) provides identity and authentication services across metacircular projects. This document describes the REST API endpoints, request/response formats, and error handling. - -## API Endpoints - -### Authentication - -#### Password-based Authentication - -**Endpoint**: `POST /v1/login/password` - -**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 -{ - "version": "v1", - "login": { - "user": "username", - "password": "secret_password" - } -} -``` - -**Required Fields**: -- `version`: Must be "v1" -- `login.user`: Username -- `login.password`: User's password - -**Response Format** (Success - 200 OK): -```json -{ - "token": "authentication_token", - "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 -- 401 Unauthorized: Invalid username or password -- 500 Internal Server Error: Server-side error - -#### Token-based Authentication - -**Endpoint**: `POST /v1/login/token` - -**Description**: Authenticates a user using a previously issued token. - -**Request Format**: -```json -{ - "version": "v1", - "login": { - "user": "username", - "token": "existing_token" - } -} -``` - -**Required Fields**: -- `version`: Must be "v1" -- `login.user`: Username -- `login.token`: Previously issued authentication token - -**Response Format** (Success - 200 OK): -```json -{ - "token": "new_authentication_token", - "expires": 1621234567 -} -``` - -**Response Fields**: -- `token`: New 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 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) - -**Description**: Retrieves database credentials for authorized users. - -## Error Handling - -All error responses follow a standard format: - -```json -{ - "error": "Error message describing the issue" -} -``` - -Common HTTP status codes: -- 200 OK: Request successful -- 400 Bad Request: Invalid request format or parameters -- 401 Unauthorized: Authentication failed -- 403 Forbidden: Insufficient permissions -- 404 Not Found: Resource not found -- 500 Internal Server Error: Server-side error - -## 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 - -### 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 - -### 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 deleted file mode 100644 index 546cad5..0000000 --- a/docs/api.org +++ /dev/null @@ -1,201 +0,0 @@ -#+title: MCIAS API Documentation -#+created: <2025-05-09 Fri 13:42> - -* MCIAS API Documentation - -** Overview - -MCIAS (Metacircular Identity and Access System) provides identity and authentication services across metacircular projects. This document describes the REST API endpoints, request/response formats, and error handling. - -** API Endpoints - -*** Authentication - -**** Password-based Authentication - -*Endpoint*: =POST /v1/login/password= - -*Description*: Authenticates a user using username and password credentials. - -*Request Format*: -#+begin_src json -{ - "version": "v1", - "login": { - "user": "username", - "password": "secret_password" - } -} -#+end_src - -*Required Fields*: -- =version=: Must be "v1" -- =login.user=: Username -- =login.password=: User's password - -*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 or password -- 500 Internal Server Error: Server-side error - -**** Token-based Authentication - -*Endpoint*: =POST /v1/login/token= - -*Description*: Authenticates a user using a previously issued token. - -*Request Format*: -#+begin_src json -{ - "version": "v1", - "login": { - "user": "username", - "token": "existing_token" - } -} -#+end_src - -*Required Fields*: -- =version=: Must be "v1" -- =login.user=: Username -- =login.token=: Previously issued authentication token - -*Response Format* (Success - 200 OK): -#+begin_src json -{ - "token": "new_authentication_token", - "expires": 1621234567 -} -#+end_src - -*Response Fields*: -- =token=: New 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 or expired token -- 500 Internal Server Error: Server-side error - -*** Database Credentials - -*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: - -#+begin_src json -{ - "error": "Error message describing the issue" -} -#+end_src - -Common HTTP status codes: -- 200 OK: Request successful -- 400 Bad Request: Invalid request format or parameters -- 401 Unauthorized: Authentication failed -- 403 Forbidden: Insufficient permissions -- 404 Not Found: Resource not found -- 500 Internal Server Error: Server-side error - -** Authentication Flow - -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. *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 - -4. *Token Expiration*: - - Tokens expire after 24 hours - - 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 deleted file mode 100644 index 0e5f4de..0000000 --- a/docs/deployment.md +++ /dev/null @@ -1,130 +0,0 @@ -# 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.md b/docs/installation.md deleted file mode 100644 index db84d31..0000000 --- a/docs/installation.md +++ /dev/null @@ -1,170 +0,0 @@ -# MCIAS Installation and Usage Guide - -## Prerequisites - -Before installing MCIAS, ensure you have the following prerequisites: - -- Go 1.23 or later -- SQLite3 -- golangci-lint (for development) - -## Installation - -### Clone the Repository - -```bash -git clone git.wntrmute.dev/kyle/mcias -cd mcias -``` - -### Install Dependencies - -```bash -go mod download -``` - -### Initialize the Database - -MCIAS uses SQLite for data storage. To initialize the database: - -```bash -go run main.go -init -db ./mcias.db -``` - -This command creates a new SQLite database file and initializes it with the schema defined in `schema.sql`. - -## Running the Server - -### Basic Usage - -To start the MCIAS server with default settings: - -```bash -go run main.go -db ./mcias.db -``` - -By default, the server listens on port 8080. - -### Configuration Options - -MCIAS supports the following command-line options: - -- `-db `: Path to the SQLite database file (default: `mcias.db`) -- `-addr
`: Address to listen on (default: `:8080`) -- `-init`: Initialize the database and exit - -Example with custom port: - -```bash -go run main.go -db ./mcias.db -addr :9000 -``` - -## Building from Source - -To build a binary: - -```bash -go build -o mcias -``` - -Then run the binary: - -```bash -./mcias -db ./mcias.db -``` - -## Development - -### Running Tests - -To run all tests: - -```bash -go test ./... -``` - -### Linting - -To run the linter: - -```bash -golangci-lint run -``` - -## Using the API - -### Authentication with Password - -To authenticate a user with a password: - -```bash -curl -X POST http://localhost:8080/v1/login/password \ - -H "Content-Type: application/json" \ - -d '{ - "version": "v1", - "login": { - "user": "username", - "password": "password" - } - }' -``` - -### Authentication with Token - -To authenticate a user with a token: - -```bash -curl -X POST http://localhost:8080/v1/login/token \ - -H "Content-Type: application/json" \ - -d '{ - "version": "v1", - "login": { - "user": "username", - "token": "your_token" - } - }' -``` - -## Troubleshooting - -### Common Issues - -1. **Database errors**: Ensure the database file exists and has the correct permissions. - ```bash - # Check permissions - ls -l mcias.db - # Fix permissions if needed - chmod 644 mcias.db - ``` - -2. **Port already in use**: If the port is already in use, specify a different port with the `-addr` flag. - ```bash - go run main.go -db ./mcias.db -addr :8081 - ``` - -3. **Authentication failures**: Ensure you're using the correct username and password/token. - -### Logging - -MCIAS logs to stdout by default. To capture logs to a file: - -```bash -go run main.go -db ./mcias.db > mcias.log 2>&1 -``` - -## Security Best Practices - -1. **Production Deployment**: - - Use HTTPS in production - - Set up proper firewall rules - - Run the service with minimal privileges - -2. **Database Security**: - - Regularly backup the database - - Restrict file permissions on the database file - - Consider encrypting the database file at rest - -3. **User Management**: - - Implement strong password policies - - Regularly rotate tokens - - Monitor for suspicious authentication attempts \ No newline at end of file diff --git a/docs/installation.org b/docs/installation.org deleted file mode 100644 index fa724cf..0000000 --- a/docs/installation.org +++ /dev/null @@ -1,238 +0,0 @@ -#+title: MCIAS Installation and Usage Guide -#+created: <2025-05-09 Fri 13:42> - -* MCIAS Installation and Usage Guide - -** Prerequisites - -Before installing MCIAS, ensure you have the following prerequisites: - -- Go 1.23 or later -- SQLite3 -- golangci-lint (for development) - -** Installation - -*** Clone the Repository - -#+begin_src bash -git clone git.wntrmute.dev/kyle/mcias -cd mcias -#+end_src - -*** Install Dependencies - -#+begin_src bash -go mod download -#+end_src - -*** Initialize the Database - -MCIAS uses SQLite for data storage. To initialize the database: - -#+begin_src bash -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=. - -** Running the Server - -*** Basic Usage - -To start the MCIAS server with default settings: - -#+begin_src bash -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 for the server: - -- =--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 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 the server binary: - -#+begin_src bash -cd cmd/mcias -go build -o mcias -#+end_src - -Then run the binary: - -#+begin_src bash -./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 - -*** Running Tests - -To run all tests: - -#+begin_src bash -go test ./... -#+end_src - -*** Linting - -To run the linter: - -#+begin_src bash -golangci-lint run -#+end_src - -** Using the API - -*** Authentication with Password - -To authenticate a user with a password: - -#+begin_src bash -curl -X POST http://localhost:8080/v1/login/password \ - -H "Content-Type: application/json" \ - -d '{ - "version": "v1", - "login": { - "user": "username", - "password": "password" - } - }' -#+end_src - -*** Authentication with Token - -To authenticate a user with a token: - -#+begin_src bash -curl -X POST http://localhost:8080/v1/login/token \ - -H "Content-Type: application/json" \ - -d '{ - "version": "v1", - "login": { - "user": "username", - "token": "your_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 - -1. *Database errors*: Ensure the database file exists and has the correct permissions. - #+begin_src bash - # Check permissions - ls -l mcias.db - # Fix permissions if needed - chmod 644 mcias.db - #+end_src - -2. *Port already in use*: If the port is already in use, specify a different port with the =-addr= flag. - #+begin_src bash - go run main.go -db ./mcias.db -addr :8081 - #+end_src - -3. *Authentication failures*: Ensure you're using the correct username and password/token. - -*** Logging - -MCIAS logs to stdout by default. To capture logs to a file: - -#+begin_src bash -go run main.go -db ./mcias.db > mcias.log 2>&1 -#+end_src - -** Security Best Practices - -1. *Production Deployment*: - - Use HTTPS in production - - Set up proper firewall rules - - Run the service with minimal privileges - -2. *Database Security*: - - Regularly backup the database - - Restrict file permissions on the database file - - Consider encrypting the database file at rest - -3. *User Management*: - - Implement strong password policies - - Regularly rotate tokens - - Monitor for suspicious authentication attempts diff --git a/docs/overview.md b/docs/overview.md deleted file mode 100644 index 6aff1eb..0000000 --- a/docs/overview.md +++ /dev/null @@ -1,130 +0,0 @@ -# MCIAS: Metacircular Identity and Access System - -## Project Overview - -MCIAS (Metacircular Identity and Access System) is a centralized identity and access management system designed to provide authentication and authorization services across metacircular projects. It serves as a single source of truth for user identity and access control. - -The system currently provides: -1. User password authentication -2. User token authentication -3. Database credential authentication - -Future planned features include: -1. TOTP (Time-based One-Time Password) authentication -2. Policy management for fine-grained access control - -## System Architecture - -MCIAS is built as a standalone REST API service with the following components: - -### Core Components - -1. **API Layer** (`api/` directory) - - HTTP server and routing - - Request/response handling - - Authentication endpoints - - Error handling - -2. **Data Layer** (`data/` directory) - - User management - - Token management - - Password hashing and verification - - Secure random generation - -3. **Database** (SQLite) - - Persistent storage for users, tokens, and credentials - - Schema defined in `schema.sql` - -### Request Flow - -1. Client sends authentication request to the API -2. API layer validates the request format -3. Data layer processes the authentication logic -4. Database is queried to verify credentials -5. Response is generated and sent back to the client - -## Database Schema - -MCIAS uses a SQLite database with the following tables: - -### Users Table -```sql -CREATE TABLE users ( - id text primary key, - created integer, - user text not null, - password blob not null, - salt blob not null -); -``` - -### Tokens Table -```sql -CREATE TABLE tokens ( - id text primary key, - uid text not null, - token text not null, - expires integer default 0, - FOREIGN KEY(uid) REFERENCES user(id) -); -``` - -### Database Credentials Table -```sql -CREATE TABLE database ( - id text primary key, - host text not null, - port integer default 5432, - name text not null, - user text not null, - password text not null -); -``` - -### Registrations Table -```sql -CREATE TABLE registrations ( - id text primary key, - code text not null -); -``` - -### Roles Tables -```sql -CREATE TABLE roles ( - id text primary key, - role text not null -); - -CREATE TABLE user_roles ( - id text primary key, - uid text not null, - rid text not null, - FOREIGN KEY(uid) REFERENCES user(id), - FOREIGN KEY(rid) REFERENCES roles(id) -); -``` - -## Security Considerations - -MCIAS implements several security best practices: - -1. **Password Security** - - Passwords are never stored in plaintext - - Scrypt key derivation function is used for password hashing - - Each user has a unique random salt - - Constant-time comparison is used to prevent timing attacks - -2. **Token Security** - - Tokens are generated using cryptographically secure random functions - - Tokens have an expiration time (24 hours by default) - - New tokens are issued on each successful authentication - -3. **API Security** - - Input validation on all endpoints - - Standardized error responses that don't leak sensitive information - - Rate limiting (to be implemented) - -4. **Database Security** - - Parameterized queries to prevent SQL injection - - Foreign key constraints to maintain data integrity \ No newline at end of file diff --git a/docs/overview.org b/docs/overview.org deleted file mode 100644 index 853f76e..0000000 --- a/docs/overview.org +++ /dev/null @@ -1,152 +0,0 @@ -#+title: MCIAS: Metacircular Identity and Access System -#+created: <2025-05-09 Fri 13:42> - -* MCIAS: Metacircular Identity and Access System - -** Project Overview - -MCIAS (Metacircular Identity and Access System) is a centralized identity and access management system designed to provide authentication and authorization services across metacircular projects. It serves as a single source of truth for user identity and access control. - -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. Policy management for fine-grained access control - -** System Architecture - -MCIAS is built as a standalone REST API service with the following components: - -*** Core Components - -1. *API Layer* (=api/= directory) - - HTTP server and routing - - Request/response handling - - Authentication endpoints - - Error handling - -2. *Data Layer* (=data/= directory) - - User management - - Token management - - Password hashing and verification - - Secure random generation - -3. *Database* (SQLite) - - Persistent storage for users, tokens, and credentials - - Schema defined in =schema.sql= - -*** Request Flow - -1. Client sends authentication request to the API -2. API layer validates the request format -3. Data layer processes the authentication logic -4. Database is queried to verify credentials -5. Response is generated and sent back to the client - -** Database Schema - -MCIAS uses a SQLite database with the following tables: - -*** Users Table -#+begin_src sql -CREATE TABLE users ( - id text primary key, - created integer, - user text not null, - password blob not null, - salt blob not null, - totp_secret text -); -#+end_src - -*** Tokens Table -#+begin_src sql -CREATE TABLE tokens ( - id text primary key, - uid text not null, - token text not null, - expires integer default 0, - FOREIGN KEY(uid) REFERENCES user(id) -); -#+end_src - -*** Database Credentials Table -#+begin_src sql -CREATE TABLE database ( - id text primary key, - host text not null, - port integer default 5432, - name text not null, - user text not null, - password text not null -); -#+end_src - -*** Registrations Table -#+begin_src sql -CREATE TABLE registrations ( - id text primary key, - code text not null -); -#+end_src - -*** Roles Tables -#+begin_src sql -CREATE TABLE roles ( - id text primary key, - role text not null -); - -CREATE TABLE user_roles ( - id text primary key, - uid text not null, - rid text not null, - FOREIGN KEY(uid) REFERENCES user(id), - FOREIGN KEY(rid) REFERENCES roles(id) -); -#+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: - -1. *Password Security* - - Passwords are never stored in plaintext - - Scrypt key derivation function is used for password hashing - - Each user has a unique random salt - - Constant-time comparison is used to prevent timing attacks - -2. *Token Security* - - Tokens are generated using cryptographically secure random functions - - Tokens have an expiration time (24 hours by default) - - New tokens are issued on each successful authentication - -3. *API Security* - - Input validation on all endpoints - - Standardized error responses that don't leak sensitive information - - Rate limiting (to be implemented) - -4. *Database Security* - - Parameterized queries to prevent SQL injection - - Foreign key constraints to maintain data integrity diff --git a/go.mod b/go.mod index 5d066fa..fb9c254 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require github.com/gokyle/twofactor v1.0.1 require ( github.com/Masterminds/squirrel v1.5.4 // indirect + github.com/cloudflare/golz4 v0.0.0-20240921210912-c4df3fe31cbd // 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 diff --git a/go.sum b/go.sum index f212265..b42f940 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/cloudflare/golz4 v0.0.0-20240921210912-c4df3fe31cbd h1:r6CfhFeB1zJY9UVvmsDyFxwJIKXLcmps03FlEs460zI= +github.com/cloudflare/golz4 v0.0.0-20240921210912-c4df3fe31cbd/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80= 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=