mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-12-22 09:00:10 +00:00
form: add CAPTCHAs
Enabled in Settings > Captchas, shows a captcha on the account creation form.
This commit is contained in:
parent
8758d74e32
commit
d9f8785372
7
api.go
7
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]
|
||||
|
@ -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
1
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
|
||||
|
6
go.sum
6
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=
|
||||
|
@ -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>
|
||||
|
@ -3,13 +3,11 @@
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
<title>
|
||||
{{ if .passwordReset }}
|
||||
{{ .strings.passwordReset }}
|
||||
<title>{{ .strings.passwordReset }}</title>
|
||||
{{ else }}
|
||||
{{ .strings.pageTitle }}
|
||||
<title>{{ .strings.pageTitle }}</title>
|
||||
{{ end }}
|
||||
</title>
|
||||
</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 }}
|
||||
|
@ -30,6 +30,7 @@
|
||||
"errorInvalidPIN": "PIN is invalid.",
|
||||
"errorUnknown": "Unknown error.",
|
||||
"errorNoEmail": "Email required.",
|
||||
"errorCaptcha": "Captcha incorrect.",
|
||||
"verified": "Account verified."
|
||||
},
|
||||
"validationStrings": {
|
||||
|
@ -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"`
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/steambap/captcha"
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
@ -107,6 +108,7 @@ type Invite struct {
|
||||
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 {
|
||||
|
46
ts/form.ts
46
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 = `
|
||||
<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
116
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()
|
||||
|
Loading…
Reference in New Issue
Block a user