Core implementation written with Junie.
This commit is contained in:
parent
0ef669352f
commit
e22c12fd39
|
@ -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.
|
121
README.org
121
README.org
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
* MCIAS
|
* 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:
|
It currently provides the following across metacircular services:
|
||||||
|
|
||||||
|
@ -12,36 +12,111 @@
|
||||||
3. Database credential authentication.
|
3. Database credential authentication.
|
||||||
|
|
||||||
Future work should consider adding support for:
|
Future work should consider adding support for:
|
||||||
1. TOTP
|
1. TOTP (Time-based One-Time Password)
|
||||||
2. Policy management.
|
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
|
* Quick Start
|
||||||
{
|
|
||||||
"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
|
To get started with MCIAS:
|
||||||
=login.user= types are required, as well as the appropriate
|
|
||||||
credential field.
|
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 <path>] [--addr <address>]
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
** Init Command
|
||||||
|
|
||||||
|
Initialize the database:
|
||||||
|
#+begin_src bash
|
||||||
|
go run main.go init [--db <path>]
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
** User Commands
|
||||||
|
|
||||||
|
Add a new user:
|
||||||
|
#+begin_src bash
|
||||||
|
go run main.go user add --username <username> --password <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 <username> [--duration <hours>]
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
List all tokens:
|
||||||
|
#+begin_src bash
|
||||||
|
go run main.go token list
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
* API Overview
|
||||||
|
|
||||||
|
** Authentication Endpoints
|
||||||
|
|
||||||
*** =/v1/login/password=
|
*** =/v1/login/password=
|
||||||
|
Password-based authentication endpoint.
|
||||||
The request should be a JSON object:
|
|
||||||
|
|
||||||
|
|
||||||
*** =/v1/login/token=
|
*** =/v1/login/token=
|
||||||
|
Token-based authentication endpoint.
|
||||||
|
|
||||||
*** =/v1/credentials/database=
|
*** =/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.
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -45,7 +45,7 @@ func (u *User) Check(login *Login) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if subtle.ConstantTimeCompare(derived, u.Password) != 0 {
|
if subtle.ConstantTimeCompare(derived, u.Password) != 1 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
);
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>`: Path to the SQLite database file (default: `mcias.db`)
|
||||||
|
- `-addr <address>`: 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
|
|
@ -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>=: Path to the SQLite database file (default: =mcias.db=)
|
||||||
|
- =-addr <address>=: 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
|
|
@ -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
|
|
@ -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
|
17
go.mod
17
go.mod
|
@ -3,7 +3,24 @@ module git.wntrmute.dev/kyle/mcias
|
||||||
go 1.23.8
|
go 1.23.8
|
||||||
|
|
||||||
require (
|
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/mattn/go-sqlite3 v1.14.28 // indirect
|
||||||
github.com/oklog/ulid/v2 v2.1.0 // 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/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
|
||||||
)
|
)
|
||||||
|
|
42
go.sum
42
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 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
|
||||||
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
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/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 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
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=
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue