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 }}
@@ -177,6 +175,13 @@
{{ end }}
+ {{ if .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()