From d1bf8191ad31445c90138c761eb73fa6ac2232b8 Mon Sep 17 00:00:00 2001 From: igorgoldobin Date: Mon, 12 Feb 2024 21:33:24 +1000 Subject: [PATCH] feat: discord integration --- .github/workflows/release_staging.yml | 33 +++++ cmd/server.go | 6 +- internal/server/config.go | 40 +++--- internal/server/dto.go | 42 +++++- internal/server/middleware.go | 176 +++++++++++++++++--------- internal/server/server.go | 109 +++++++++++++++- web/src/Faucet.svelte | 66 +++++++++- 7 files changed, 381 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/release_staging.yml diff --git a/.github/workflows/release_staging.yml b/.github/workflows/release_staging.yml new file mode 100644 index 00000000..7f00a0c1 --- /dev/null +++ b/.github/workflows/release_staging.yml @@ -0,0 +1,33 @@ +name: Deploy to Azure Staging + +on: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Download artifact + uses: dawidd6/action-download-artifact@v3 + with: + workflow: build.yml + name: build-artifact + path: ./deploy + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: "16" + + - name: Deploy to Azure App Service + uses: azure/webapps-deploy@v2 + with: + app-name: auroria-test-faucet # Replace with your Azure App Service name + slot-name: staging + publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE_STAGING }} # Azure publish profile secret + package: ./deploy # Path to the downloaded artifact diff --git a/cmd/server.go b/cmd/server.go index e0232c12..25e503eb 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -37,6 +37,10 @@ var ( hcaptchaSiteKeyFlag = flag.String("hcaptcha_sitekey", os.Getenv("HCAPTCHA_SITEKEY"), "hCaptcha sitekey") hcaptchaSecretFlag = flag.String("hcaptcha_secret", os.Getenv("HCAPTCHA_SECRET"), "hCaptcha secret") + + discordClientId = flag.String("discord_client_id", os.Getenv("DISCORD_CLIENTID"), "Discord client id for oauth2") + discordClientSecret = flag.String("discord_client_secret", os.Getenv("DISCORD_CLIENTSECRET"), "Discord client secret for oauth2") + discordRedirectUrl = flag.String("discord_redirect_url", os.Getenv("DISCORD_REDIRECTURL"), "Discord redirect url for oauth2") ) func init() { @@ -80,7 +84,7 @@ func Execute() { } payoutFlag := flag.Int("faucet.amount", faucetAmount, "Number of Ethers to transfer per user request") - config := server.NewConfig(*netnameFlag, *symbolFlag, *httpPortFlag, *intervalFlag, *payoutFlag, *proxyCntFlag, *hcaptchaSiteKeyFlag, *hcaptchaSecretFlag) + config := server.NewConfig(*netnameFlag, *symbolFlag, *httpPortFlag, *intervalFlag, *payoutFlag, *proxyCntFlag, *hcaptchaSiteKeyFlag, *hcaptchaSecretFlag, *discordClientId, *discordClientSecret, *discordRedirectUrl) go server.NewServer(txBuilder, config).Run() c := make(chan os.Signal, 1) diff --git a/internal/server/config.go b/internal/server/config.go index 514bcfd3..4d72031b 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -1,25 +1,31 @@ package server type Config struct { - network string - symbol string - httpPort int - interval int - payout int - proxyCount int - hcaptchaSiteKey string - hcaptchaSecret string + network string + symbol string + httpPort int + interval int + payout int + proxyCount int + hcaptchaSiteKey string + hcaptchaSecret string + discordClientId string + discordClientSecret string + discordRedirectUrl string } -func NewConfig(network, symbol string, httpPort, interval, payout, proxyCount int, hcaptchaSiteKey, hcaptchaSecret string) *Config { +func NewConfig(network, symbol string, httpPort, interval, payout, proxyCount int, hcaptchaSiteKey, hcaptchaSecret, discordClientId, discordClientSecret, discordRedirectUrl string) *Config { return &Config{ - network: network, - symbol: symbol, - httpPort: httpPort, - interval: interval, - payout: payout, - proxyCount: proxyCount, - hcaptchaSiteKey: hcaptchaSiteKey, - hcaptchaSecret: hcaptchaSecret, + network: network, + symbol: symbol, + httpPort: httpPort, + interval: interval, + payout: payout, + proxyCount: proxyCount, + hcaptchaSiteKey: hcaptchaSiteKey, + hcaptchaSecret: hcaptchaSecret, + discordClientId: discordClientId, + discordClientSecret: discordClientSecret, + discordRedirectUrl: discordRedirectUrl, } } diff --git a/internal/server/dto.go b/internal/server/dto.go index 3bdd6f28..54114ec0 100644 --- a/internal/server/dto.go +++ b/internal/server/dto.go @@ -16,19 +16,37 @@ type claimRequest struct { Address string `json:"address"` } +type loginRequest struct { + Code string `json:"code"` +} + type claimResponse struct { Message string `json:"msg"` } +type loginResponse struct { + Message string `json:"msg"` +} + +type discordTokenResponse struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` +} + type infoResponse struct { Account string `json:"account"` Network string `json:"network"` Payout string `json:"payout"` Symbol string `json:"symbol"` HcaptchaSiteKey string `json:"hcaptcha_sitekey,omitempty"` - RemoteAddr string `json:"remote_addr,omitempty"` - Forward string `json:"forward,omitempty"` - RealIP string `json:"real_ip,omitempty"` + RemoteAddr string `json:"remote_addr,omitempty"` + Forward string `json:"forward,omitempty"` + RealIP string `json:"real_ip,omitempty"` +} + +type authResponse struct { + Token string `json:"token"` } type malformedRequest struct { @@ -95,6 +113,24 @@ func readAddress(r *http.Request) (string, error) { return claimReq.Address, nil } +func readCode(r *http.Request) (string, error) { + var loginReq loginRequest + if err := decodeJSONBody(r, &loginReq); err != nil { + return "", err + } + + return loginReq.Code, nil +} + +func readToken(r *http.Request) (string, error) { + var discordRes discordTokenResponse + if err := decodeJSONBody(r, &discordRes); err != nil { + return "", err + } + + return discordRes.AccessToken, nil +} + func renderJSON(w http.ResponseWriter, v interface{}, code int) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) diff --git a/internal/server/middleware.go b/internal/server/middleware.go index cd6589ef..dbd7a26e 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -56,7 +56,7 @@ func (l *Limiter) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.Ha l.mutex.Lock() // if l.limitByKey(w, address) { - if l.limitByKey(w, address) || l.limitByKey(w, clintIP) { + if l.limitByKey(w, address) || l.limitByKey(w, clintIP) { l.mutex.Unlock() return } @@ -70,7 +70,7 @@ func (l *Limiter) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.Ha l.cache.Remove(clintIP) return } - + log.WithFields(log.Fields{ "address": address, "clientIP": clintIP, @@ -104,78 +104,77 @@ func getClientIPFromRequest(proxyCount int, r *http.Request) string { remoteIP, _, err := net.SplitHostPort(getIPAdress(r)) if err != nil { remoteIP = getIPAdress(r) - if (remoteIP == "") { + if remoteIP == "" { remoteIP = r.RemoteAddr } } return remoteIP } -//ipRange - a structure that holds the start and end of a range of ip addresses +// ipRange - a structure that holds the start and end of a range of ip addresses type ipRange struct { - start net.IP - end net.IP + start net.IP + end net.IP } // inRange - check to see if a given ip address is within a range given func inRange(r ipRange, ipAddress net.IP) bool { - // strcmp type byte comparison - if bytes.Compare(ipAddress, r.start) >= 0 && bytes.Compare(ipAddress, r.end) < 0 { - return true - } - return false + // strcmp type byte comparison + if bytes.Compare(ipAddress, r.start) >= 0 && bytes.Compare(ipAddress, r.end) < 0 { + return true + } + return false } var privateRanges = []ipRange{ - ipRange{ - start: net.ParseIP("10.0.0.0"), - end: net.ParseIP("10.255.255.255"), - }, - ipRange{ - start: net.ParseIP("100.64.0.0"), - end: net.ParseIP("100.127.255.255"), - }, - ipRange{ - start: net.ParseIP("172.16.0.0"), - end: net.ParseIP("172.31.255.255"), - }, - ipRange{ - start: net.ParseIP("192.0.0.0"), - end: net.ParseIP("192.0.0.255"), - }, - ipRange{ - start: net.ParseIP("192.168.0.0"), - end: net.ParseIP("192.168.255.255"), - }, - ipRange{ - start: net.ParseIP("198.18.0.0"), - end: net.ParseIP("198.19.255.255"), - }, + ipRange{ + start: net.ParseIP("10.0.0.0"), + end: net.ParseIP("10.255.255.255"), + }, + ipRange{ + start: net.ParseIP("100.64.0.0"), + end: net.ParseIP("100.127.255.255"), + }, + ipRange{ + start: net.ParseIP("172.16.0.0"), + end: net.ParseIP("172.31.255.255"), + }, + ipRange{ + start: net.ParseIP("192.0.0.0"), + end: net.ParseIP("192.0.0.255"), + }, + ipRange{ + start: net.ParseIP("192.168.0.0"), + end: net.ParseIP("192.168.255.255"), + }, + ipRange{ + start: net.ParseIP("198.18.0.0"), + end: net.ParseIP("198.19.255.255"), + }, } - // isPrivateSubnet - check to see if this ip is in a private subnet func isPrivateSubnet(ipAddress net.IP) bool { - // my use case is only concerned with ipv4 atm - if ipCheck := ipAddress.To4(); ipCheck != nil { - // iterate over all our ranges - for _, r := range privateRanges { - // check if this ip is in a private range - if inRange(r, ipAddress){ - return true - } - } - } - return false + // my use case is only concerned with ipv4 atm + if ipCheck := ipAddress.To4(); ipCheck != nil { + // iterate over all our ranges + for _, r := range privateRanges { + // check if this ip is in a private range + if inRange(r, ipAddress) { + return true + } + } + } + return false } func getIPAdress(r *http.Request) string { - for _, h := range []string{"X-Forwarded-For", "X-Real-Ip"} { - addresses := strings.Split(r.Header.Get(h), ",") - // march from right to left until we get a public address - // that will be the address right before our proxy. - for i := len(addresses) -1 ; i >= 0; i-- { - ip := strings.TrimSpace(addresses[i]) + for _, h := range []string{"X-Forwarded-For", "X-Real-Ip"} { + addresses := strings.Split(r.Header.Get(h), ",") + // march from right to left until we get a public address + // that will be the address right before our proxy. + for i := len(addresses) - 1; i >= 0; i-- { + ip := strings.TrimSpace(addresses[i]) realIP, _, err := net.SplitHostPort(ip) if err != nil { realIP = ip @@ -183,13 +182,13 @@ func getIPAdress(r *http.Request) string { parsedId := net.ParseIP(realIP) if !parsedId.IsGlobalUnicast() || isPrivateSubnet(parsedId) { - // bad address, go to next - continue - } - return realIP - } - } - return "" + // bad address, go to next + continue + } + return realIP + } + } + return "" } type Captcha struct { @@ -197,6 +196,10 @@ type Captcha struct { secret string } +type Auth struct { + code string +} + func NewCaptcha(hcaptchaSiteKey, hcaptchaSecret string) *Captcha { client := hcaptcha.New(hcaptchaSecret) client.SiteKey = hcaptchaSiteKey @@ -206,6 +209,12 @@ func NewCaptcha(hcaptchaSiteKey, hcaptchaSecret string) *Captcha { } } +func NewAuth(code string) *Auth { + return &Auth{ + code: code, + } +} + func (c *Captcha) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { if c.secret == "" { next.ServeHTTP(w, r) @@ -220,3 +229,50 @@ func (c *Captcha) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.Ha next.ServeHTTP(w, r) } + +func (c *Auth) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + + cookie, err := r.Cookie("token") + if err != nil { + // If the cookie is not set, return an unauthorized status + if err == http.ErrNoCookie { + renderJSON(w, loginResponse{Message: "Invalid login. Please authenticate with discord first"}, http.StatusUnauthorized) + return + } + // For any other error, return a bad request status + renderJSON(w, loginResponse{Message: "Invalid login. Please authenticate with discord first"}, http.StatusBadRequest) + return + } + + token := cookie.Value + + isValid := validateToken(token) + if !isValid { + renderJSON(w, loginResponse{Message: "Invalid login. Please authenticate with discord first"}, http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) +} + +func validateToken(token string) bool { + req, err := http.NewRequest("GET", "https://discord.com/api/users/@me", nil) + if err != nil { + return false + } + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + // Check if the response status code is 200 OK + if resp.StatusCode == http.StatusOK { + return true + } + + return false +} diff --git a/internal/server/server.go b/internal/server/server.go index a7cc2fc1..db72537f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,8 +2,10 @@ package server import ( "context" + "encoding/json" "fmt" "net/http" + "net/url" "os" "strconv" "strings" @@ -34,7 +36,10 @@ func (s *Server) setupRouter() *http.ServeMux { router.Handle("/", http.FileServer(web.Dist())) limiter := NewLimiter(s.cfg.proxyCount, time.Duration(s.cfg.interval)*time.Minute) hcaptcha := NewCaptcha(s.cfg.hcaptchaSiteKey, s.cfg.hcaptchaSecret) - router.Handle("/api/claim", negroni.New(limiter, hcaptcha, negroni.Wrap(s.handleClaim()))) + auth := NewAuth("") + router.Handle("/api/login", s.handleLogin()) + router.Handle("/api/check", negroni.New(auth, negroni.Wrap(s.handleLoginCheck()))) + router.Handle("/api/claim", negroni.New(limiter, hcaptcha, auth, negroni.Wrap(s.handleClaim()))) router.Handle("/api/info", s.handleInfo()) return router @@ -109,9 +114,105 @@ func (s *Server) handleInfo() http.HandlerFunc { Symbol: s.cfg.symbol, Payout: strconv.Itoa(s.cfg.payout), HcaptchaSiteKey: s.cfg.hcaptchaSiteKey, - RemoteAddr: r.RemoteAddr, - Forward: r.Header.Get("X-Forwarded-For"), - RealIP: r.Header.Get("X-Real-IP"), + RemoteAddr: r.RemoteAddr, + Forward: r.Header.Get("X-Forwarded-For"), + RealIP: r.Header.Get("X-Real-IP"), }, http.StatusOK) } } + +func (s *Server) handleLogin() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + + if r.Method != "POST" { + http.NotFound(w, r) + return + } + + // The error always be nil since it has already been handled in limiter + code, _ := readCode(r) + + token, err := exchangeCodeForToken(code, s.cfg.discordClientId, s.cfg.discordClientSecret, s.cfg.discordRedirectUrl) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + expirationTime := time.Now().Add(24 * time.Hour) // Expires in 24 hours + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: token.AccessToken, // Replace with the actual token value + Expires: expirationTime, + HttpOnly: true, // This makes the cookie inaccessible to JavaScript + }) + + // You can send back a simple response + //w.Write([]byte("User logged in")) + + renderJSON(w, authResponse{ + Token: token.AccessToken, + }, http.StatusOK) + } +} + +func (s *Server) handleLoginCheck() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("token") + if err != nil { + // Handle the case where the cookie is not present or invalid + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Additional checks can be performed here, such as validating the token + log.Info(cookie.Value) + + // If everything is okay + w.WriteHeader(http.StatusOK) + } +} + +func exchangeCodeForToken(code, discordClientId, discordClientSecret, discordRedirectUrl string) (*discordTokenResponse, error) { + log.Info(discordClientId, discordClientSecret, discordRedirectUrl, code) + // Prepare the request data + data := url.Values{} + data.Set("client_id", discordClientId) + data.Set("client_secret", discordClientSecret) + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("redirect_uri", discordRedirectUrl) + data.Set("scope", "identify") + + // Make the request + req, err := http.NewRequest("POST", "https://discord.com/api/oauth2/token", strings.NewReader(data.Encode())) + if err != nil { + log.Error(err) + return nil, err + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + log.Error(err) + return nil, err + } + defer resp.Body.Close() + + log.Info(resp.Body) + + // Decode the response + var tokenResp discordTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + log.Error(err) + return nil, err + } + + log.WithFields(log.Fields{ + "token": tokenResp.AccessToken, + "erorr": tokenResp.Error, + "error_description": tokenResp.ErrorDesc, + }).Info("Response") + + return &tokenResp, nil +} diff --git a/web/src/Faucet.svelte b/web/src/Faucet.svelte index f8a512a6..3489c138 100644 --- a/web/src/Faucet.svelte +++ b/web/src/Faucet.svelte @@ -14,10 +14,22 @@ hcaptcha_sitekey: '', }; + let loggedIn = false; let mounted = false; let hcaptchaLoaded = false; + let loginUrl = `https://discord.com/api/oauth2/authorize?client_id=1206392566468321340&redirect_uri=${window.location.href}&response_type=code&scope=identify%20email`; onMount(async () => { + + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + + await checkAuthentication(); + + if (code) { + exchangeCodeForToken(code); + } + await checkNetwork(); if (window.ethereum) { window.ethereum.on('chainChanged', (_chainId) => { @@ -29,6 +41,39 @@ mounted = true; }); + async function checkAuthentication() { + try { + const response = await fetch('/api/check', { + credentials: 'include' // Ensures cookies are included in the request + }); + + if (response.ok) { + loggedIn = true; + } else { + loggedIn = false; + } + } catch (error) { + console.error('Error checking authentication:', error); + } + } + + async function exchangeCodeForToken(code) { + // Make an API request to your backend with the code + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + + if (response.ok) { + loggedIn = true; + const newUrl = window.location.pathname; // This retains the current path without the query parameters + window.history.replaceState({}, '', newUrl); + } else { + // Handle errors + } + } + window.hcaptchaOnLoad = () => { hcaptchaLoaded = true; }; @@ -89,7 +134,7 @@ console.log('Ethereum wallet not detected'); networkLabel = 'No Wallet Detected'; } - }, 3000); + }, 2000); } async function addCustomNetwork() { @@ -168,6 +213,7 @@ const res = await fetch('/api/claim', { method: 'POST', + credentials: 'include', headers, body: JSON.stringify({ address, @@ -444,11 +490,19 @@
- + {#if loggedIn} + + {:else} + + Login with Discord -> + + {/if}