Core implementation written with Junie.

This commit is contained in:
Kyle Isom 2025-06-06 10:15:49 -07:00
parent 0ef669352f
commit e22c12fd39
28 changed files with 2597 additions and 24 deletions

27
.junie/guidelines.md Normal file
View File

@ -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.

View File

@ -3,7 +3,7 @@
* MCIAS
MCIAS is the metacircular identity and access system.
MCIAS is the metacircular identity and access system, providing identity and authentication across metacircular projects.
It currently provides the following across metacircular services:
@ -12,17 +12,94 @@
3. Database credential authentication.
Future work should consider adding support for:
1. TOTP
2. Policy management.
1. TOTP (Time-based One-Time Password)
2. Policy management for fine-grained access control.
** API endpoints
* Documentation
*** The login type
Comprehensive documentation is available in the [[file:docs/][docs]] directory:
- [[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
* Quick Start
To get started with MCIAS:
1. Initialize the database:
#+begin_src bash
go run main.go init --db ./mcias.db
#+end_src
2. Start the server:
#+begin_src bash
go run main.go server --db ./mcias.db
#+end_src
3. The server will listen on port 8080 by default.
* CLI Commands
MCIAS provides a command-line interface with the following commands:
** Server Command
Start the MCIAS server:
#+begin_src bash
go run main.go server [--db <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=
Password-based authentication endpoint.
*** =/v1/login/token=
Token-based authentication endpoint.
*** =/v1/credentials/database=
Database credential authentication endpoint (not yet fully implemented).
** Request Format
The general datastructure used to log in should look like:
#+begin_src: json
{
#+begin_src json
{
"version": "v1",
"login": {
"user": "username",
@ -30,18 +107,16 @@
"token": "1234567890",
"totp": "123456"
}
}
#+end_src
}
#+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.
*** =/v1/login/password=
* Development
The request should be a JSON object:
- Run tests: =go test ./...=
- Run linter: =golangci-lint run=
*** =/v1/login/token=
*** =/v1/credentials/database=
See the [[file:docs/installation.org][Installation and Usage Guide]] for more details.

266
api/auth.go Normal file
View File

@ -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)
}
}

336
api/auth_test.go Normal file
View File

@ -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)
}
}

42
api/server.go Normal file
View File

@ -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)
}

110
cmd/mcias/database.go Normal file
View File

@ -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)
}

68
cmd/mcias/init.go Normal file
View File

@ -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")
}

13
cmd/mcias/main.go Normal file
View File

@ -0,0 +1,13 @@
package main
import (
"fmt"
"os"
)
func main() {
if err := Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

64
cmd/mcias/root.go Normal file
View File

@ -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())
}
}

78
cmd/mcias/root_test.go Normal file
View File

@ -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)
}
}

46
cmd/mcias/server.go Normal file
View File

@ -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)
}
}

144
cmd/mcias/token.go Normal file
View File

@ -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)
}
}

134
cmd/mcias/user.go Normal file
View File

@ -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)
}
}

View File

@ -45,7 +45,7 @@ func (u *User) Check(login *Login) bool {
return false
}
if subtle.ConstantTimeCompare(derived, u.Password) != 0 {
if subtle.ConstantTimeCompare(derived, u.Password) != 1 {
return false
}

83
data/user_test.go Normal file
View File

@ -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")
}
}

18
database/schema.go Normal file
View File

@ -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
}

42
database/schema.sql Normal file
View File

@ -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)
);

43
docs/README.md Normal file
View File

@ -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

46
docs/README.org Normal file
View File

@ -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

125
docs/api.md Normal file
View File

@ -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

128
docs/api.org Normal file
View File

@ -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

170
docs/installation.md Normal file
View File

@ -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

173
docs/installation.org Normal file
View File

@ -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

130
docs/overview.md Normal file
View File

@ -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

133
docs/overview.org Normal file
View File

@ -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
View File

@ -3,7 +3,24 @@ module git.wntrmute.dev/kyle/mcias
go 1.23.8
require (
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-sqlite3 v1.14.28 // indirect
github.com/oklog/ulid/v2 v2.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/viper v1.20.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

42
go.sum
View File

@ -1,7 +1,49 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

20
main.go Normal file
View File

@ -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)
}
}