form: add CAPTCHAs

Enabled in Settings > Captchas, shows a captcha on the account creation
form.
This commit is contained in:
Harvey Tindall 2022-01-10 01:55:48 +00:00
parent 8758d74e32
commit d9f8785372
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
12 changed files with 223 additions and 13 deletions

7
api.go
View File

@ -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]

View File

@ -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": {

1
go.mod
View File

@ -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

6
go.sum
View File

@ -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=

View File

@ -26,6 +26,7 @@
window.matrixEnabled = {{ .matrixEnabled }};
window.matrixRequired = {{ .matrixRequired }};
window.matrixUserID = "{{ .matrixUser }}";
window.captcha = {{ .captcha }};
</script>
{{ if .passwordReset }}
<script src="js/pwr.js" type="module"></script>

View File

@ -3,13 +3,11 @@
<head>
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css">
{{ template "header.html" . }}
<title>
{{ if .passwordReset }}
{{ .strings.passwordReset }}
{{ else }}
{{ .strings.pageTitle }}
{{ end }}
</title>
{{ if .passwordReset }}
<title>{{ .strings.passwordReset }}</title>
{{ else }}
<title>{{ .strings.pageTitle }}</title>
{{ end }}
</head>
<body class="max-w-full overflow-x-hidden section">
<div id="modal-success" class="modal">
@ -177,6 +175,13 @@
{{ end }}
</ul>
</div>
{{ if .captcha }}
<div class="card ~neutral @low mb-4">
<span class="label supra mb-2">CAPTCHA <span id="captcha-success" class="badge lg @low ~critical ml-2 float-right"><i class="ri-close-line"></i></span></span>
<div id="captcha-img" class="mt-2 mb-2"></div>
<input class="field ~neutral @low" id="captcha-input" class="mt-2" placeholder="CAPTCHA">
</div>
{{ end }}
{{ if .contactMessage }}
<aside class="col aside sm ~info mt-4">{{ .contactMessage }}</aside>
{{ end }}

View File

@ -30,6 +30,7 @@
"errorInvalidPIN": "PIN is invalid.",
"errorUnknown": "Unknown error.",
"errorNoEmail": "Email required.",
"errorCaptcha": "Captcha incorrect.",
"verified": "Account verified."
},
"validationStrings": {

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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 = `
<img class="w-100" src="${window.location.toString().substring(0, window.location.toString().lastIndexOf("/invite"))}/captcha/img/${window.code}/${captchaID}"></img>
`;
}
}
});
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 = `<i class="ri-check-line"></i>`;
checkbox.classList.add("~positive");
checkbox.classList.remove("~critical");
} else {
input.disabled = true;
input.nextElementSibling.setAttribute("disabled", "true");
checkbox.innerHTML = `<i class="ri-close-line"></i>`;
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;

116
views.go
View File

@ -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()