diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 0000000..1edd1d3 --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,27 @@ +# Project Guidelines + +MCIAS is the metacircular identity and access system, providing identity and +authentication across the metacircular projects. + +## Structure + ++ The system should be runnable through a cobra CLI tool, with + subcommands implemented as wrappers around packages. ++ The REST API code should be under the `api` directory. ++ The system should be backed by a single SQLite database whose schema + is stored in `schema.sql`. + +## Writing code + ++ Junie should write and run tests to validate correctness. ++ The tests should be runnable with just `go test` (with + any approporiate arguments). ++ Junie should validate the build and ensure that the code is + properly linted. Junie should use `golangci-lint` for this. ++ Junie should elide trivial comments, and only write comments where it + is beneficial to provide exposition on the code. + +## Notes + +This is a security system, so care should be taken towards +correctness. \ No newline at end of file diff --git a/README.org b/README.org index 9913a35..7174aa1 100644 --- a/README.org +++ b/README.org @@ -3,7 +3,7 @@ * MCIAS - MCIAS is the metacircular identity and access system. + MCIAS is the metacircular identity and access system, providing identity and authentication across metacircular projects. It currently provides the following across metacircular services: @@ -12,36 +12,111 @@ 3. Database credential authentication. Future work should consider adding support for: - 1. TOTP - 2. Policy management. + 1. TOTP (Time-based One-Time Password) + 2. Policy management for fine-grained access control. -** API endpoints +* Documentation -*** The login type + Comprehensive documentation is available in the [[file:docs/][docs]] directory: - The general datastructure used to log in should look like: + - [[file:docs/overview.org][Overview]] - Project overview, system architecture, database schema, and security considerations + - [[file:docs/api.org][API Documentation]] - API endpoints, request/response formats, error handling, and authentication flow + - [[file:docs/installation.org][Installation and Usage Guide]] - Prerequisites, installation steps, running the server, and more -#+begin_src: json -{ - "version": "v1", - "login": { - "user": "username", - "password": "secret password", - "token": "1234567890", - "totp": "123456" - } -} -#+end_src +* Quick Start - Any fields that aren't used should be omitted. The =version= and - =login.user= types are required, as well as the appropriate - credential field. + To get started with MCIAS: + + 1. Initialize the database: + #+begin_src bash + go run main.go init --db ./mcias.db + #+end_src + + 2. Start the server: + #+begin_src bash + go run main.go server --db ./mcias.db + #+end_src + + 3. The server will listen on port 8080 by default. + +* CLI Commands + + MCIAS provides a command-line interface with the following commands: + +** Server Command + + Start the MCIAS server: + #+begin_src bash + go run main.go server [--db ] [--addr
] + #+end_src + +** Init Command + + Initialize the database: + #+begin_src bash + go run main.go init [--db ] + #+end_src + +** User Commands + + Add a new user: + #+begin_src bash + go run main.go user add --username --password + #+end_src + + List all users: + #+begin_src bash + go run main.go user list + #+end_src + +** Token Commands + + Add a new token for a user: + #+begin_src bash + go run main.go token add --username [--duration ] + #+end_src + + List all tokens: + #+begin_src bash + go run main.go token list + #+end_src + +* API Overview + +** Authentication Endpoints *** =/v1/login/password= - - The request should be a JSON object: - + Password-based authentication endpoint. *** =/v1/login/token= + Token-based authentication endpoint. *** =/v1/credentials/database= + Database credential authentication endpoint (not yet fully implemented). + +** Request Format + + The general datastructure used to log in should look like: + + #+begin_src json + { + "version": "v1", + "login": { + "user": "username", + "password": "secret password", + "token": "1234567890", + "totp": "123456" + } + } + #+end_src + + Any fields that aren't used should be omitted. The =version= and + =login.user= types are required, as well as the appropriate + credential field. + +* Development + + - Run tests: =go test ./...= + - Run linter: =golangci-lint run= + + See the [[file:docs/installation.org][Installation and Usage Guide]] for more details. diff --git a/api/auth.go b/api/auth.go new file mode 100644 index 0000000..e805725 --- /dev/null +++ b/api/auth.go @@ -0,0 +1,266 @@ +package api + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "git.wntrmute.dev/kyle/mcias/data" + "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 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 + } + + if !user.Check(&req.Login) { + s.sendError(w, "Invalid username or password", http.StatusUnauthorized) + return + } + + token, expires, err := s.createToken(user.ID) + if err != nil { + s.Logger.Printf("Token creation 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: token, + Expires: expires, + }); 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 + } + + userID, err := s.verifyToken(req.Login.User, req.Login.Token) + if err != nil { + s.sendError(w, "Invalid or expired token", http.StatusUnauthorized) + return + } + + token, expires, err := s.createToken(userID) + if err != nil { + s.Logger.Printf("Token creation 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: token, + Expires: expires, + }); err != nil { + s.Logger.Printf("Error encoding response: %v", err) + } +} + +func (s *Server) sendError(w http.ResponseWriter, message string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(ErrorResponse{Error: message}); err != nil { + s.Logger.Printf("Error encoding error response: %v", err) + } +} + +func (s *Server) getUserByUsername(username string) (*data.User, error) { + query := `SELECT id, created, user, password, salt FROM users WHERE user = ?` + row := s.DB.QueryRow(query, username) + + user := &data.User{} + err := row.Scan(&user.ID, &user.Created, &user.User, &user.Password, &user.Salt) + if err != nil { + return nil, err + } + + rolesQuery := ` + SELECT r.role FROM roles r + JOIN user_roles ur ON r.id = ur.rid + WHERE ur.uid = ? + ` + rows, err := s.DB.Query(rolesQuery, user.ID) + 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) { + token := ulid.Make().String() + + expires := time.Now().Add(24 * time.Hour).Unix() + query := `INSERT INTO tokens (id, uid, token, expires) VALUES (?, ?, ?, ?)` + tokenID := ulid.Make().String() + _, err := s.DB.Exec(query, tokenID, userID, token, expires) + if err != nil { + return "", 0, err + } + + return token, expires, nil +} + +func (s *Server) verifyToken(username, token string) (string, error) { + query := ` + SELECT t.uid, t.expires FROM tokens t + JOIN users u ON t.uid = u.id + WHERE u.user = ? AND t.token = ? + ` + var userID string + var expires int64 + err := s.DB.QueryRow(query, username, token).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) 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 + _, err := s.verifyToken(username, token) + if err != nil { + s.sendError(w, "Invalid or expired token", http.StatusUnauthorized) + return + } + + // Check if user has admin role + user, err := s.getUserByUsername(username) + if err != nil { + s.Logger.Printf("Database error: %v", err) + s.sendError(w, "Internal server error", http.StatusInternalServerError) + return + } + + hasAdminRole := false + for _, role := range user.Roles { + if role == "admin" { + hasAdminRole = true + break + } + } + + if !hasAdminRole { + s.sendError(w, "Insufficient permissions", http.StatusForbidden) + return + } + + // Retrieve database credentials + query := `SELECT id, host, port, name, user, password FROM database LIMIT 1` + row := s.DB.QueryRow(query) + + var id string + var creds DatabaseCredentials + err = row.Scan(&id, &creds.Host, &creds.Port, &creds.Name, &creds.User, &creds.Password) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + s.sendError(w, "No database credentials found", http.StatusNotFound) + } else { + s.Logger.Printf("Database error: %v", err) + s.sendError(w, "Internal server error", http.StatusInternalServerError) + } + return + } + + // Return the credentials + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(creds); err != nil { + s.Logger.Printf("Error encoding response: %v", err) + } +} diff --git a/api/auth_test.go b/api/auth_test.go new file mode 100644 index 0000000..65dff77 --- /dev/null +++ b/api/auth_test.go @@ -0,0 +1,336 @@ +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("../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) 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) + } + + 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" + 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) + } + + 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) + } + + 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 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 createTestAdminUser(t *testing.T, db *sql.DB) *data.User { + user := createTestUser(t, db) + + // Add admin role + roleID := "role123" + _, err := db.Exec("INSERT INTO roles (id, role) VALUES (?, ?)", roleID, "admin") + if err != nil { + t.Fatalf("Failed to insert admin role: %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 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 TestDatabaseCredentials(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 TestDatabaseCredentialsUnauthorized(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + user := createTestUser(t, db) // Regular user without admin role + 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.StatusForbidden { + t.Errorf("Expected status code %d, got %d", http.StatusForbidden, recorder.Code) + } +} diff --git a/api/server.go b/api/server.go new file mode 100644 index 0000000..8578876 --- /dev/null +++ b/api/server.go @@ -0,0 +1,42 @@ +package api + +import ( + "database/sql" + "log" + "net/http" + + _ "github.com/mattn/go-sqlite3" +) + +type Server struct { + DB *sql.DB + Router *http.ServeMux + Logger *log.Logger +} + +func NewServer(db *sql.DB, logger *log.Logger) *Server { + s := &Server{ + DB: db, + Router: http.NewServeMux(), + Logger: logger, + } + + 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("GET /v1/database/credentials", s.handleDatabaseCredentials) +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.Router.ServeHTTP(w, r) +} + +func (s *Server) Start(addr string) error { + s.Logger.Printf("Starting server on %s", addr) + return http.ListenAndServe(addr, s) +} diff --git a/cmd/mcias/database.go b/cmd/mcias/database.go new file mode 100644 index 0000000..c1e50de --- /dev/null +++ b/cmd/mcias/database.go @@ -0,0 +1,110 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + + "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 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(cmd *cobra.Command, args []string) { + getCredentials() + }, +} + +func init() { + rootCmd.AddCommand(databaseCmd) + databaseCmd.AddCommand(getCredentialsCmd) + + 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) + req, err := http.NewRequest("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 err := json.Unmarshal(body, &errResp); err == nil { + logger.Fatalf("Error: %s", errResp.Error) + } else { + logger.Fatalf("Error: %s", resp.Status) + } + } + + var creds DatabaseCredentials + if err := json.Unmarshal(body, &creds); err != nil { + logger.Fatalf("Failed to parse response: %v", err) + } + + 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) +} \ No newline at end of file diff --git a/cmd/mcias/init.go b/cmd/mcias/init.go new file mode 100644 index 0000000..ce4e0f2 --- /dev/null +++ b/cmd/mcias/init.go @@ -0,0 +1,68 @@ +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 new file mode 100644 index 0000000..a26b076 --- /dev/null +++ b/cmd/mcias/main.go @@ -0,0 +1,13 @@ +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/root.go b/cmd/mcias/root.go new file mode 100644 index 0000000..7f813d2 --- /dev/null +++ b/cmd/mcias/root.go @@ -0,0 +1,64 @@ +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 { + return rootCmd.Execute() +} + +func init() { + 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", ":8080", "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/root_test.go b/cmd/mcias/root_test.go new file mode 100644 index 0000000..b7ff37c --- /dev/null +++ b/cmd/mcias/root_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestRootCommand(t *testing.T) { + if rootCmd.Use != "mcias" { + t.Errorf("Expected root command Use to be 'mcias', got '%s'", rootCmd.Use) + } + + if rootCmd.Short == "" { + t.Error("Expected root command Short to be set") + } + + if rootCmd.Long == "" { + t.Error("Expected root command Long to be set") + } + dbFlag := rootCmd.PersistentFlags().Lookup("db") + if dbFlag == nil { + t.Error("Expected 'db' flag to be defined") + } else { + if dbFlag.DefValue != "mcias.db" { + t.Errorf("Expected 'db' flag default value to be 'mcias.db', got '%s'", dbFlag.DefValue) + } + } + + addrFlag := rootCmd.PersistentFlags().Lookup("addr") + if addrFlag == nil { + t.Error("Expected 'addr' flag to be defined") + } else { + if addrFlag.DefValue != ":8080" { + t.Errorf("Expected 'addr' flag default value to be ':8080', got '%s'", addrFlag.DefValue) + } + } + hasServerCmd := false + hasInitCmd := false + hasUserCmd := false + hasTokenCmd := false + + for _, cmd := range rootCmd.Commands() { + switch cmd.Use { + case "server": + hasServerCmd = true + case "init": + hasInitCmd = true + case "user": + hasUserCmd = true + case "token": + hasTokenCmd = true + } + } + + if !hasServerCmd { + t.Error("Expected 'server' command to be added to root command") + } + if !hasInitCmd { + t.Error("Expected 'init' command to be added to root command") + } + if !hasUserCmd { + t.Error("Expected 'user' command to be added to root command") + } + if !hasTokenCmd { + t.Error("Expected 'token' command to be added to root command") + } +} + +func TestExecute(t *testing.T) { + origCmd := rootCmd + defer func() { rootCmd = origCmd }() + + rootCmd = &cobra.Command{Use: "test"} + if err := Execute(); err != nil { + t.Errorf("Execute() returned an error: %v", err) + } +} diff --git a/cmd/mcias/server.go b/cmd/mcias/server.go new file mode 100644 index 0000000..7adda4a --- /dev/null +++ b/cmd/mcias/server.go @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..0d33ef7 --- /dev/null +++ b/cmd/mcias/token.go @@ -0,0 +1,144 @@ +package main + +import ( + "database/sql" + "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 err == sql.ErrNoRows { + logger.Fatalf("User %s does not exist", tokenUsername) + } + logger.Fatalf("Failed to check if user exists: %v", err) + } + + token := ulid.Make().String() + + 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/user.go b/cmd/mcias/user.go new file mode 100644 index 0000000..3e39156 --- /dev/null +++ b/cmd/mcias/user.go @@ -0,0 +1,134 @@ +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/user.go b/data/user.go index 2210218..6f328fb 100644 --- a/data/user.go +++ b/data/user.go @@ -45,7 +45,7 @@ func (u *User) Check(login *Login) bool { return false } - if subtle.ConstantTimeCompare(derived, u.Password) != 0 { + if subtle.ConstantTimeCompare(derived, u.Password) != 1 { return false } diff --git a/data/user_test.go b/data/user_test.go new file mode 100644 index 0000000..36697c8 --- /dev/null +++ b/data/user_test.go @@ -0,0 +1,83 @@ +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/schema.go b/database/schema.go new file mode 100644 index 0000000..031d1ba --- /dev/null +++ b/database/schema.go @@ -0,0 +1,18 @@ +// 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 +} \ No newline at end of file diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..8f78467 --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,42 @@ +CREATE TABLE users ( + id text primary key, + created integer, + user text not null, + password blob not null, + salt blob not null +); + +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) +); \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b866186 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,43 @@ +# 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 new file mode 100644 index 0000000..02307cf --- /dev/null +++ b/docs/README.org @@ -0,0 +1,46 @@ +#+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 new file mode 100644 index 0000000..86d6e74 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,125 @@ +# 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**: +```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 +} +``` + +**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**: +```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 + +### 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 + +1. **Initial Authentication**: + - Client sends username and password to `/v1/login/password` + - Server validates credentials and returns a token + +2. **Subsequent Requests**: + - Client uses the token for authentication by sending it to `/v1/login/token` + - Server validates the token and issues a new token + +3. **Token Expiration**: + - Tokens expire after 24 hours + - Clients should request a new token before expiration \ No newline at end of file diff --git a/docs/api.org b/docs/api.org new file mode 100644 index 0000000..1e57307 --- /dev/null +++ b/docs/api.org @@ -0,0 +1,128 @@ +#+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*: =/v1/credentials/database= (Not yet implemented) + +*Description*: Retrieves database credentials for authorized users. + +** 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 + +2. *Subsequent Requests*: + - Client uses the token for authentication by sending it to =/v1/login/token= + - Server validates the token and issues a new token + +3. *Token Expiration*: + - Tokens expire after 24 hours + - Clients should request a new token before expiration \ No newline at end of file diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..db84d31 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,170 @@ +# 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 new file mode 100644 index 0000000..15013e1 --- /dev/null +++ b/docs/installation.org @@ -0,0 +1,173 @@ +#+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 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 main.go -db ./mcias.db +#+end_src + +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: + +#+begin_src bash +go run main.go -db ./mcias.db -addr :9000 +#+end_src + +** Building from Source + +To build a binary: + +#+begin_src bash +go build -o mcias +#+end_src + +Then run the binary: + +#+begin_src bash +./mcias -db ./mcias.db +#+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 + +** 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 \ No newline at end of file diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..6aff1eb --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,130 @@ +# 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 new file mode 100644 index 0000000..8684468 --- /dev/null +++ b/docs/overview.org @@ -0,0 +1,133 @@ +#+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 + +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 +#+begin_src sql +CREATE TABLE users ( + id text primary key, + created integer, + user text not null, + password blob not null, + salt blob not null +); +#+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 + +** 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/go.mod b/go.mod index ce99df2..0f16306 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,24 @@ module git.wntrmute.dev/kyle/mcias go 1.23.8 require ( + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-sqlite3 v1.14.28 // indirect github.com/oklog/ulid/v2 v2.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/viper v1.20.1 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect golang.org/x/crypto v0.38.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 96418fb..ea1dda4 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,49 @@ +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= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..2dbc829 --- /dev/null +++ b/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "os" + "os/exec" +) + +func main() { + cmd := exec.Command("go", "run", "cmd/mcias/main.go") + + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error running mcias command: %v\n", err) + os.Exit(1) + } +}