From 50d2f5f8d24a30cd96f210bb40bbbe045d4bb0b4 Mon Sep 17 00:00:00 2001 From: Dan Anglin Date: Tue, 20 Feb 2024 12:33:23 +0000 Subject: [PATCH] checkpoint: add login with Oauth2 --- .gitignore | 1 + go.mod | 11 +++- go.sum | 24 +++++++ main.go | 187 ++++++++++++++++++++++++++++++++++++++++++++--------- 4 files changed, 193 insertions(+), 30 deletions(-) create mode 100644 .gitignore create mode 100644 go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c493042 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/environment/ diff --git a/go.mod b/go.mod index d01bc29..6b09132 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,12 @@ -module enbas +module codeflow.dananglin.me.uk/apollo/enbas go 1.22.0 + +require golang.org/x/oauth2 v0.17.0 + +require ( + github.com/golang/protobuf v1.5.3 // indirect + golang.org/x/net v0.21.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..35081b1 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= +golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/main.go b/main.go index e87ee60..e8d3bec 100644 --- a/main.go +++ b/main.go @@ -4,26 +4,31 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" + "os" + "os/exec" + "runtime" + "strings" "time" -) -func main() { - register() -} + "golang.org/x/oauth2" +) const ( - applicationName = "Enbas" - redirectUris = "urn:ietf:wg:oauth:2.0:oob" + applicationName = "enbas" + applicationWebsite = "https://codeflow.dananglin.me.uk/apollo/enbas" + redirectUri = "urn:ietf:wg:oauth:2.0:oob" + userAgent = "Enbas/0.0.0" ) -type AccountConfig struct { - CurrentAccount string `json:"currentAccount"` - Accounts map[string]Account `json:"accounts"` +type AuthenticationConfig struct { + CurrentAccount string `json:"currentAccount"` + Authentications map[string]Authentication `json:"authentications"` } -type Account struct { +type Authentication struct { Instance string `json:"instance"` ClientID string `json:"clientId"` ClientSecret string `json:"clientSecret"` @@ -47,25 +52,86 @@ type RegisterResponse struct { Website string `json:"website"` } -func register() Account { - // ask user for instance, if not start with http(s), prepend with https. +var errEmptyAccessToken = errors.New("received an empty access token") + +var consentMessageFormat = ` +You'll need to sign into your GoToSocial's consent page in order to generate the out-of-band token to continue with +the application's login process. Your browser may have opened the link to the consent page already. If not, please +copy and paste the link below to your browser: + +%s + +Once you have the code please copy and paste it below. + +` + +func main() { + if err := loginWithOauth2(); err != nil { + fmt.Printf("ERROR: Unable to register the application; %v\n", err) + os.Exit(1) + } +} + +func loginWithOauth2() error { + var instance string + fmt.Print("Please enter the instance URL: ") - instance := "" if _, err := fmt.Scanln(&instance); err != nil { - panic(err) + return fmt.Errorf("unable to read user input for the instance value; %w", err) + } + + authentication, err := register(instance) + if err != nil { + return fmt.Errorf("unable to register the application; %w", err) + } + + consentPageURL := authCodeURL(authentication) + + openLink(consentPageURL) + + fmt.Printf(consentMessageFormat, consentPageURL) + + var code string + fmt.Print("Out-of-band token: ") + + if _, err := fmt.Scanln(&code); err != nil { + return fmt.Errorf("failed to read access code; %w", err) + } + + authentication, err = addAccessToken(authentication, code) + if err != nil { + return fmt.Errorf("unable to get the access token; %w", err) + } + + fmt.Printf("%+v", authentication) + + // validate authentication and get username for file save + + // save the authentication to a file + + return nil +} + +func register(instance string) (Authentication, error) { + if !strings.HasPrefix(instance, "https") || !strings.HasPrefix(instance, "http") { + instance = "https://" + instance + } + + for strings.HasSuffix(instance, "/") { + instance = instance[:len(instance)-1] } request := RegisterRequest{ ClientName: applicationName, - RedirectUris: redirectUris, - Scopes: "read", - Website: "", + RedirectUris: redirectUri, + Scopes: "read write", + Website: applicationWebsite, } data, err := json.Marshal(request) if err != nil { - panic(err) + return Authentication{}, fmt.Errorf("unable to marshal the request body; %w", err) } httpRequestBody := bytes.NewBuffer(data) @@ -73,22 +139,29 @@ func register() Account { path := "/api/v1/apps" url := instance + path - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, url, httpRequestBody) if err != nil { - panic(err) + return Authentication{}, fmt.Errorf("unable to create the HTTP request; %w", err) } httpRequest.Header.Add("Content-Type", "application/json") - httpRequest.Header.Set("User-Agent", "Enbas/0.0.0") + httpRequest.Header.Set("User-Agent", userAgent) httpClient := http.Client{} httpResponse, err := httpClient.Do(httpRequest) if err != nil { - panic(err) + return Authentication{}, fmt.Errorf("received an error after sending the request; %w", err) + } + + if httpResponse.StatusCode != http.StatusOK { + return Authentication{}, fmt.Errorf( + "did not receive an OK response from the GoToSocial server; got %d", + httpResponse.StatusCode, + ) } defer httpResponse.Body.Close() @@ -96,19 +169,75 @@ func register() Account { var registerResponse RegisterResponse if err := json.NewDecoder(httpResponse.Body).Decode(®isterResponse); err != nil { - panic(err) + return Authentication{}, fmt.Errorf( + "unable to decode the response from the GoToSocial server; %w", + err, + ) } - fmt.Println(registerResponse) - - fmt.Println("Please sign into your instance with your browser and enter the access token\nAccess token: ") - - account := Account{ + account := Authentication{ Instance: instance, ClientID: registerResponse.ClientID, ClientSecret: registerResponse.ClientSecret, AccessToken: "", } - return account + return account, nil +} + +func authCodeURL(account Authentication) string { + config := oauth2.Config{ + ClientID: account.ClientID, + ClientSecret: account.ClientSecret, + Scopes: []string{"read", "write"}, + RedirectURL: redirectUri, + Endpoint: oauth2.Endpoint{ + AuthURL: account.Instance + "/oauth/authorize", + TokenURL: account.Instance + "/oauth/token", + }, + } + + url := config.AuthCodeURL("state", oauth2.AccessTypeOffline) + fmt.Sprintf("&client_name=%s", applicationName) + + return url +} + +func addAccessToken(authentication Authentication, code string) (Authentication, error) { + config := oauth2.Config{ + ClientID: authentication.ClientID, + ClientSecret: authentication.ClientSecret, + Scopes: []string{"read", "write"}, + RedirectURL: redirectUri, + Endpoint: oauth2.Endpoint{ + AuthURL: authentication.Instance + "/oauth/authorize", + TokenURL: authentication.Instance + "/oauth/token", + }, + } + + token, err := config.Exchange(context.Background(), code) + if err != nil { + return Authentication{}, fmt.Errorf("unable to exchange the code for an access token; %w", err) + } + + if token == nil || token.AccessToken == "" { + return Authentication{}, errEmptyAccessToken + } + + authentication.AccessToken = token.AccessToken + + return authentication, nil +} + +func openLink(url string) { + var open string + + if runtime.GOOS == "linux" { + open = "xdg-open" + } else { + return + } + + command := exec.Command(open, url) + + command.Start() }