Junie: TOTP flow update and db migrations.

This commit is contained in:
2025-06-06 12:42:23 -07:00
parent 396214739e
commit 95d96732d2
26 changed files with 1397 additions and 194 deletions

View File

@@ -0,0 +1,137 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type DatabaseCredentials struct {
Host string `json:"host"`
Port int `json:"port"`
Name string `json:"name"`
User string `json:"user"`
Password string `json:"password"`
}
var (
dbUsername string
dbToken string
useStored 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.`,
Run: func(cmd *cobra.Command, args []string) {
getCredentials()
},
}
func init() {
rootCmd.AddCommand(databaseCmd)
databaseCmd.AddCommand(getCredentialsCmd)
getCredentialsCmd.Flags().StringVarP(&dbUsername, "username", "u", "", "Username for authentication")
getCredentialsCmd.Flags().StringVarP(&dbToken, "token", "t", "", "Authentication token")
getCredentialsCmd.Flags().BoolVarP(&useStored, "use-stored", "s", false, "Use stored token from previous login")
// Make username required only if not using stored token
getCredentialsCmd.MarkFlagsMutuallyExclusive("token", "use-stored")
}
func getCredentials() {
// 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"
}
url := fmt.Sprintf("%s/v1/database/credentials?username=%s", serverAddr, dbUsername)
// Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err)
os.Exit(1)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", dbToken))
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 creds DatabaseCredentials
if unmarshalErr := json.Unmarshal(body, &creds); unmarshalErr != nil {
fmt.Fprintf(os.Stderr, "Failed to parse response: %v\n", unmarshalErr)
os.Exit(1)
}
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)
}

368
cmd/mcias-client/login.go Normal file
View File

@@ -0,0 +1,368 @@
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))
}

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

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

78
cmd/mcias-client/root.go Normal file
View File

@@ -0,0 +1,78 @@
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()
}

63
cmd/mcias-client/util.go Normal file
View File

@@ -0,0 +1,63 @@
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
}