checkpoint: add login with Oauth2

This commit is contained in:
Dan Anglin 2024-02-20 12:33:23 +00:00
parent 804268f298
commit 50d2f5f8d2
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
4 changed files with 193 additions and 30 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/environment/

11
go.mod
View file

@ -1,3 +1,12 @@
module enbas module codeflow.dananglin.me.uk/apollo/enbas
go 1.22.0 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
)

24
go.sum Normal file
View file

@ -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=

187
main.go
View file

@ -4,26 +4,31 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os"
"os/exec"
"runtime"
"strings"
"time" "time"
)
func main() { "golang.org/x/oauth2"
register() )
}
const ( const (
applicationName = "Enbas" applicationName = "enbas"
redirectUris = "urn:ietf:wg:oauth:2.0:oob" applicationWebsite = "https://codeflow.dananglin.me.uk/apollo/enbas"
redirectUri = "urn:ietf:wg:oauth:2.0:oob"
userAgent = "Enbas/0.0.0"
) )
type AccountConfig struct { type AuthenticationConfig struct {
CurrentAccount string `json:"currentAccount"` CurrentAccount string `json:"currentAccount"`
Accounts map[string]Account `json:"accounts"` Authentications map[string]Authentication `json:"authentications"`
} }
type Account struct { type Authentication struct {
Instance string `json:"instance"` Instance string `json:"instance"`
ClientID string `json:"clientId"` ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"` ClientSecret string `json:"clientSecret"`
@ -47,25 +52,86 @@ type RegisterResponse struct {
Website string `json:"website"` Website string `json:"website"`
} }
func register() Account { var errEmptyAccessToken = errors.New("received an empty access token")
// ask user for instance, if not start with http(s), prepend with https.
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: ") fmt.Print("Please enter the instance URL: ")
instance := ""
if _, err := fmt.Scanln(&instance); err != nil { 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{ request := RegisterRequest{
ClientName: applicationName, ClientName: applicationName,
RedirectUris: redirectUris, RedirectUris: redirectUri,
Scopes: "read", Scopes: "read write",
Website: "", Website: applicationWebsite,
} }
data, err := json.Marshal(request) data, err := json.Marshal(request)
if err != nil { if err != nil {
panic(err) return Authentication{}, fmt.Errorf("unable to marshal the request body; %w", err)
} }
httpRequestBody := bytes.NewBuffer(data) httpRequestBody := bytes.NewBuffer(data)
@ -73,22 +139,29 @@ func register() Account {
path := "/api/v1/apps" path := "/api/v1/apps"
url := instance + path url := instance + path
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, url, httpRequestBody) httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, url, httpRequestBody)
if err != nil { 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.Add("Content-Type", "application/json")
httpRequest.Header.Set("User-Agent", "Enbas/0.0.0") httpRequest.Header.Set("User-Agent", userAgent)
httpClient := http.Client{} httpClient := http.Client{}
httpResponse, err := httpClient.Do(httpRequest) httpResponse, err := httpClient.Do(httpRequest)
if err != nil { 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() defer httpResponse.Body.Close()
@ -96,19 +169,75 @@ func register() Account {
var registerResponse RegisterResponse var registerResponse RegisterResponse
if err := json.NewDecoder(httpResponse.Body).Decode(&registerResponse); err != nil { if err := json.NewDecoder(httpResponse.Body).Decode(&registerResponse); err != nil {
panic(err) return Authentication{}, fmt.Errorf(
"unable to decode the response from the GoToSocial server; %w",
err,
)
} }
fmt.Println(registerResponse) account := Authentication{
fmt.Println("Please sign into your instance with your browser and enter the access token\nAccess token: ")
account := Account{
Instance: instance, Instance: instance,
ClientID: registerResponse.ClientID, ClientID: registerResponse.ClientID,
ClientSecret: registerResponse.ClientSecret, ClientSecret: registerResponse.ClientSecret,
AccessToken: "", 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()
} }