diff --git a/cloud/account.go b/cloud/account.go index fd61f6d..20d04c9 100644 --- a/cloud/account.go +++ b/cloud/account.go @@ -12,72 +12,60 @@ import ( "github.com/google/uuid" ) -type httpBeginLoginFunc func( - ctx context.Context, - params *oapi.BeginLoginParams, - body oapi.BeginLoginJSONRequestBody, - reqEditors ...oapi.RequestEditorFn, -) (*oapi.BeginLoginResponse, error) - -func BeginLogin( - requestBeginLogin httpBeginLoginFunc, -) func(email string) (challengeID uuid.UUID, err error) { - return func(email string) (challengeID uuid.UUID, err error) { - body := oapi.BeginLoginJSONRequestBody{ +func BeginLogin(email string) (challengeID uuid.UUID, err error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + { + body := oapi.GetUserStatusJSONRequestBody{ Email: types.Email(email), } + resp, err := client.GetUserStatusWithResponse(ctx, nil, body) + if err != nil { + return uuid.Nil, fmt.Errorf("couldn't get user status: %w", err) + } - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - defer cancel() + if resp == nil || resp.JSON200 == nil { + return uuid.Nil, fmt.Errorf("couldn't get user status: nil response") + } + } - resp, err := requestBeginLogin(ctx, nil, body) + body := oapi.BeginLoginJSONRequestBody{ + Email: types.Email(email), + } + resp, err := client.BeginLoginWithResponse(ctx, nil, body) - if err != nil || resp.JSON200 == nil { - return uuid.Nil, fmt.Errorf("couldn't perform login: error %w, http status %s", err, resp.Status()) - } + if err != nil { + return uuid.Nil, fmt.Errorf("couldn't begin login: %w", err) + } - return uuid.UUID(resp.JSON200.ChallengeId), nil + if resp == nil || resp.JSON200 == nil { + return uuid.Nil, fmt.Errorf("couldn't begin login: nil response") } + + return resp.JSON200.ChallengeId, nil } -type httpCompleteLoginFunc func( - ctx context.Context, - params *oapi.CompleteLoginParams, - body oapi.CompleteLoginJSONRequestBody, - reqEditors ...oapi.RequestEditorFn, -) (*oapi.CompleteLoginResponse, error) - -func CompleteLogin( - requestCompleteLogin httpCompleteLoginFunc, -) func( - email string, - otpCode string, - challengeID uuid.UUID, - password string, -) (token string, err error) { - return func( - email string, - otpCode string, - challengeID uuid.UUID, - password string, - ) (token string, err error) { - body := oapi.CompleteLoginJSONRequestBody{ - Email: types.Email(email), - OtpCode: otpCode, - ChallengeId: challengeID, - Password: password, - } +func CompleteLogin(email, otpCode, password string, challengeID uuid.UUID) (token string, err error) { + body := oapi.CompleteLoginJSONRequestBody{ + Email: types.Email(email), + OtpCode: otpCode, + ChallengeId: challengeID, + Password: password, + } - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - defer cancel() + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() - resp, err := requestCompleteLogin(ctx, nil, body) + resp, err := client.CompleteLoginWithResponse(ctx, nil, body) - if err != nil || resp.JSON200 == nil { - return "", fmt.Errorf("couldn't perform login: error %w, http status %s", err, resp.Status()) - } - - return resp.JSON200.Tokeng, nil + if err != nil { + return "", fmt.Errorf("couldn't complete login: %w", err) + } + if resp == nil || resp.JSON200 == nil { + return "", fmt.Errorf("couldn't complete login: nil response") } + + return resp.JSON200.Token, nil } diff --git a/cloud/client.go b/cloud/client.go new file mode 100644 index 0000000..fb9fa9b --- /dev/null +++ b/cloud/client.go @@ -0,0 +1,71 @@ +package cloud + +import ( + "context" + "fmt" + "github.com/libdyson-wg/libdyson-go/internal/generated/oapi" + "net/http" +) + +type ServerRegion int + +const ( + RegionGlobal ServerRegion = iota + RegionChina +) + +var ( + region ServerRegion = RegionGlobal + client oapi.ClientWithResponsesInterface + + provisioned bool + + token string + + servers = []string{ + "https://appapi.cp.dyson.com", + "https://appapi.cp.dyson.cn", + } +) + +func init() { + setServer() +} + +func addAuthToken(_ context.Context, r *http.Request) error { + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + return nil +} + +func addUserAgent(_ context.Context, r *http.Request) error { + r.Header.Set("User-Agent", "android client") + return nil +} + +func setServer() { + var err error + client, err = oapi.NewClientWithResponses( + servers[region], + oapi.WithRequestEditorFn(addAuthToken), + oapi.WithRequestEditorFn(addUserAgent), + ) + if err != nil { + panic(fmt.Errorf("unable to initialize client: %w", err)) + } + + _, err = client.ProvisionWithResponse(context.Background()) + if err != nil { + panic(fmt.Errorf("unable to provision api client: %w", err)) + } +} + +func SetToken(t string) { + token = t +} + +func SetServerRegion(r ServerRegion) { + region = r + setServer() +} diff --git a/cmd/dson/cmd/devices.go b/cmd/dson/cmd/devices.go index d442fa1..2c76852 100644 --- a/cmd/dson/cmd/devices.go +++ b/cmd/dson/cmd/devices.go @@ -1,7 +1,7 @@ package cmd import ( - "fmt" + "github.com/libdyson-wg/libdyson-go/config" "github.com/spf13/cobra" ) @@ -9,8 +9,18 @@ import ( var devicesCmd = &cobra.Command{ Use: "devices", Short: "Lists the devices on your account", + PreRunE: func(cmd *cobra.Command, args []string) error { + tok, err := config.GetToken() + if err != nil { + return err + } + if tok == "" { + err = funcs.Login() + } + return err + }, Run: func(cmd *cobra.Command, args []string) { - fmt.Println("devices called") + //ds, err := funcs.GetDevices }, } diff --git a/cmd/dson/cmd/funcs.go b/cmd/dson/cmd/funcs.go new file mode 100644 index 0000000..6d142e3 --- /dev/null +++ b/cmd/dson/cmd/funcs.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "github.com/libdyson-wg/libdyson-go/cloud" + "github.com/libdyson-wg/libdyson-go/config" + "github.com/libdyson-wg/libdyson-go/internal/account" + "github.com/libdyson-wg/libdyson-go/internal/shell" +) + +type functions struct { + Login func() error + GetDevices func() error +} + +var funcs functions + +func init() { + funcs = functions{ + Login: account.Login( + shell.PromptForInput, + shell.PromptForPassword, + cloud.BeginLogin, + cloud.CompleteLogin, + config.SetToken, + cloud.SetToken, + cloud.SetServerRegion, + ), + } +} diff --git a/cmd/dson/cmd/login.go b/cmd/dson/cmd/login.go index 3882a20..853e7ad 100644 --- a/cmd/dson/cmd/login.go +++ b/cmd/dson/cmd/login.go @@ -1,14 +1,30 @@ package cmd import ( + "fmt" + "github.com/libdyson-wg/libdyson-go/config" + "github.com/spf13/cobra" ) var loginCmd = &cobra.Command{ Use: "login", + RunE: func(cmd *cobra.Command, args []string) error { + return funcs.Login() + }, + PostRun: func(cmd *cobra.Command, args []string) { + fmt.Println( + fmt.Sprintf( + "You are logged in. Please note that your API Token has been saved to %s.\n\n"+ + "This API Token is sensitive and should not be shared with anyone you don't trust. "+ + "It could possibly be used to control your Dyson devices or learn sensitive private "+ + "information about you through your Dyson account.", + config.GetFilePath(), + ), + ) + }, } func init() { - loginCmd.Run = - rootCmd.AddCommand(loginCmd) + rootCmd.AddCommand(loginCmd) } diff --git a/cmd/dson/main.go b/cmd/dson/main.go index 142c080..16a27da 100644 --- a/cmd/dson/main.go +++ b/cmd/dson/main.go @@ -1,7 +1,16 @@ package main -import "libdyson-go/cmd/dson/cmd" +import ( + "github.com/libdyson-wg/libdyson-go/cloud" + "github.com/libdyson-wg/libdyson-go/cmd/dson/cmd" + "github.com/libdyson-wg/libdyson-go/config" +) func main() { + tok, err := config.GetToken() + if err != nil { + panic(err) + } + cloud.SetToken(tok) cmd.Execute() } diff --git a/config/account.go b/config/account.go index d912156..26a6ba4 100644 --- a/config/account.go +++ b/config/account.go @@ -1 +1,27 @@ package config + +import "fmt" + +func SetToken(t string) error { + conf, err := readConfig() + if err != nil { + return fmt.Errorf("could not read config: %v", err) + } + + conf.Token = t + err = writeConfig(conf) + if err != nil { + err = fmt.Errorf("could not save config: %v", err) + } + + return err +} + +func GetToken() (string, error) { + conf, err := readConfig() + if err != nil { + return "", fmt.Errorf("could not read token from config: %v", err) + } + + return conf.Token, nil +} diff --git a/config/file.go b/config/file.go index fc75325..19cb78d 100644 --- a/config/file.go +++ b/config/file.go @@ -1,6 +1,84 @@ package config +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +const configFilePath = "libdyson/config.yml" + +// init sets up the file path for config, panics if there are any problems trying to do so +func init() { + confDir, err := os.UserConfigDir() + if err != nil { + panic(fmt.Errorf("could not get user config dir: %w", err)) + } + fullFilePath = filepath.Clean(fmt.Sprintf("%s/%s", confDir, configFilePath)) + + // Make sure the directory is created. Return any error other than an "already exists" error + err = os.MkdirAll(filepath.Dir(fullFilePath), os.ModePerm) + if err != nil && !errors.Is(err, os.ErrExist) { + panic(fmt.Errorf("could not create config dir: %w", err)) + } + + // Stat the file, if it doesn't exist try to create it + _, err = os.Stat(fullFilePath) + if errors.Is(err, os.ErrNotExist) { + _, err = os.Create(fullFilePath) + } + + if err != nil { + panic(fmt.Errorf("problem with config file: %w", err)) + } +} + +var fullFilePath string + type Config struct { - AccountID string - Token string + Token string + + Devices []Device +} + +type Device struct { + Serial string +} + +// writeConfig is a variable so it can be replaced with a mock in unit tests +var writeConfig = func(config Config) error { + file, err := os.Create(fullFilePath) + if err != nil { + return fmt.Errorf("unable to open config file for writing: %w", err) + } + + err = json.NewEncoder(file).Encode(config) + if err != nil { + err = fmt.Errorf("unable to parse config file: %w", err) + } + + return err +} + +// readConfig is a variable so it can be replaced with a mock in unit tests +var readConfig = func() (Config, error) { + conf := Config{} + + f, err := os.Open(fullFilePath) + if err != nil { + return conf, fmt.Errorf("unable to open config file for reading: %w", err) + } + + err = json.NewDecoder(f).Decode(&conf) + if err != nil { + err = fmt.Errorf("unable to parse config file: %w", err) + } + + return conf, nil +} + +func GetFilePath() string { + return fullFilePath } diff --git a/go.mod b/go.mod index 25e54b8..c7970e8 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/google/uuid v1.6.0 github.com/oapi-codegen/runtime v1.1.1 github.com/spf13/cobra v1.8.0 + golang.org/x/term v0.21.0 ) require ( @@ -20,5 +21,6 @@ require ( github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.21.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a25ea8c..8d255c9 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,10 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/account/account.go b/internal/account/account.go new file mode 100644 index 0000000..ee8ab6a --- /dev/null +++ b/internal/account/account.go @@ -0,0 +1,62 @@ +package account + +import ( + "fmt" + "github.com/google/uuid" + "github.com/libdyson-wg/libdyson-go/cloud" +) + +func Login( + readLine func(prompt string) (string, error), + readSecret func(prompt string) (string, error), + beginLogin func(email string) (challengeID uuid.UUID, err error), + completeLogin func(email, otpCode, password string, challengeID uuid.UUID) (token string, err error), + setConfigToken func(token string) error, + setServerToken func(token string), + setServerRegion func(region cloud.ServerRegion), +) func() error { + return func() error { + cns, err := readLine("Use China region for account? (Default is no) [y/n]: ") + if err != nil { + return fmt.Errorf("error reading input: %w", err) + } + + if cns == "y" || cns == "Y" { + setServerRegion(cloud.RegionChina) + } + + email, err := readLine("Email Address: ") + if err != nil { + return fmt.Errorf("error reading email: %w", err) + } + + challenge, err := beginLogin(email) + if err != nil { + return fmt.Errorf("error starting login: %w", err) + } + + otp, err := readLine("Code from confirmation email: ") + if err != nil { + return fmt.Errorf("error reading OTP Code: %w", err) + } + + pw, err := readSecret("Password: ") + if err != nil { + return fmt.Errorf("error reading password: %w", err) + } + + tok, err := completeLogin(email, otp, pw, challenge) + if err != nil { + return fmt.Errorf("error completing login: %w", err) + } + + err = setConfigToken(tok) + if err != nil { + err = fmt.Errorf("error saving config: %w", err) + } + + setServerToken(tok) + + return err + } +} diff --git a/internal/shell/shell.go b/internal/shell/shell.go new file mode 100644 index 0000000..fbd8491 --- /dev/null +++ b/internal/shell/shell.go @@ -0,0 +1,32 @@ +package shell + +import ( + "bufio" + "fmt" + "golang.org/x/term" + "os" + "strings" + "syscall" +) + +func PromptForPassword(prompt string) (string, error) { + fmt.Print(prompt) + pass, err := term.ReadPassword(syscall.Stdin) + fmt.Println("") + if err != nil { + err = fmt.Errorf("error reading password: %w", err) + } + return string(pass), err +} + +func PromptForInput(prompt string) (string, error) { + fmt.Print(prompt) + reader := bufio.NewReader(os.Stdin) + in, err := reader.ReadString('\n') + if err != nil { + err = fmt.Errorf("error reading input: %w", err) + } + + in = strings.TrimSpace(in) + return in, err +}