From a3e56fa7533f2421ba75692818ac666f7183bf01 Mon Sep 17 00:00:00 2001 From: gabe farrell Date: Mon, 27 Jun 2022 23:15:45 -0400 Subject: [PATCH] 0.0.4 rate limiting, captcha, more better auth, bug fixes --- accounts.go | 82 ++++++++++++++++++++++++-- chat.go | 9 ++- client.go | 26 ++++++-- clock.go | 3 +- db.go | 34 +++++++---- frontend/package-lock.json | 36 ++++++++++- frontend/package.json | 5 +- frontend/src/App.vue | 12 ++-- frontend/src/components/BetBox.vue | 46 +++++++++------ frontend/src/components/BetInput.vue | 6 +- frontend/src/components/ChatBox.vue | 48 ++++++++------- frontend/src/components/LoginForm.vue | 20 +++++-- frontend/src/components/PageFooter.vue | 2 +- frontend/src/main.js | 6 +- frontend/vue.config.js | 4 +- go.mod | 12 ++-- go.sum | 2 + limits.go | 80 +++++++++++++++++++++++++ main.go | 25 ++++++-- 19 files changed, 363 insertions(+), 95 deletions(-) create mode 100644 limits.go diff --git a/accounts.go b/accounts.go index 8ab1a75..a135e76 100644 --- a/accounts.go +++ b/accounts.go @@ -6,6 +6,8 @@ import ( "fmt" "log" "net/http" + "net/url" + "os" "regexp" "strings" "time" @@ -13,11 +15,15 @@ import ( "github.com/google/uuid" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" "go.mongodb.org/mongo-driver/mongo/readpref" "golang.org/x/crypto/bcrypt" ) +// Note: I would love to embed a lot of these structs to avoid +// duplicate fields, but it breaks json.(Un)marshal and I dont want to deal with that + type Credentials struct { Username string `json:"username" bson:"username"` Password string `json:"password" bson:"password"` @@ -29,6 +35,13 @@ type Login struct { RememberMe bool `json:"remember"` } +type CreateAccount struct { + Username string `json:"username" bson:"username"` + Password string `json:"password" bson:"password"` + RememberMe bool `json:"remember"` + Token string `json:"token"` +} + type Session struct { Session string `json:"session" bson:"session"` } @@ -61,13 +74,55 @@ func createAccount(w http.ResponseWriter, r *http.Request) { if err != nil { DB = openDB() } - userCollection := DB.Database("Users").Collection("Users") + var userCollection *mongo.Collection + if os.Getenv("ENVIRONMENT") == "production" { + userCollection = DB.Database("Users").Collection("Users") + } else { + userCollection = DB.Database("Development").Collection("Users") + } // var v contains POST credentials - var v Login + var v CreateAccount err = json.NewDecoder(r.Body).Decode(&v) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + log.Println("* Create Account Refused: Bad form data") + return + } + + var verify struct { + Secret string + Response string + } + verify.Secret = os.Getenv("CAPTCHA_SECRET") + verify.Response = v.Token + + //url := "https://www.google.com/recaptcha/api/siteverify?secret=" + url.QueryEscape(verify.Secret) + "?response=" + url.QueryEscape(verify.Response) + u := "https://www.google.com/recaptcha/api/siteverify" + d := url.Values{"secret": []string{verify.Secret}, "response": []string{verify.Response}} - if err != nil || len(v.Password) < 8 || len(v.Password) > 255 || !UsernameRegex.MatchString(v.Username) { + resp, err := http.PostForm(u, d) + + var captchaResponse struct { + Success bool `json:"success"` + Time primitive.Timestamp `json:"challenge_ts"` + Hostname string `json:"hostname"` + Errors []string `json:"error-codes"` + } + + json.NewDecoder(resp.Body).Decode(&captchaResponse) + + if !captchaResponse.Success { + w.WriteHeader(http.StatusBadRequest) + log.Print("* Create Account Refused: Unsuccessful reCaptcha challenge\nErrors: ") + for _, e := range captchaResponse.Errors { + fmt.Print(e + " ") + } + fmt.Print("\n") + return + } + + if len(v.Password) < 8 || len(v.Password) > 255 || !UsernameRegex.MatchString(v.Username) { w.WriteHeader(http.StatusBadRequest) fmt.Fprint(w, "{\"error\":\"there was a problem with your request. Please try again with different values\"}") return @@ -148,7 +203,12 @@ func login(w http.ResponseWriter, r *http.Request) { DB = openDB() } w.Header().Set("Content-Type", "application/json") - userCollection := DB.Database("Users").Collection("Users") + var userCollection *mongo.Collection + if os.Getenv("ENVIRONMENT") == "production" { + userCollection = DB.Database("Users").Collection("Users") + } else { + userCollection = DB.Database("Development").Collection("Users") + } // decode POST into v struct var v Login @@ -234,7 +294,12 @@ func loginBySession(w http.ResponseWriter, r *http.Request) { if err != nil { DB = openDB() } - userCollection := DB.Database("Users").Collection("Users") + var userCollection *mongo.Collection + if os.Getenv("ENVIRONMENT") == "production" { + userCollection = DB.Database("Users").Collection("Users") + } else { + userCollection = DB.Database("Development").Collection("Users") + } var id Session var account ReturnedAccount @@ -263,7 +328,12 @@ func logout(w http.ResponseWriter, r *http.Request) { if err != nil { DB = openDB() } - userCollection := DB.Database("Users").Collection("Users") + var userCollection *mongo.Collection + if os.Getenv("ENVIRONMENT") == "production" { + userCollection = DB.Database("Users").Collection("Users") + } else { + userCollection = DB.Database("Development").Collection("Users") + } var v Credentials diff --git a/chat.go b/chat.go index 5d10cac..f3ebe8c 100644 --- a/chat.go +++ b/chat.go @@ -4,10 +4,12 @@ import ( "context" "encoding/json" "net/http" + "os" "strings" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" "go.mongodb.org/mongo-driver/mongo/readpref" ) @@ -23,7 +25,12 @@ func chatColor(w http.ResponseWriter, r *http.Request) { if err != nil { DB = openDB() } - userCollection := DB.Database("Users").Collection("Users") + var userCollection *mongo.Collection + if os.Getenv("ENVIRONMENT") == "production" { + userCollection = DB.Database("Users").Collection("Users") + } else { + userCollection = DB.Database("Development").Collection("Users") + } // decode PUT into v struct var v ColorRequest diff --git a/client.go b/client.go index 09c632e..e114baa 100644 --- a/client.go +++ b/client.go @@ -47,6 +47,14 @@ type Client struct { send chan []byte username string + + auth bool +} + +type Auth struct { + Type string `json:"type"` + Username string `json:"username"` + Key string `json:"key"` } // readPump pumps messages from the websocket connection to the hub. @@ -70,18 +78,28 @@ func (c *Client) readPump() { } break } - var v WsBetMessage + var v Auth err = json.Unmarshal(message, &v) if err == nil { - if v.Type == "bind" { + // check for authorization messages + if v.Type == "auth" { + // bind the WS client to the username (useful) c.username = strings.ToLower(v.Username) + // if auth key matches session, they are authorized to bet/chat etc + if DBGetUserByUsername(c.username).Session == v.Key { + c.auth = true + } + // if the user has bet (and then reloaded the page) send them their state if c.hub.allUsers[c.username] != "" { - c.send <- []byte("{\"type\":\"hasbet\",\"value\":true,\"bet\":\"" + c.hub.allUsers[c.username] + "\"}") + c.send <- []byte("{\"type\":\"state\",\"value\":true,\"bet\":\"" + c.hub.allUsers[c.username] + "\"}") } continue } } - c.hub.broadcast <- message + // only authorized clients are allowed to push to the Hub + if c.auth { + c.hub.broadcast <- message + } } } diff --git a/clock.go b/clock.go index 01db904..c26f0b4 100644 --- a/clock.go +++ b/clock.go @@ -24,8 +24,7 @@ func (h *Hub) runGameClock() { msg = "{\"type\":\"flip\",\"value\":\"tails\"}" } h.broadcast <- []byte(msg) - time.Sleep(4 * time.Second) + time.Sleep(5 * time.Second) h.broadcastPoolUpdate() - time.Sleep(1 * time.Second) } } diff --git a/db.go b/db.go index d49d56a..971e273 100644 --- a/db.go +++ b/db.go @@ -7,7 +7,6 @@ import ( "os" "time" - "github.com/joho/godotenv" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" @@ -15,26 +14,21 @@ import ( "go.mongodb.org/mongo-driver/mongo/readpref" ) -var DB = openDB() +var DB *mongo.Client func openDB() *mongo.Client { - err := godotenv.Load() - if err != nil { - log.Fatal("Unable to load database. Shutting down...") - } dbUsername := os.Getenv("DB_USERNAME") dbPassword := os.Getenv("DB_PASSWORD") serverAPIOptions := options.ServerAPI(options.ServerAPIVersion1) clientOptions := options.Client(). - ApplyURI("mongodb+srv://" + dbUsername + ":" + dbPassword + "@cluster0.tqrat.mongodb.net/myFirstDatabase?retryWrites=true&w=majority"). + ApplyURI("mongodb+srv://" + dbUsername + ":" + dbPassword + "@cluster0.tqrat.mongodb.net/?retryWrites=true&w=majority"). SetServerAPIOptions(serverAPIOptions) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() client, err := mongo.Connect(ctx, clientOptions) - err = client.Ping(ctx, readpref.Primary()) if err != nil { - log.Fatal("DB Error: " + err.Error()) + log.Fatal(err) } return client } @@ -44,7 +38,13 @@ func DBSubtractPoints(user string, p int) error { if err != nil { DB = openDB() } - userCollection := DB.Database("Users").Collection("Users") + var userCollection *mongo.Collection + if os.Getenv("ENVIRONMENT") == "production" { + userCollection = DB.Database("Users").Collection("Users") + } else { + userCollection = DB.Database("Development").Collection("Users") + } + var v ExistingAccount err = userCollection.FindOne(context.Background(), bson.D{primitive.E{Key: "username", Value: user}}).Decode(&v) @@ -67,7 +67,12 @@ func DBAddPoints(user string, p int) error { if err != nil { DB = openDB() } - userCollection := DB.Database("Users").Collection("Users") + var userCollection *mongo.Collection + if os.Getenv("ENVIRONMENT") == "production" { + userCollection = DB.Database("Users").Collection("Users") + } else { + userCollection = DB.Database("Development").Collection("Users") + } var v ExistingAccount err = userCollection.FindOne(context.Background(), bson.D{primitive.E{Key: "username", Value: user}}).Decode(&v) @@ -92,7 +97,12 @@ func DBGetUserByUsername(user string) ExistingAccount { if err != nil { DB = openDB() } - userCollection := DB.Database("Users").Collection("Users") + var userCollection *mongo.Collection + if os.Getenv("ENVIRONMENT") == "production" { + userCollection = DB.Database("Users").Collection("Users") + } else { + userCollection = DB.Database("Development").Collection("Users") + } var v ExistingAccount err = userCollection.FindOne(context.Background(), bson.D{primitive.E{Key: "username", Value: user}}).Decode(&v) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1683b68..b8ed2ed 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,18 +1,19 @@ { "name": "massflip", - "version": "0.0.3", + "version": "0.0.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "massflip", - "version": "0.0.3", + "version": "0.0.4", "dependencies": { "core-js": "^3.8.3", "dotenv": "^16.0.1", "pinia": "^2.0.0-rc.10", "vue": "^3.2.13", - "vue-gtag": "^2.0.1" + "vue-gtag": "^2.0.1", + "vue-recaptcha-v3": "^2.0.1" }, "devDependencies": { "@babel/core": "^7.12.16", @@ -9264,6 +9265,11 @@ "node": ">=8.10.0" } }, + "node_modules/recaptcha-v3": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/recaptcha-v3/-/recaptcha-v3-1.10.0.tgz", + "integrity": "sha512-aGTxYSk3FFNKnXeKDbLpgRDRyIHRZNBF5HyaXXAN1Aj4TSyyZvmoAn9CylvpqLV3pYpIQavwc+2rzhNFn5SsLQ==" + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -10788,6 +10794,17 @@ "node": ">=8" } }, + "node_modules/vue-recaptcha-v3": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/vue-recaptcha-v3/-/vue-recaptcha-v3-2.0.1.tgz", + "integrity": "sha512-isEDtOfHU4wWRrZZuxciAELtQmPOeEEdicPNa0f1AOyLPy3sCcBEcpFt+FOcO3RQv5unJ3Yn5NlsWtXv9rXqjg==", + "dependencies": { + "recaptcha-v3": "^1.8.0" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, "node_modules/vue-style-loader": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.3.tgz", @@ -18387,6 +18404,11 @@ "picomatch": "^2.2.1" } }, + "recaptcha-v3": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/recaptcha-v3/-/recaptcha-v3-1.10.0.tgz", + "integrity": "sha512-aGTxYSk3FFNKnXeKDbLpgRDRyIHRZNBF5HyaXXAN1Aj4TSyyZvmoAn9CylvpqLV3pYpIQavwc+2rzhNFn5SsLQ==" + }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -19558,6 +19580,14 @@ } } }, + "vue-recaptcha-v3": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/vue-recaptcha-v3/-/vue-recaptcha-v3-2.0.1.tgz", + "integrity": "sha512-isEDtOfHU4wWRrZZuxciAELtQmPOeEEdicPNa0f1AOyLPy3sCcBEcpFt+FOcO3RQv5unJ3Yn5NlsWtXv9rXqjg==", + "requires": { + "recaptcha-v3": "^1.8.0" + } + }, "vue-style-loader": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index e893608..fef8c87 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "massflip", - "version": "0.0.2", + "version": "0.0.4", "private": true, "scripts": { "serve": "vue-cli-service serve", @@ -12,7 +12,8 @@ "dotenv": "^16.0.1", "pinia": "^2.0.0-rc.10", "vue": "^3.2.13", - "vue-gtag": "^2.0.1" + "vue-gtag": "^2.0.1", + "vue-recaptcha-v3": "^2.0.1" }, "devDependencies": { "@babel/core": "^7.12.16", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3dd7920..0d98d0b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -28,7 +28,7 @@ onMounted(() => { let id = jar["session"] // handle logged in user let req = new XMLHttpRequest - req.open("POST", "/api/login/bysession") + req.open('POST', '/api/login/bysession') req.send(JSON.stringify({ session: id })) @@ -36,14 +36,16 @@ onMounted(() => { if (req.readyState == XMLHttpRequest.DONE) { let usr = JSON.parse(req.responseText) if ("error" in usr) { - document.cookie = "session=; Max-Age=-99999999" - console.log(usr["error"]) + document.cookie = 'session=; Max-Age=-99999999' + console.log(usr['error']) return } userStore().updateUser(usr) + let jar = cookiesToObj(cookieStr) let msg = JSON.stringify({ - type: "bind", - username: usr.username + type: "auth", + username: usr.username, + key: jar['session'] }) WSSend(msg) } diff --git a/frontend/src/components/BetBox.vue b/frontend/src/components/BetBox.vue index 08d9f3f..51b9882 100644 --- a/frontend/src/components/BetBox.vue +++ b/frontend/src/components/BetBox.vue @@ -69,6 +69,7 @@ onMounted(() => { if (isNaN(hP)) { headsPercent.value = 0 headsStyle['--p'] = 50 + } else { headsPercent.value = hP headsStyle['--p'] = headsPercent.value @@ -83,26 +84,30 @@ onMounted(() => { tailsPool.value = wsMsg.tailspool } WS.addEventListener("message", function (evt) { - let wsMsg = JSON.parse(evt.data) - if (wsMsg.type == "pool") { - updatePool(wsMsg) - } else if (wsMsg.type == "win") { - userStore().addPoints(wsMsg.value) - } else if (wsMsg.type == "tick") { - let time = wsMsg.clock - let timeString = (Math.floor(time/60)).toString() + ":" + ((time%60)>9?"":"0") + (time%60).toString() + "s" - clock.value = timeString - until.value = 'until next flip' - updatePool(wsMsg) - } else if (wsMsg.type == "flip") { - clock.value = wsMsg.value - until.value = '' - userStore().setBet("") - } else if (wsMsg.type == "hasbet") { - if (wsMsg.value == true) { - userStore().bet = wsMsg.bet + let MSGs = evt.data.split('\n') + MSGs.forEach((i) => { + let wsMsg = JSON.parse(i) + if (wsMsg.type == "pool") { + updatePool(wsMsg) + } else if (wsMsg.type == "win") { + userStore().addPoints(wsMsg.value) + } else if (wsMsg.type == "tick") { + let time = wsMsg.clock + let timeString = (Math.floor(time/60)).toString() + ":" + ((time%60)>9?"":"0") + (time%60).toString() + "s" + clock.value = timeString + until.value = 'until next flip' + updatePool(wsMsg) + } else if (wsMsg.type == "flip") { + clock.value = wsMsg.value + until.value = '' + userStore().setBet("") + } else if (wsMsg.type == "state") { + if (wsMsg.value == true) { + userStore().bet = wsMsg.bet + } } - } + }) + }) }) @@ -192,6 +197,9 @@ onMounted(() => { background:var(--c); transform:rotate(calc(var(--p)*3.6deg - 90deg)) translate(calc(var(--w)/2 - 50%)); } +@-moz-keyframes p{ + from{--p:0} +} @keyframes p{ from{--p:0} } diff --git a/frontend/src/components/BetInput.vue b/frontend/src/components/BetInput.vue index 3a91f9c..3382c26 100644 --- a/frontend/src/components/BetInput.vue +++ b/frontend/src/components/BetInput.vue @@ -22,10 +22,14 @@ function submitBet(HorT){ if (userStore().bet != '') { return } - if (bet.value <= 0) { + if (bet.value <= 0 || isNaN(bet.value)) { hasError.value = true error.value = "Error: bet must be greater than 0" return + } else if (bet.value % 1 != 0) { + hasError.value = true + error.value = "Error: bet must be a whole number" + return } if (userStore().points - bet.value < 0) { hasError.value = true diff --git a/frontend/src/components/ChatBox.vue b/frontend/src/components/ChatBox.vue index 4201503..15b2759 100644 --- a/frontend/src/components/ChatBox.vue +++ b/frontend/src/components/ChatBox.vue @@ -31,10 +31,11 @@ const ChatColors = { green: "limegreen", yellow: "gold", cyan: "cyan", - red: "firebrick", + red: "crimson", pink: "fuchsia", violet: "violet", orange: "orange", + blue: "cornflowerblue" } const _CHAT_MAX_HISTORY = 75; @@ -49,28 +50,31 @@ onMounted(() => { } } WS.addEventListener("message", function (evt) { - let wsMsg = JSON.parse(evt.data) - if (wsMsg.type == "chat") { - chatQueue.enqueue(wsMsg) - if (chatQueue.length >= _CHAT_MAX_HISTORY) { - chatQueue.dequeue() + let MSGs = evt.data.split('\n') + MSGs.forEach((i) => { + let wsMsg = JSON.parse(i) + if (wsMsg.type == "chat") { + chatQueue.enqueue(wsMsg) + if (chatQueue.length >= _CHAT_MAX_HISTORY) { + chatQueue.dequeue() + } + log.innerHTML = "" + for (let message of Object.values(chatQueue.elements)) { + var item = document.createElement("div") + let fromUser = document.createElement("span") + fromUser.style = `color: ${message.color};` + fromUser.innerText = message.username + item.appendChild(fromUser) + let chatScore = document.createElement("span") + chatScore.innerText = `(${message.points})` + chatScore.style = `color: ${message.color};font-family: 'Helvetica';font-size: 12px;` + item.appendChild(chatScore) + let chatMsg = document.createTextNode(`: ${message.message}`) + item.appendChild(chatMsg) + appendLog(item) + } } - log.innerHTML = "" - for (let message of Object.values(chatQueue.elements)) { - var item = document.createElement("div") - let fromUser = document.createElement("span") - fromUser.style = `color: ${message.color};` - fromUser.innerText = message.username - item.appendChild(fromUser) - let chatScore = document.createElement("span") - chatScore.innerText = `(${message.points})` - chatScore.style = `color: ${message.color};font-family: 'Helvetica';font-size: 12px;` - item.appendChild(chatScore) - let chatMsg = document.createTextNode(`: ${message.message}`) - item.appendChild(chatMsg) - appendLog(item) - } - } + }) }) }) diff --git a/frontend/src/components/LoginForm.vue b/frontend/src/components/LoginForm.vue index f15cbf5..b8a0d56 100644 --- a/frontend/src/components/LoginForm.vue +++ b/frontend/src/components/LoginForm.vue @@ -16,8 +16,9 @@
Don't have an account?
- - + + +

{{ serverResponse }}

@@ -26,6 +27,8 @@