From cd2f58a24cfd3c141892ef4039f1d7bf1023a82c Mon Sep 17 00:00:00 2001 From: "Gustavo \"Guz\" L. de Mello" Date: Wed, 24 Jul 2024 18:43:23 -0300 Subject: [PATCH] feat(oauth,twitter): default oauth2 implementation --- internals/app/oauth.go | 24 ++++++ internals/auth/auth.go | 90 ---------------------- internals/oauth/oauth.templ | 148 ++++++++++++++++++++++++++++++++++++ internals/oauth/twitter.go | 23 ++++++ main.go | 2 +- routes/index.templ | 5 +- routes/routes.go | 2 + 7 files changed, 201 insertions(+), 93 deletions(-) create mode 100644 internals/app/oauth.go delete mode 100644 internals/auth/auth.go create mode 100644 internals/oauth/oauth.templ create mode 100644 internals/oauth/twitter.go diff --git a/internals/app/oauth.go b/internals/app/oauth.go new file mode 100644 index 0000000..04e81bf --- /dev/null +++ b/internals/app/oauth.go @@ -0,0 +1,24 @@ +package app + +import ( + "extrovert/internals/oauth" + "net/url" + "os" +) + +const DOMAIN = "http://localhost:7331/" + +const TWITTER_REDIRECT = "/api/twitter/oauth2" + +var TWITTER_APP = func() oauth.TwitterOAuth { + ru, _ := url.Parse(DOMAIN) + ru = ru.JoinPath(TWITTER_REDIRECT) + + c := oauth.NewTwitterOAuth( + os.Getenv("TWITTER_CLIENT_ID"), + os.Getenv("TWITTER_CLIENT_SECRET"), + ru, + ) + + return c +}() diff --git a/internals/auth/auth.go b/internals/auth/auth.go deleted file mode 100644 index c7ad77e..0000000 --- a/internals/auth/auth.go +++ /dev/null @@ -1,90 +0,0 @@ -package auth - -import ( - "bytes" - "context" - "encoding/json" - "extrovert/templates/pages" - "io" - "log" - "net/http" - "net/url" - "strings" - - "github.com/a-h/templ" -) - -type Client interface { - OAuthHandler(w http.ResponseWriter, r *http.Request) -} - -type DefaultClient struct { - name string - tokenEndpoint url.URL - id string - redirectUri string -} - -func (c DefaultClient) OAuthHandler(w http.ResponseWriter, r *http.Request) { - code := r.URL.Query().Get("code") - if code == "" { - log.Fatalf("TODO-ERR missing code parameter") - } - - req := c.tokenEndpoint - - q := req.Query() - q.Add("grant_type", "authorization_code") - q.Add("code_verifier", "challenge") - q.Add("challenge_method", "plain") - q.Add("code", code) - q.Add("client_id", c.id) - q.Add("redirect_uri", c.redirectUri) - - res, err := http.Post(req.String(), "application/x-www-form-urlencoded", bytes.NewReader([]byte(""))) - if err != nil { - log.Fatalf("TODO-ERR trying to get token on %s, error:\n%s", req.Host, err) - } - - body, err := io.ReadAll(res.Body) - if err != nil || res.StatusCode != 200 { - log.Fatalf("TODO-ERR trying to read body on %s, body:\n%s\n\nerror:\n%s", req.Host, body, err) - } - - var token DefaultClientToken - err = json.Unmarshal(body, &token) - if err != nil { - log.Fatalf("TODO-ERR trying to parse json body to token:\n%s", err) - } - - cookie := http.Cookie{ - Name: strings.ToUpper("__Host-TOKEN-" + c.name), - // Value: token.String(), - SameSite: http.SameSiteStrictMode, - Path: "/", - Secure: true, - } - - http.SetCookie(w, &cookie) - - err = pages.RedirectPopUp( - "Logged into "+c.name+"!", - "Your "+c.name+" account was succeffully logged into project-extrovert. ", - templ.SafeURL("/index.html"), - ).Render(context.Background(), w) - - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - log.Fatalf("TODO-ERR trying to render static page:\n%s", err) - return - } - - w.WriteHeader(http.StatusOK) -} - -type DefaultClientToken struct { - Type string `json:"token_type"` - Token string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - Scope string `json:"scope"` -} diff --git a/internals/oauth/oauth.templ b/internals/oauth/oauth.templ new file mode 100644 index 0000000..041506c --- /dev/null +++ b/internals/oauth/oauth.templ @@ -0,0 +1,148 @@ +package oauth + +import ( + "encoding/json" + e "errors" + "net/http" + "net/url" + "strings" + "fmt" + + "extrovert/internals/router/errors" + "extrovert/templates/pages" +) + +type OAuthClient interface { + ServeHTTP(w http.ResponseWriter, r *http.Request) + LoginButton() templ.Component +} + +type DefaultOAuthToken struct { + Type string `json:"token_type"` + Token string `json:"access_token"` + Expires int `json:"expires_in"` + Scope string `json:"scope"` + RefreshToken *string `json:"refresh_token"` +} + +type DefaultOAuthClient struct { + Name string + Id string + Secret string + AuthEndpoint *url.URL + TokenEndpoint *url.URL + RedirectUri *url.URL +} + +func NewDefaultOAuthClient(u *url.URL, id string, secret string, redirect *url.URL) DefaultOAuthClient { + auth, _ := url.Parse(u.String()) + + q := auth.Query() + q.Add("client_id", id) + q.Add("redirect_uri", redirect.String()) + q.Add("response_type", "code") + q.Add("scope", "read write") + q.Add("state", "state") + q.Add("code_challenge", "challenge") + q.Add("code_challenge_method", "plain") + auth.RawQuery = q.Encode() + + auth = auth.JoinPath("/oauth2/authorize") + + token, _ := url.Parse(u.String()) + token = token.JoinPath("/oauth2/token") + + return DefaultOAuthClient{ + Name: u.Hostname(), + Id: id, + Secret: secret, + AuthEndpoint: auth, + TokenEndpoint: token, + RedirectUri: redirect, + } +} + +templ (c DefaultOAuthClient) LoginButton() { + + Login on { c.Name } + +} + +func (c DefaultOAuthClient) ServeHTTP(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + if code == "" { + errors.NewErrMissingParams("code").ServeHTTP(w, r) + return + } + + req := c.TokenEndpoint + + q := req.Query() + q.Add("client_id", c.Id) + q.Add("client_secret", c.Secret) + q.Add("code", code) + q.Add("redirect_uri", c.RedirectUri.String()) + q.Add("grant_type", "authorization_code") + q.Add("code_verifier", "challenge") + q.Add("challenge_method", "plain") + req.RawQuery = q.Encode() + + res, err := http.Post(req.String(), "application/x-www-form-urlencoded", bytes.NewReader([]byte(""))) + if err != nil { + errors.NewErrInternal(e.New("Error while trying to request token"), err).ServeHTTP(w, r) + return + } + + body, err := io.ReadAll(res.Body) + if err != nil { + errors.NewErrInternal(e.New("Error while trying to read token response body"), err).ServeHTTP(w, r) + return + } + if res.StatusCode != 200 { + errors.NewErrInternal(fmt.Errorf( + "Unable to retrieve token, non-200 response from platform. \nStatus: %v\nBody: %s", + res.StatusCode, string(body)), + ) + return + } + + var token DefaultOAuthToken + err = json.Unmarshal(body, &token) + if err != nil { + errors.NewErrInternal(e.New("Error while trying to validate token response body"), err).ServeHTTP(w, r) + return + } + + cv, err := json.Marshal(token) + if err != nil { + errors.NewErrInternal(e.New("Error while trying to validate token response body"), err).ServeHTTP(w, r) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "__Host-OAUTH-" + strings.ToUpper(c.Name), + Value: string(cv), + SameSite: http.SameSiteStrictMode, + Path: "/", + Secure: true, + HttpOnly: true, + }) + + err = pages.RedirectPopUp( + "Logged into "+c.Name+"!", + "Your "+c.Name+" account was successfully logged into project-extrovert. "+ + "Click the button below to return to the Homepage.", + templ.SafeURL("/"), + ).Render(r.Context(), w) + + if err != nil { + errors.NewErrInternal(e.New("Unable to render redirect page"), err).ServeHTTP(w, r) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/internals/oauth/twitter.go b/internals/oauth/twitter.go new file mode 100644 index 0000000..b19ccc0 --- /dev/null +++ b/internals/oauth/twitter.go @@ -0,0 +1,23 @@ +package oauth + +import "net/url" + +type TwitterOAuth struct { + DefaultOAuthClient +} + +func NewTwitterOAuth(id string, secret string, redirect *url.URL) TwitterOAuth { + u, _ := url.Parse("https://api.twitter.com/2/") + c := NewDefaultOAuthClient(u, id, secret, redirect) + + c.Name = "Twitter" + + u, _ = url.Parse("https://twitter.com/i/oauth2/authorize") + q := c.AuthEndpoint.Query() + q.Del("scope") + q.Add("scope", "tweet.read tweet.write users.read") + u.RawQuery = q.Encode() + c.AuthEndpoint = u + + return TwitterOAuth{c} +} diff --git a/main.go b/main.go index c5ca3b2..c437826 100644 --- a/main.go +++ b/main.go @@ -28,7 +28,7 @@ func main() { if *dev { r.AddMiddleware(middlewares.NewDevelopmentMiddleware(logger)) } - r.AddMiddleware(middlewares.NewCookiesCryptoMiddleware(os.Getenv("CRYPTO_COOKIES_KEY"), logger)) + r.AddMiddleware(middlewares.NewCookiesCryptoMiddleware(os.Getenv("CRYPTO_COOKIE_KEY"), logger)) err := http.ListenAndServe(fmt.Sprintf(":%v", *port), r) if err != nil { diff --git a/routes/index.templ b/routes/index.templ index 3f55cce..6158cbf 100644 --- a/routes/index.templ +++ b/routes/index.templ @@ -3,6 +3,7 @@ package routes import ( "extrovert/templates/layouts" "extrovert/components" + "extrovert/internals/app" "net/http" "extrovert/internals/router/errors" e "errors" @@ -12,6 +13,7 @@ type Homepage struct{} func (h Homepage) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) + err := h.page().Render(context.Background(), w) if err != nil { errors.NewErrInternal(e.New("Unable to render dashboard"), err).ServeHTTP(w, r) @@ -29,8 +31,7 @@ templ (h Homepage) page() { style="height:100%;display:flex;gap:2rem;" >
- @components.LoginTwitter() - @components.LoginMastodon() + @app.TWITTER_APP.LoginButton()