Skip to content

Commit 2c716ff

Browse files
committed
implement cloudflare turnstile support
1 parent 2a645d6 commit 2c716ff

13 files changed

Lines changed: 232 additions & 6 deletions

File tree

internal/options/app.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ type Opts struct {
8181
// If not set, falls back to ReadonlyUser for backward compatibility
8282
ResetUser string
8383
ResetPassword string
84+
85+
CfTurnstileSiteKey string
86+
CfTurnstileSecret string
87+
CfTurnstileTimeoutSeconds uint
8488
}
8589

8690
// ConfigError represents a configuration validation error.
@@ -432,13 +436,37 @@ func ParseArgs(args []string) (*Opts, error) {
432436
envStringOrDefault("LDAP_RESET_PASSWORD", ""),
433437
"Password for the dedicated reset user.",
434438
)
439+
440+
fCfTurnstileSiteKey = fs.String(
441+
"cf-turnstile-site-key",
442+
envStringOrDefault("CF_TURNSTILE_SITEKEY", ""),
443+
"Cloudflare Turnstile site key. Empty disables Turnstile.",
444+
)
445+
fCfTurnstileSecret = fs.String(
446+
"cf-turnstile-secret",
447+
envStringOrDefault("CF_TURNSTILE_SECRET", ""),
448+
"Cloudflare Turnstile secret key.",
449+
)
450+
fCfTurnstileTimeoutSeconds = fs.Uint(
451+
"cf-turnstile-timeout-seconds",
452+
envIntOrDefault("CF_TURNSTILE_TIMEOUT_SECONDS", 15, errs),
453+
"Timeout in seconds for Cloudflare Turnstile verification.",
454+
)
435455
)
436456

437457
// Parse the provided command-line arguments (caller passes args without program name)
438458
if err := fs.Parse(args); err != nil {
439459
errs.Add(fmt.Sprintf("flag parsing error: %v", err))
440460
}
441461

462+
if (*fCfTurnstileSiteKey == "") != (*fCfTurnstileSecret == "") {
463+
errs.Add("cf-turnstile-site-key and cf-turnstile-secret must be configured together")
464+
}
465+
466+
if *fCfTurnstileTimeoutSeconds == 0 {
467+
errs.Add("cf-turnstile-timeout-seconds must be greater than zero")
468+
}
469+
442470
// Keep the conversions at the use sites total: see the bound constants.
443471
checkUintMax("smtp-port", *fSMTPPort, maxSMTPPort, errs)
444472
checkUintMax("reset-token-expiry-minutes", *fResetTokenExpiryMinutes, maxDurationMinutes, errs)
@@ -551,6 +579,10 @@ func ParseArgs(args []string) (*Opts, error) {
551579

552580
ResetUser: *fResetUser,
553581
ResetPassword: *fResetPassword,
582+
583+
CfTurnstileSiteKey: *fCfTurnstileSiteKey,
584+
CfTurnstileSecret: *fCfTurnstileSecret,
585+
CfTurnstileTimeoutSeconds: *fCfTurnstileTimeoutSeconds,
554586
}, nil
555587
}
556588

internal/rpchandler/dto.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ var ErrInvalidArgumentCount = errors.New("invalid argument count")
77

88
// Request represents a JSON-RPC 2.0 request with method name and string parameters.
99
type Request struct {
10-
Method string `json:"method"`
11-
Params []string `json:"params"`
10+
Method string `json:"method"`
11+
Params []string `json:"params"`
12+
TurnstileToken string `json:"turnstileToken,omitempty"`
1213
}
1314

1415
// Response represents a JSON-RPC 2.0 response with success status and data payload.

internal/rpchandler/handler.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ package rpchandler
33
import (
44
"fmt"
55
"net/http"
6+
"time"
67

78
"github.com/gofiber/fiber/v3"
89
ldap "github.com/netresearch/simple-ldap-go"
910

1011
"github.com/netresearch/ldap-selfservice-password-changer/internal/options"
12+
"github.com/netresearch/ldap-selfservice-password-changer/internal/turnstile"
1113
)
1214

