From 843cd34785d07adf042ad958adcd3023e044dab5 Mon Sep 17 00:00:00 2001 From: imterah Date: Sun, 5 Jan 2025 20:51:06 -0500 Subject: [PATCH] chore: Adds login and user creation support to the API. --- .gitignore | 4 +- apiclient/apiclient.go | 21 +++++ apiclient/backendstructs/struct.go | 102 ++++++++++++++++++++ apiclient/users/auth.go | 99 ++++++++++++++++++++ apiclient/users/create.go | 63 +++++++++++++ frontend/commands/users/create.go | 115 +++++++++++++++++++++++ frontend/commands/users/login.go | 98 ++++++++++++++++++++ frontend/config/config.go | 30 ++++++ frontend/dev.env | 1 + frontend/main.go | 143 +++++++++++++++++++++++++++++ go.mod | 5 +- go.sum | 8 +- init.sh | 6 +- 13 files changed, 683 insertions(+), 12 deletions(-) create mode 100644 apiclient/apiclient.go create mode 100644 apiclient/backendstructs/struct.go create mode 100644 apiclient/users/auth.go create mode 100644 apiclient/users/create.go create mode 100644 frontend/commands/users/create.go create mode 100644 frontend/commands/users/login.go create mode 100644 frontend/config/config.go create mode 100644 frontend/dev.env create mode 100644 frontend/main.go diff --git a/.gitignore b/.gitignore index f82cd86..cf34e00 100644 --- a/.gitignore +++ b/.gitignore @@ -3,13 +3,11 @@ backend/sshbackend/sshbackend backend/dummybackend/dummybackend backend/externalbackendlauncher/externalbackendlauncher backend/api/api +frontend/frontend # Backup artifacts *.json.gz -# LOM -sshfrontend/keys - # Output out diff --git a/apiclient/apiclient.go b/apiclient/apiclient.go new file mode 100644 index 0000000..8f23a80 --- /dev/null +++ b/apiclient/apiclient.go @@ -0,0 +1,21 @@ +package apiclient + +import "git.terah.dev/imterah/hermes/apiclient/users" + +type HermesAPIClient struct { + URL string +} + +/// Users + +func (api *HermesAPIClient) UserGetRefreshToken(username *string, email *string, password string) (string, error) { + return users.GetRefreshToken(api.URL, username, email, password) +} + +func (api *HermesAPIClient) UserGetJWTFromToken(refreshToken string) (string, error) { + return users.GetJWTFromToken(api.URL, refreshToken) +} + +func (api *HermesAPIClient) UserCreate(fullName, username, email, password string, isBot bool) (string, error) { + return users.CreateUser(api.URL, fullName, username, email, password, isBot) +} diff --git a/apiclient/backendstructs/struct.go b/apiclient/backendstructs/struct.go new file mode 100644 index 0000000..be4b757 --- /dev/null +++ b/apiclient/backendstructs/struct.go @@ -0,0 +1,102 @@ +package backendstructs + +type BackendCreationRequest struct { + Token string `validate:"required"` + Name string `validate:"required"` + Description *string `json:"description"` + Backend string `validate:"required"` + BackendParameters interface{} `json:"connectionDetails" validate:"required"` +} + +type BackendLookupRequest struct { + Token string `validate:"required"` + BackendID *uint `json:"id"` + Name *string `json:"name"` + Description *string `json:"description"` + Backend *string `json:"backend"` +} + +type BackendRemovalRequest struct { + Token string `validate:"required"` + BackendID uint `json:"id" validate:"required"` +} + +type ConnectionsRequest struct { + Token string `validate:"required" json:"token"` + Id uint `validate:"required" json:"id"` +} + +type ProxyCreationRequest struct { + Token string `validate:"required" json:"token"` + Name string `validate:"required" json:"name"` + Description *string `json:"description"` + Protocol string `validate:"required" json:"protocol"` + SourceIP string `validate:"required" json:"sourceIP"` + SourcePort uint16 `validate:"required" json:"sourcePort"` + DestinationPort uint16 `validate:"required" json:"destinationPort"` + ProviderID uint `validate:"required" json:"providerID"` + AutoStart *bool `json:"autoStart"` +} + +type ProxyLookupRequest struct { + Token string `validate:"required" json:"token"` + Id *uint `json:"id"` + Name *string `json:"name"` + Description *string `json:"description"` + Protocol *string `json:"protocol"` + SourceIP *string `json:"sourceIP"` + SourcePort *uint16 `json:"sourcePort"` + DestinationPort *uint16 `json:"destPort"` + ProviderID *uint `json:"providerID"` + AutoStart *bool `json:"autoStart"` +} + +type ProxyRemovalRequest struct { + Token string `validate:"required" json:"token"` + ID uint `validate:"required" json:"id"` +} + +type ProxyStartRequest struct { + Token string `validate:"required" json:"token"` + ID uint `validate:"required" json:"id"` +} + +type ProxyStopRequest struct { + Token string `validate:"required" json:"token"` + ID uint `validate:"required" json:"id"` +} + +type UserCreationRequest struct { + Name string `json:"name" validate:"required"` + Email string `json:"email" validate:"required"` + Password string `json:"password" validate:"required"` + Username string `json:"username" validate:"required"` + + ExistingUserToken string `json:"token"` + IsBot bool `json:"isBot"` +} + +type UserLoginRequest struct { + Username *string `json:"username"` + Email *string `json:"email"` + + Password string `json:"password" validate:"required"` +} + +type UserLookupRequest struct { + Token string `validate:"required"` + UID *uint `json:"id"` + Name *string `json:"name"` + Email *string `json:"email"` + Username *string `json:"username"` + IsBot *bool `json:"isServiceAccount"` +} + +type UserRefreshRequest struct { + Token string `json:"token" validate:"required"` +} + +type UserRemovalRequest struct { + Token string `json:"token" validate:"required"` + UID *uint `json:"uid"` +} diff --git a/apiclient/users/auth.go b/apiclient/users/auth.go new file mode 100644 index 0000000..91e7f67 --- /dev/null +++ b/apiclient/users/auth.go @@ -0,0 +1,99 @@ +package users + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "git.terah.dev/imterah/hermes/apiclient/backendstructs" +) + +type refreshTokenResponse struct { + Success bool `json:"success"` + RefreshToken string `json:"refreshToken"` +} + +type jwtTokenResponse struct { + Success bool `json:"success"` + JWT string `json:"token"` +} + +func GetRefreshToken(url string, username, email *string, password string) (string, error) { + body, err := json.Marshal(&backendstructs.UserLoginRequest{ + Username: username, + Email: email, + Password: password, + }) + + if err != nil { + return "", err + } + + res, err := http.Post(fmt.Sprintf("%s/api/v1/users/login", url), "application/json", bytes.NewBuffer(body)) + + if err != nil { + return "", err + } + + bodyContents, err := io.ReadAll(res.Body) + + if err != nil { + return "", fmt.Errorf("failed to read response body: %s", err.Error()) + } + + response := &refreshTokenResponse{} + + if err := json.Unmarshal(bodyContents, response); err != nil { + return "", err + } + + if !response.Success { + return "", fmt.Errorf("failed to get refresh token") + } + + if response.RefreshToken == "" { + return "", fmt.Errorf("refresh token is empty") + } + + return response.RefreshToken, nil +} + +func GetJWTFromToken(url, refreshToken string) (string, error) { + body, err := json.Marshal(&backendstructs.UserRefreshRequest{ + Token: refreshToken, + }) + + if err != nil { + return "", err + } + + res, err := http.Post(fmt.Sprintf("%s/api/v1/users/refresh", url), "application/json", bytes.NewBuffer(body)) + + if err != nil { + return "", err + } + + bodyContents, err := io.ReadAll(res.Body) + + if err != nil { + return "", fmt.Errorf("failed to read response body: %s", err.Error()) + } + + response := &jwtTokenResponse{} + + if err := json.Unmarshal(bodyContents, response); err != nil { + return "", err + } + + if !response.Success { + return "", fmt.Errorf("failed to get JWT token") + } + + if response.JWT == "" { + return "", fmt.Errorf("JWT token is empty") + } + + return response.JWT, nil +} diff --git a/apiclient/users/create.go b/apiclient/users/create.go new file mode 100644 index 0000000..6e03c58 --- /dev/null +++ b/apiclient/users/create.go @@ -0,0 +1,63 @@ +package users + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "git.terah.dev/imterah/hermes/apiclient/backendstructs" +) + +type createUserResponse struct { + Error string `json:"error"` + Success bool `json:"success"` + RefreshToken string `json:"refreshToken"` +} + +func CreateUser(url, fullName, username, email, password string, isBot bool) (string, error) { + body, err := json.Marshal(&backendstructs.UserCreationRequest{ + Username: username, + Name: fullName, + Email: email, + Password: password, + IsBot: isBot, + }) + + if err != nil { + return "", err + } + + res, err := http.Post(fmt.Sprintf("%s/api/v1/users/create", url), "application/json", bytes.NewBuffer(body)) + + if err != nil { + return "", err + } + + bodyContents, err := io.ReadAll(res.Body) + + if err != nil { + return "", fmt.Errorf("failed to read response body: %s", err.Error()) + } + + response := &createUserResponse{} + + if err := json.Unmarshal(bodyContents, response); err != nil { + return "", err + } + + if response.Error != "" { + return "", fmt.Errorf("error from server: %s", response.Error) + } + + if !response.Success { + return "", fmt.Errorf("failed to get refresh token") + } + + if response.RefreshToken == "" { + return "", fmt.Errorf("refresh token is empty") + } + + return response.RefreshToken, nil +} diff --git a/frontend/commands/users/create.go b/frontend/commands/users/create.go new file mode 100644 index 0000000..3d6c94b --- /dev/null +++ b/frontend/commands/users/create.go @@ -0,0 +1,115 @@ +package users + +import ( + "errors" + "fmt" + "os" + "syscall" + + "git.terah.dev/imterah/hermes/apiclient" + "git.terah.dev/imterah/hermes/frontend/config" + "github.com/charmbracelet/log" + "github.com/urfave/cli/v2" + "golang.org/x/term" + "gopkg.in/yaml.v3" +) + +func CreateUserCommand(cCtx *cli.Context) error { + configPath := cCtx.String("config-path") + + var configContents *config.Config + + _, err := os.Stat(configPath) + + if err != nil { + if errors.Is(err, os.ErrNotExist) { + configContents = &config.Config{} + } else { + return fmt.Errorf("failed to get configuration file information: %s", err.Error()) + } + } else { + configContents, err = config.ReadAndParseConfig(configPath) + + if err != nil { + return fmt.Errorf("failed to read and parse configuration file: %s", err.Error()) + } + } + + username := cCtx.String("username") + + if username == "" { + if configContents.Username == "" { + return fmt.Errorf("username not specified and username is not in the configuration file") + } + + username = configContents.Username + } + + var password string + + if cCtx.Bool("ask-password") { + fmt.Print("Password: ") + passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Print("\n") + + if err != nil { + return fmt.Errorf("failed to read password from console: %s", err.Error()) + } + + password = string(passwordBytes) + } else { + password = cCtx.String("password") + + if password == "" { + return fmt.Errorf("password is not specified and password asking is not enabled") + } + } + + var serverURL string + + if cCtx.String("server-url") == "" { + if configContents.APIPath == "" { + return fmt.Errorf("server URL not specified and server URL is not in the configuration file") + } + + serverURL = configContents.APIPath + } else { + serverURL = cCtx.String("server-url") + } + + fullName := cCtx.String("full-name") + email := cCtx.String("email") + isBot := cCtx.Bool("user-is-bot") + + log.Info("Creating user...") + + api := &apiclient.HermesAPIClient{ + URL: serverURL, + } + + refreshToken, err := api.UserCreate(fullName, username, email, password, isBot) + + if err != nil { + return fmt.Errorf("failed to create user: %s", err.Error()) + } + + log.Info("Successfully created user.") + + if cCtx.Bool("do-not-save-configuration") { + return nil + } + + configContents.Username = username + configContents.RefreshToken = refreshToken + configContents.APIPath = serverURL + + data, err := yaml.Marshal(configContents) + + if err != nil { + return fmt.Errorf("failed to marshal configuration data: %s", err.Error()) + } + + os.WriteFile(configPath, data, 0644) + + return nil +} diff --git a/frontend/commands/users/login.go b/frontend/commands/users/login.go new file mode 100644 index 0000000..8248b1f --- /dev/null +++ b/frontend/commands/users/login.go @@ -0,0 +1,98 @@ +package users + +import ( + "errors" + "fmt" + "os" + "syscall" + + "git.terah.dev/imterah/hermes/apiclient" + "git.terah.dev/imterah/hermes/frontend/config" + "github.com/charmbracelet/log" + "github.com/urfave/cli/v2" + "golang.org/x/term" + "gopkg.in/yaml.v3" +) + +func GetRefreshTokenCommand(cCtx *cli.Context) error { + configPath := cCtx.String("config-path") + + var configContents *config.Config + + _, err := os.Stat(configPath) + + if err != nil { + if errors.Is(err, os.ErrNotExist) { + configContents = &config.Config{} + } else { + return fmt.Errorf("failed to get configuration file information: %s", err.Error()) + } + } else { + configContents, err = config.ReadAndParseConfig(configPath) + + if err != nil { + return fmt.Errorf("failed to read and parse configuration file: %s", err.Error()) + } + } + + var username string + var password string + + if cCtx.String("username") == "" { + if configContents.Username == "" { + return fmt.Errorf("username not specified and username is not in the configuration file") + } + + username = configContents.Username + } else { + username = cCtx.String("username") + } + + if cCtx.Bool("ask-password") { + fmt.Print("Password: ") + passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Print("\n") + + if err != nil { + return fmt.Errorf("failed to read password from console: %s", err.Error()) + } + + password = string(passwordBytes) + } else { + password = cCtx.String("password") + + if password == "" { + return fmt.Errorf("password is not specified and password asking is not enabled") + } + } + + serverURL := cCtx.String("server-url") + log.Info("Authenticating with API...") + + api := &apiclient.HermesAPIClient{ + URL: serverURL, + } + + refreshToken, err := api.UserGetRefreshToken(&username, nil, password) + + if err != nil { + return fmt.Errorf("failed to authenticate with the API: %s", err.Error()) + } + + configContents.Username = username + configContents.RefreshToken = refreshToken + configContents.APIPath = serverURL + + log.Info("Writing configuration file...") + data, err := yaml.Marshal(configContents) + + if err != nil { + return fmt.Errorf("failed to marshal configuration data: %s", err.Error()) + } + + log.Infof("config path: %s", configPath) + + os.WriteFile(configPath, data, 0644) + + return nil +} diff --git a/frontend/config/config.go b/frontend/config/config.go new file mode 100644 index 0000000..ff9cb69 --- /dev/null +++ b/frontend/config/config.go @@ -0,0 +1,30 @@ +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Username string `json:"username"` + RefreshToken string `json:"token"` + APIPath string `json:"api_path"` +} + +func ReadAndParseConfig(configFile string) (*Config, error) { + configFileContents, err := os.ReadFile(configFile) + + if err != nil { + return nil, err + } + + config := &Config{} + err = yaml.Unmarshal(configFileContents, config) + + if err != nil { + return nil, err + } + + return config, nil +} diff --git a/frontend/dev.env b/frontend/dev.env new file mode 100644 index 0000000..ccd7c30 --- /dev/null +++ b/frontend/dev.env @@ -0,0 +1 @@ +HERMES_LOG_LEVEL=debug diff --git a/frontend/main.go b/frontend/main.go new file mode 100644 index 0000000..95b366f --- /dev/null +++ b/frontend/main.go @@ -0,0 +1,143 @@ +package main + +import ( + "os" + "path" + + "git.terah.dev/imterah/hermes/frontend/commands/users" + "github.com/charmbracelet/log" + "github.com/urfave/cli/v2" +) + +func main() { + logLevel := os.Getenv("HERMES_LOG_LEVEL") + + if logLevel != "" { + switch logLevel { + case "debug": + log.SetLevel(log.DebugLevel) + + case "info": + log.SetLevel(log.InfoLevel) + + case "warn": + log.SetLevel(log.WarnLevel) + + case "error": + log.SetLevel(log.ErrorLevel) + + case "fatal": + log.SetLevel(log.FatalLevel) + } + } + + configDir, err := os.UserConfigDir() + + if err != nil { + log.Fatalf("Failed to get configuration directory: %s", err.Error()) + } + + app := &cli.App{ + Name: "hermcli", + Usage: "client for Hermes -- port forwarding across boundaries", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "config-path", + Aliases: []string{"config", "cp", "c"}, + Value: path.Join(configDir, "hermcli.yml"), + }, + }, + Commands: []*cli.Command{ + { + Name: "login", + Usage: "log in to the API", + Action: users.GetRefreshTokenCommand, + Aliases: []string{"l"}, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "username", + Aliases: []string{"user", "u"}, + Usage: "username to authenticate as", + }, + &cli.StringFlag{ + Name: "password", + Aliases: []string{"pass", "p"}, + Usage: "password to authenticate with", + }, + &cli.StringFlag{ + Name: "server-url", + Aliases: []string{"server", "s"}, + Usage: "URL of the server to authenticate with", + }, + &cli.BoolFlag{ + Name: "ask-password", + Aliases: []string{"ask-pass", "ap"}, + Usage: "asks you the password to authenticate with", + }, + }, + }, + { + Name: "users", + Usage: "user management commands", + Aliases: []string{"u"}, + Subcommands: []*cli.Command{ + { + Name: "create", + Aliases: []string{"c"}, + Usage: "create a user", + Action: users.CreateUserCommand, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "full-name", + Aliases: []string{"name", "n"}, + Usage: "full name for the user", + Required: true, + }, + &cli.StringFlag{ + Name: "username", + Aliases: []string{"user", "us"}, + Usage: "username to give the user", + Required: true, + }, + &cli.StringFlag{ + Name: "email", + Aliases: []string{"e"}, + Usage: "email to give the user", + Required: true, + }, + &cli.StringFlag{ + Name: "password", + Aliases: []string{"pass", "p"}, + Usage: "password to give the user", + }, + &cli.StringFlag{ + Name: "server-url", + Aliases: []string{"server", "s"}, + Usage: "URL of the server to connect with", + }, + &cli.BoolFlag{ + Name: "ask-password", + Aliases: []string{"ask-pass", "ap"}, + Usage: "asks you the password to give the user", + }, + &cli.BoolFlag{ + Name: "user-is-bot", + Aliases: []string{"user-bot", "ub", "u"}, + Usage: "if set, makes the user flagged as a bot", + }, + &cli.BoolFlag{ + Name: "do-not-save-configuration", + Aliases: []string{"no-save", "ns"}, + Usage: "doesn't save the authenticated user credentials", + }, + }, + }, + }, + }, + }, + } + + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod index 4f83220..0942d36 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.1 github.com/urfave/cli/v2 v2.27.5 golang.org/x/crypto v0.31.0 + golang.org/x/term v0.28.0 + gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/postgres v1.5.11 gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 @@ -57,8 +59,7 @@ require ( golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/net v0.33.0 // indirect golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect google.golang.org/protobuf v1.36.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 65feb3f..da8d74e 100644 --- a/go.sum +++ b/go.sum @@ -125,10 +125,10 @@ golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ= diff --git a/init.sh b/init.sh index 1fe457d..65aaee0 100644 --- a/init.sh +++ b/init.sh @@ -7,11 +7,11 @@ if [ ! -d "backend/.tmp" ]; then mkdir backend/.tmp fi -if [ ! -f "backend-legacy/.env" ]; then - cp backend-legacy/dev.env backend-legacy/.env +if [ ! -f "frontend/.env" ]; then + cp frontend/dev.env frontend/.env fi set -a -source backend-legacy/.env source backend/.env +source frontend/.env set +a