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