From d9f878537280d4595cc14a95d454cff4c644a08e Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 10 Jan 2022 01:55:48 +0000 Subject: [PATCH] form: add CAPTCHAs Enabled in Settings > Captchas, shows a captcha on the account creation form. --- api.go | 7 ++- config/config-base.json | 17 ++++++ go.mod | 1 + go.sum | 6 +++ html/form-base.html | 1 + html/form.html | 19 ++++--- lang/form/en-us.json | 1 + models.go | 5 ++ router.go | 5 ++ storage.go | 12 +++-- ts/form.ts | 46 ++++++++++++++++ views.go | 116 ++++++++++++++++++++++++++++++++++++++++ 12 files changed, 223 insertions(+), 13 deletions(-) diff --git a/api.go b/api.go index a4a6821..a896446 100644 --- a/api.go +++ b/api.go @@ -651,6 +651,11 @@ func (app *appContext) NewUser(gc *gin.Context) { respond(400, "errorNoEmail", gc) return } + if app.config.Section("captcha").Key("enabled").MustBool(false) && !verifyCaptcha(req.Captcha) { + app.info.Printf("%s: New user failed: Captcha Incorrect", req.Code) + respond(400, "errorCaptcha", gc) + return + } f, success := app.newUser(req, false) if !success { f(gc) @@ -1585,7 +1590,7 @@ func (app *appContext) DeleteOmbiProfile(gc *gin.Context) { // @Summary Set whether or not a user can access jfa-go. Redundant if the user is a Jellyfin admin. // @Produce json -// @Param setAccountsAdminDTO body setAccountsAdminDTO true "Map of userIDs whether or not they have access." +// @Param setAccountsAdminDTO body setAccountsAdminDTO true "Map of userIDs to whether or not they have access." // @Success 204 {object} boolResponse // @Failure 500 {object} boolResponse // @Router /users/accounts-admin [post] diff --git a/config/config-base.json b/config/config-base.json index d59f431..46b73f7 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -312,6 +312,23 @@ } } }, + "captcha": { + "order": [], + "meta": { + "name": "Captcha", + "description": "Settings related to user creation CAPTCHAs." + }, + "settings": { + "enabled": { + "name": "Enabled", + "required": false, + "requires_restart": true, + "type": "bool", + "value": false, + "description": "Enable a CAPTCHA on the account creation form." + } + } + }, "password_validation": { "order": [], "meta": { diff --git a/go.mod b/go.mod index 15fc95d..3224ae5 100644 --- a/go.mod +++ b/go.mod @@ -48,6 +48,7 @@ require ( github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/pkg/errors v0.9.1 // indirect github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 + github.com/steambap/captcha v1.4.1 // indirect github.com/swaggo/files v0.0.0-20210815190702-a29dd2bc99b2 github.com/swaggo/gin-swagger v1.3.3 github.com/swaggo/swag v1.7.8 // indirect diff --git a/go.sum b/go.sum index 0f63189..b7c50e6 100644 --- a/go.sum +++ b/go.sum @@ -126,6 +126,8 @@ github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= @@ -239,6 +241,8 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EE github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/steambap/captcha v1.4.1 h1:OmMdxLCWCqJvsFaFYwRpvMckIuvI6s8s1LsBrBw97P0= +github.com/steambap/captcha v1.4.1/go.mod h1:oC9T7IfEgnrhzjDz5Djf1H7GPffCzRMbsQfFkJmhlnk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -305,6 +309,8 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d h1:RNPAfi2nHY7C2srAV8A49jpsYr0ADedCk1wq6fTMTvs= +golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38= diff --git a/html/form-base.html b/html/form-base.html index 4edb119..55229a6 100644 --- a/html/form-base.html +++ b/html/form-base.html @@ -26,6 +26,7 @@ window.matrixEnabled = {{ .matrixEnabled }}; window.matrixRequired = {{ .matrixRequired }}; window.matrixUserID = "{{ .matrixUser }}"; + window.captcha = {{ .captcha }}; {{ if .passwordReset }} diff --git a/html/form.html b/html/form.html index b7dde13..771b1bc 100644 --- a/html/form.html +++ b/html/form.html @@ -3,13 +3,11 @@ {{ template "header.html" . }} - - {{ if .passwordReset }} - {{ .strings.passwordReset }} - {{ else }} - {{ .strings.pageTitle }} - {{ end }} - + {{ if .passwordReset }} + {{ .strings.passwordReset }} + {{ else }} + {{ .strings.pageTitle }} + {{ end }} + {{ if .captcha }} +
+ CAPTCHA +
+ +
+ {{ end }} {{ if .contactMessage }} {{ end }} diff --git a/lang/form/en-us.json b/lang/form/en-us.json index c504cb7..6c89726 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -30,6 +30,7 @@ "errorInvalidPIN": "PIN is invalid.", "errorUnknown": "Unknown error.", "errorNoEmail": "Email required.", + "errorCaptcha": "Captcha incorrect.", "verified": "Account verified." }, "validationStrings": { diff --git a/models.go b/models.go index ba74494..2f1b83c 100644 --- a/models.go +++ b/models.go @@ -23,6 +23,7 @@ type newUserDTO struct { DiscordContact bool `json:"discord_contact"` // Whether or not to use discord for notifications/pwrs MatrixPIN string `json:"matrix_pin" example:"A1-B2-3C"` // Matrix verification PIN (if used) MatrixContact bool `json:"matrix_contact"` // Whether or not to use matrix for notifications/pwrs + Captcha string `json:"captcha"` // Captcha text (if enabled) } type newUserResponse struct { @@ -349,3 +350,7 @@ type LogDTO struct { } type setAccountsAdminDTO map[string]bool + +type genCaptchaDTO struct { + ID string `json:"id"` +} diff --git a/router.go b/router.go index 65186f5..52ccf64 100644 --- a/router.go +++ b/router.go @@ -121,6 +121,11 @@ func (app *appContext) loadRoutes(router *gin.Engine) { router.POST(p+"/newUser", app.NewUser) router.Use(static.Serve(p+"/invite/", app.webFS)) router.GET(p+"/invite/:invCode", app.InviteProxy) + if app.config.Section("captcha").Key("enabled").MustBool(false) { + router.GET(p+"/captcha/gen/:invCode", app.GenCaptcha) + router.GET(p+"/captcha/img/:invCode/:captchaID", app.GetCaptcha) + router.POST(p+"/captcha/verify/:invCode/:captchaID/:text", app.VerifyCaptcha) + } if telegramEnabled { router.GET(p+"/invite/:invCode/telegram/verified/:pin", app.TelegramVerifiedInvite) } diff --git a/storage.go b/storage.go index 56f646a..c1fadda 100644 --- a/storage.go +++ b/storage.go @@ -12,6 +12,7 @@ import ( "time" "github.com/hrfee/mediabrowser" + "github.com/steambap/captcha" ) type Storage struct { @@ -102,11 +103,12 @@ type Invite struct { UserMinutes int `json:"user-minutes,omitempty"` SendTo string `json:"email"` // Used to be stored as formatted time, now as Unix. - UsedBy [][]string `json:"used-by"` - Notify map[string]map[string]bool `json:"notify"` - Profile string `json:"profile"` - Label string `json:"label,omitempty"` - Keys []string `json:"keys,omitempty"` + UsedBy [][]string `json:"used-by"` + Notify map[string]map[string]bool `json:"notify"` + Profile string `json:"profile"` + Label string `json:"label,omitempty"` + Keys []string `json:"keys,omitempty"` + Captchas map[string]*captcha.Data // Map of Captcha IDs to answers } type Lang struct { diff --git a/ts/form.ts b/ts/form.ts index f6f9853..aaaaefd 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -30,6 +30,7 @@ interface formWindow extends Window { userExpiryMinutes: number; userExpiryMessage: string; emailRequired: boolean; + captcha: boolean; } loadLangSelector("form"); @@ -262,10 +263,52 @@ interface sendDTO { discord_contact?: boolean; matrix_pin?: string; matrix_contact?: boolean; + captcha?: string; +} + +let captchaVerified = false; +let captchaID = ""; +let captchaInput = document.getElementById("captcha-input") as HTMLInputElement; + +if (window.captcha) { + _get("/captcha/gen/"+window.code, null, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + if (req.status == 200) { + captchaID = req.response["id"]; + document.getElementById("captcha-img").innerHTML = ` + + `; + } + } + }); + const input = document.querySelector("input[type=submit]") as HTMLInputElement; + const checkbox = document.getElementById("captcha-success") as HTMLSpanElement; + captchaInput.onkeyup = () => _post("/captcha/verify/" + window.code + "/" + captchaID + "/" + captchaInput.value, null, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + if (req.status == 204) { + input.disabled = false; + input.nextElementSibling.removeAttribute("disabled"); + checkbox.innerHTML = ``; + checkbox.classList.add("~positive"); + checkbox.classList.remove("~critical"); + } else { + input.disabled = true; + input.nextElementSibling.setAttribute("disabled", "true"); + checkbox.innerHTML = ``; + checkbox.classList.add("~critical"); + checkbox.classList.remove("~positive"); + } + } + }, ); + input.disabled = true; + input.nextElementSibling.setAttribute("disabled", "true"); } const create = (event: SubmitEvent) => { event.preventDefault(); + if (window.captcha && !captchaVerified) { + + } toggleLoader(submitSpan); let send: sendDTO = { code: window.code, @@ -294,6 +337,9 @@ const create = (event: SubmitEvent) => { send.matrix_contact = true; } } + if (window.captcha) { + send.captcha = captchaInput.value; + } _post("/newUser", send, (req: XMLHttpRequest) => { if (req.readyState == 4) { let vals = req.response as respDTO; diff --git a/views.go b/views.go index dfbf751..5d5650b 100644 --- a/views.go +++ b/views.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt" "github.com/hrfee/mediabrowser" + "github.com/steambap/captcha" ) var cssVersion string @@ -253,6 +254,120 @@ func (app *appContext) ResetPassword(gc *gin.Context) { } } +// @Summary returns the captcha image corresponding to the given ID. +// @Param code path string true "invite code" +// @Param captchaID path string true "captcha ID" +// @Tags Other +// @Router /captcha/img/{code}/{captchaID} [get] +func (app *appContext) GetCaptcha(gc *gin.Context) { + code := gc.Param("invCode") + captchaID := gc.Param("captchaID") + inv, ok := app.storage.invites[code] + if !ok { + gcHTML(gc, 404, "invalidCode.html", gin.H{ + "cssClass": app.cssClass, + "cssVersion": cssVersion, + "contactMessage": app.config.Section("ui").Key("contact_message").String(), + }) + } + var capt *captcha.Data + if inv.Captchas != nil { + capt = inv.Captchas[captchaID] + } + if capt == nil { + respondBool(400, false, gc) + return + } + if err := capt.WriteImage(gc.Writer); err != nil { + app.err.Printf("Failed to write CAPTCHA image: %v", err) + respondBool(500, false, gc) + return + } + gc.Status(200) + return +} + +// @Summary Generates a new captcha and returns it's ID. This can then be included in a request to /captcha/img/{id} to get an image. +// @Produce json +// @Param code path string true "invite code" +// @Success 200 {object} genCaptchaDTO +// @Router /captcha/gen/{code} [get] +// @Security Bearer +// @tags Users +func (app *appContext) GenCaptcha(gc *gin.Context) { + code := gc.Param("invCode") + inv, ok := app.storage.invites[code] + if !ok { + gcHTML(gc, 404, "invalidCode.html", gin.H{ + "cssClass": app.cssClass, + "cssVersion": cssVersion, + "contactMessage": app.config.Section("ui").Key("contact_message").String(), + }) + } + capt, err := captcha.New(300, 100) + if err != nil { + app.err.Printf("Failed to generate captcha: %v", err) + respondBool(500, false, gc) + return + } + if inv.Captchas == nil { + inv.Captchas = map[string]*captcha.Data{} + } + captchaID := genAuthToken() + inv.Captchas[captchaID] = capt + app.storage.invites[code] = inv + gc.JSON(200, genCaptchaDTO{captchaID}) + return +} + +func (app *appContext) verifyCaptcha(code, id, text string) bool { + inv, ok := app.storage.invites[code] + if !ok || inv.Captchas == nil { + return false + } + c, ok := inv.Captchas[id] + if !ok { + return false + } + return strings.ToLower(c.Text) == strings.ToLower(text) +} + +// @Summary returns 204 if the given Captcha contents is correct for the corresponding captcha ID and invite code. +// @Param code path string true "invite code" +// @Param captchaID path string true "captcha ID" +// @Param text path string true "Captcha text" +// @Success 204 +// @Tags Other +// @Router /captcha/verify/{code}/{captchaID}/{text} [get] +func (app *appContext) VerifyCaptcha(gc *gin.Context) { + code := gc.Param("invCode") + captchaID := gc.Param("captchaID") + text := gc.Param("text") + inv, ok := app.storage.invites[code] + if !ok { + gcHTML(gc, 404, "invalidCode.html", gin.H{ + "cssClass": app.cssClass, + "cssVersion": cssVersion, + "contactMessage": app.config.Section("ui").Key("contact_message").String(), + }) + return + } + var capt *captcha.Data + if inv.Captchas != nil { + capt = inv.Captchas[captchaID] + } + if capt == nil { + respondBool(400, false, gc) + return + } + if strings.ToLower(capt.Text) != strings.ToLower(text) { + respondBool(400, false, gc) + return + } + respondBool(204, true, gc) + return +} + func (app *appContext) InviteProxy(gc *gin.Context) { app.pushResources(gc, false) code := gc.Param("invCode") @@ -370,6 +485,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) { "discordEnabled": discord, "matrixEnabled": matrix, "emailRequired": app.config.Section("email").Key("required").MustBool(false), + "captcha": app.config.Section("captcha").Key("enabled").MustBool(false), } if telegram { data["telegramPIN"] = app.telegram.NewAuthToken()