1315
// Func is a type alias for RPC handler functions that process string parameters and return results or errors.
@@ -101,6 +103,18 @@ func (h *Handler) Handle(c fiber.Ctx) error {
101103
// Extract client IP for rate limiting
102104
clientIP := extractClientIP(c)
103105

106+
// Verify Cloudflare Turnstile when configured.
107+
if h.opts.CfTurnstileSiteKey != "" && h.opts.CfTurnstileSecret != "" {
108+
if err := turnstile.Verify(
109+
h.opts.CfTurnstileSecret,
110+
body.TurnstileToken,
111+
clientIP,
112+
time.Duration(h.opts.CfTurnstileTimeoutSeconds)*time.Second,
113+
); err != nil {
114+
return sendErrorResponse(c, http.StatusForbidden, "Turnstile verification failed")
115+
}
116+
}
117+
104118
switch body.Method {
105119
case "change-password":
106120
return h.handleChangePassword(c, body.Params, clientIP)

internal/turnstile/turnstile.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package turnstile
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"net/url"
9+
"strings"
10+
"time"
11+
)
12+
13+
const siteverifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
14+
15+
type verifyResponse struct {
16+
Success bool `json:"success"`
17+
}
18+
19+
// Verify validates a Turnstile token with Cloudflare.
20+
func Verify(secret, token, remoteIP string, timeout time.Duration) error {
21+
if token == "" {
22+
return errors.New("missing Turnstile token")
23+
}
24+
25+
values := url.Values{
26+
"secret": {secret},
27+
"response": {token},
28+
}
29+
30+
if remoteIP != "" {
31+
values.Set("remoteip", remoteIP)
32+
}
33+
34+
req, err := http.NewRequest(
35+
http.MethodPost,
36+
siteverifyURL,
37+
strings.NewReader(values.Encode()),
38+
)
39+
if err != nil {
40+
return fmt.Errorf("create Turnstile verification request: %w", err)
41+
}
42+
43+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
44+
45+
client := &http.Client{
46+
Timeout: timeout,
47+
}
48+
49+
resp, err := client.Do(req)
50+
if err != nil {
51+
return fmt.Errorf("verify Turnstile token: %w", err)
52+
}
53+
defer resp.Body.Close()
54+
55+
if resp.StatusCode != http.StatusOK {
56+
return fmt.Errorf("Turnstile verification returned HTTP %d", resp.StatusCode)
57+
}
58+
59+
var result verifyResponse
60+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
61+
return fmt.Errorf("decode Turnstile verification response: %w", err)
62+
}
63+
64+
if !result.Success {
65+
return errors.New("Turnstile verification failed")
66+
}
67+
68+
return nil
69+
}

internal/web/static/js/app.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ interface Opts {
3030
passwordCanIncludeUsername: boolean;
3131
}
3232

33+
declare const turnstile: {
34+
reset: () => void;
35+
};
36+
3337
export const init = (opts: Opts) => {
3438
initThemeToggle();
3539
initDensityToggle();
@@ -223,12 +227,20 @@ export const init = (opts: Opts) => {
223227
const [username, oldPassword, newPassword] = fields.map((f) => f.getValue());
224228

225229
try {
230+
const turnstileToken =
231+
form.querySelector<HTMLInputElement>('input[name="cf-turnstile-response"]')?.value ?? "";
232+
if (form.querySelector(".cf-turnstile") && !turnstileToken) {
233+
toggleFields(true);
234+
return;
235+
}
236+
226237
const res = await fetch("/api/rpc", {
227238
method: "POST",
228239
headers: { "Content-Type": "application/json" },
229240
body: JSON.stringify({
230241
method: "change-password",
231-
params: [username, oldPassword, newPassword]
242+
params: [username, oldPassword, newPassword],
243+
...(turnstileToken && { turnstileToken })
232244
})
233245
});
234246

@@ -248,6 +260,11 @@ export const init = (opts: Opts) => {
248260
successContainer.classList.remove("hidden");
249261
} catch (err) {
250262
setSubmitError(submitErrorContainer, (err as Error).message);
263+
264+
if (form.querySelector(".cf-turnstile")) {
265+
turnstile.reset();
266+
}
267+
251268
toggleFields(true);
252269
}
253270
};

internal/web/static/js/forgot-password.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ const identifierField = (mode: IdentifierMode): { label: string; validators: ((v
2121
}
2222
};
2323

24+
declare const turnstile: {
25+
reset: () => void;
26+
};
27+
2428
export const init = (rawMode: string) => {
2529
const mode = asMode(rawMode);
2630
initThemeToggle();
@@ -146,10 +150,21 @@ export const init = (rawMode: string) => {
146150
const [email] = fields.map((f) => f.getValue());
147151

148152
try {
153+
const turnstileToken =
154+
form.querySelector<HTMLInputElement>('input[name="cf-turnstile-response"]')?.value ?? "";
155+
if (form.querySelector(".cf-turnstile") && !turnstileToken) {
156+
toggleFields(true);
157+
return;
158+
}
159+
149160
const res = await fetch("/api/rpc", {
150161
method: "POST",
151162
headers: { "Content-Type": "application/json" },
152-
body: JSON.stringify({ method: "request-password-reset", params: [email] })
163+
body: JSON.stringify({
164+
method: "request-password-reset",
165+
params: [email],
166+
...(turnstileToken && { turnstileToken })
167+
})
153168
});
154169

155170
const body = await res.text();
@@ -168,6 +183,11 @@ export const init = (rawMode: string) => {
168183
successContainer.classList.remove("hidden");
169184
} catch (err) {
170185
setSubmitError(submitErrorContainer, (err as Error).message);
186+
187+
if (form.querySelector(".cf-turnstile")) {
188+
turnstile.reset();
189+
}
190+
171191
toggleFields(true);
172192
}
173193
};

internal/web/static/js/reset-password.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ interface Opts {
2626
minLowercase: number;
2727
}
2828

29+
declare const turnstile: {
30+
reset: () => void;
31+
};
32+
2933
export const init = (opts: Opts) => {
3034
initThemeToggle();
3135
initDensityToggle();
@@ -219,10 +223,21 @@ export const init = (opts: Opts) => {
219223
const [newPassword] = fields.map((f) => f.getValue());
220224

221225
try {
226+
const turnstileToken =
227+
form.querySelector<HTMLInputElement>('input[name="cf-turnstile-response"]')?.value ?? "";
228+
if (form.querySelector(".cf-turnstile") && !turnstileToken) {
229+
toggleFields(true);
230+
return;
231+
}
232+
222233
const res = await fetch("/api/rpc", {
223234
method: "POST",
224235
headers: { "Content-Type": "application/json" },
225-
body: JSON.stringify({ method: "reset-password", params: [token, newPassword] })
236+
body: JSON.stringify({
237+
method: "reset-password",
238+
params: [token, newPassword],
239+
...(turnstileToken && { turnstileToken })
240+
})
226241
});
227242

228243
const body = await res.text();
@@ -241,6 +256,11 @@ export const init = (opts: Opts) => {
241256
successContainer.classList.remove("hidden");
242257
} catch (err) {
243258
setSubmitError(submitErrorContainer, (err as Error).message);
259+
260+
if (form.querySelector(".cf-turnstile")) {
261+
turnstile.reset();
262+
}
263+
244264
toggleFields(true);
245265
}
246266
};

internal/web/templates/forgot-password.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44
<title>Forgot Password — {{ .branding.Name }}</title>
55
{{ template "html-head" }} {{ template "theme-init-script" }} {{ template "density-init-script" }}
66
<link rel="modulepreload" href="/static/js/forgot-password.js" crossorigin="anonymous" />
7+
{{ if .opts.CfTurnstileSiteKey }}
8+
<script
9+
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
10+
async
11+
defer
12+
></script>
13+
{{ end }}
714
</head>
815

916
<body class="page-body">
@@ -43,6 +50,8 @@
4350
{{ template "input" InputOpts "email" "Email Address" "email" "email" "Enter your email address to receive a password reset link" }}
4451
{{ end }}
4552

53+
{{ template "turnstile" .opts.CfTurnstileSiteKey }}
54+
4655
{{ template "form-submit" "Send Reset Link" }}
4756
</form>
4857

internal/web/templates/index.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44
<title>{{ .branding.PageTitle }}</title>
55
{{ template "html-head" }} {{ template "theme-init-script" }} {{ template "density-init-script" }}
66
<link rel="modulepreload" href="/static/js/app.js" crossorigin="anonymous" />
7+
{{ if .opts.CfTurnstileSiteKey }}
8+
<script
9+
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
10+
async
11+
defer
12+
></script>
13+
{{ end }}
714
</head>
815

916
<body class="page-body">
@@ -43,6 +50,8 @@
4350
<!-- prettier-ignore -->
4451
{{ template "input" InputOpts "confirm_password" "Confirm New Password" "password" "new-password" "Re-enter your new password to confirm" }}
4552

53+
{{ template "turnstile" .opts.CfTurnstileSiteKey }}
54+
4655
{{ template "form-submit" "Update Password" }}
4756
</form>
4857

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{{ define "turnstile" }}
2+
{{ if . }}
3+
<div class="cf-turnstile" data-sitekey="{{ . }}"></div>
4+
{{ end }}
5+
{{ end }}

0 commit comments

Comments
 (0)