Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added support for turnstile verification on signup #568

Merged
merged 7 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/gen_ai_review.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
name: "gen: AI review"
on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
jobs:
pr_agent_job:
if: ${{ github.event.sender.type != 'Bot' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
issues: write
pull-requests: write
contents: write
name: Run pr agent on every pull request, respond to user comments
steps:
- name: PR Agent action step
id: pragent
uses: Codium-ai/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
config.max_model_tokens: 100000
config.model: "anthropic/claude-3-5-sonnet-20240620"
config.model_turbo: "anthropic/claude-3-5-sonnet-20240620"
config.ignore.glob: "['vendor/**','**/client_gen.go','**/models_gen.go','**/generated.go','**/*.gen.go']"
10 changes: 10 additions & 0 deletions build/dev/docker/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -160,5 +160,15 @@ services:
SMTP_USER: user
restart: always

nginx:
image: nginx:alpine
ports:
- "443:443"
volumes:
- ./nginx/html:/usr/share/nginx/html
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx//ssl:/etc/nginx/ssl
restart: unless-stopped

volumes:
pgdata: {}
95 changes: 95 additions & 0 deletions build/dev/docker/nginx/html/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign Up Form</title>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f0f0f0;
}
form {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
input {
display: block;
width: 100%;
margin-bottom: 10px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
width: 100%;
padding: 10px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<form id="signupForm">
<h2>Sign Up</h2>
<input type="email" id="email" placeholder="Email" required>
<input type="password" id="password" placeholder="Password" required>
<div class="cf-turnstile" data-sitekey="FIXME"></div>
<button type="submit">Sign Up</button>
</form>

<script>
document.getElementById('signupForm').addEventListener('submit', async function(e) {
e.preventDefault();

const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const turnstileResponse = turnstile.getResponse();

if (!turnstileResponse) {
alert('Please complete the Turnstile challenge.');
return;
}

try {
const response = await fetch('http://localhost:4000/signup/email-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-cf-turnstile-response': turnstileResponse,
},
body: JSON.stringify({
email,
password,
}),
});

if (response.ok) {
alert('Sign up successful!');
// Optionally, redirect the user or clear the form
} else {
const errorData = await response.json();
alert(`Sign up failed: ${errorData.message || 'Unknown error'}`);
}
} catch (error) {
console.error('Error:', error);
alert('An error occurred. Please try again.');
}
});
</script>
</body>
</html>
41 changes: 41 additions & 0 deletions build/dev/docker/nginx/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
events {
worker_connections 1024;
}

http {
server {
listen 443 ssl;
server_name localhost;

ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt;
ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key;

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384;

location / {
root /usr/share/nginx/html;
index index.html;

add_header 'Access-Control-Allow-Origin' 'https://challenges.cloudflare.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';


add_header Content-Security-Policy "default-src 'self'; frame-src 'self' https://challenges.cloudflare.com; frame-ancestors 'self' https://challenges.cloudflare.com; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' http://localhost:4000 https://challenges.cloudflare.com;";

if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://challenges.cloudflare.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}

}
}
20 changes: 20 additions & 0 deletions build/dev/docker/nginx/ssl/nginx-selfsigned.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDSDCCAjACCQDGV/un6TjVazANBgkqhkiG9w0BAQsFADBmMQswCQYDVQQGEwJV
UzEOMAwGA1UECAwFU3RhdGUxDTALBgNVBAcMBENpdHkxFTATBgNVBAoMDE9yZ2Fu
aXphdGlvbjENMAsGA1UECwwEVW5pdDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI0
MDkyNTA4MTA0NVoXDTI1MDkyNTA4MTA0NVowZjELMAkGA1UEBhMCVVMxDjAMBgNV
BAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5MRUwEwYDVQQKDAxPcmdhbml6YXRpb24x
DTALBgNVBAsMBFVuaXQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBAMLzWZAlYj7FMsBHFbiRPNebUTYiVmDiyH/Yk/eG
JhS4Rpw2UjI9LpBgGOJKrPRAD14KBjNHJSSTVrPV6l2kdsugDJDwDUhWlRxng1+Z
zP6MNB9WME6wOFxiUARHumHaABqd/8zY4Ws1DJbPgyXgARmv+2PaeFMOrTsBn70N
Og5s7JZC0V9u7vRxsbrmmWiq9Ew3GOO3RnPg+6gmrxQEo2K85h0npOeCRtP3zfd+
pAUnujstk+75gqAEPlkf+DrUmg8opd4CquoRg2dVai2YwIdvtMqE0SWgZs/wbaOg
gATdOtp8T8YKuyTZ7cTnQRHZJFVMkzfZ6/WCLcysHgtItTMCAwEAATANBgkqhkiG
9w0BAQsFAAOCAQEAXIDN3YBDj+QF+L1F1PlV54/hUv3vXoU35hTfKLu09k76n7wD
pYLHj7/pdZtuqt+0PCllfGBXnCMMtFeH+ZSUZciSinoDg4fpUUT9BnhUVr9MvESu
1fa64aYsOY9KYi8I9LpdIYs3DfpVuAKxFOOW2vH54+HaZMKfGH3ZZKQ8QL+KZzk4
zxaCkl2jCFwD2cCuvc/wu5EBGcjkv429eapFp/8lXqv9YbiW2833t0r4rkEUroP1
Dbm+kf6AUt7tm5AkxvRmbtKjQ8l5kB+GnHVMo+eltmesb8HHMYJqOBbIOTOUz6b8
uPJWrTgLvYT2El87a3pFL2XP4PkV9igakf94gg==
-----END CERTIFICATE-----
28 changes: 28 additions & 0 deletions build/dev/docker/nginx/ssl/nginx-selfsigned.key
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDC81mQJWI+xTLA
RxW4kTzXm1E2IlZg4sh/2JP3hiYUuEacNlIyPS6QYBjiSqz0QA9eCgYzRyUkk1az
1epdpHbLoAyQ8A1IVpUcZ4Nfmcz+jDQfVjBOsDhcYlAER7ph2gAanf/M2OFrNQyW
z4Ml4AEZr/tj2nhTDq07AZ+9DToObOyWQtFfbu70cbG65ploqvRMNxjjt0Zz4Puo
Jq8UBKNivOYdJ6TngkbT9833fqQFJ7o7LZPu+YKgBD5ZH/g61JoPKKXeAqrqEYNn
VWotmMCHb7TKhNEloGbP8G2joIAE3TrafE/GCrsk2e3E50ER2SRVTJM32ev1gi3M
rB4LSLUzAgMBAAECggEAHnzCD+nYwGpEco9rVS7ZbfprK+UYzxQIOP4cvcPr5qee
20Ufe53Xz1pH6fO1sojmHlXA/Hnu1BZ6o6sbeMOElHmWHYB9A0gPD58ci3HY/iHc
8N2gtl2WotP5IYI6Ip1eEMuOunFcZ1CnhFo1b1HluiesT8RVtt9/tc+eNImB/8lA
/FFGn6/Q78xeS9MbwFEpEXgEaBX3DKEcmfwcvu5fDpc12koYWenLGTor16fepJ5a
rFI64yCxDAg3A0mRdiY6RlgmFvqlm9J3BdO+np/GrE4QkrmE78LuX8mToMwnE8Q9
vZeh731WNv2EzJsewy+ayyh/RIEH5PUAgVt+qLP+AQKBgQDujnQzgoBEgA7XTTiz
w6I90lvFxw3DR7ptc4ai79gz/cTzoJoIGpcjI2cyDpcGL5RuBh06L8Y64NVQxp29
jnGFt9Hil/2ZomYlCnc3OUiaTQZnn98k4jPsLNwB8Ng+AZYHMVuJ2qut2x0KDUz2
e6F/N7h5VGjLFZnM1w80T3TIMwKBgQDRNKLbSPOLxCiNV2OF5KfDbIQt6sWPMnDo
zGyGQgT4WZjLdVX4a3NYKwaHVQq6bASqbr2X1Vdk5zEMzqqTM+AvFA/9OCne1mrH
fPISBimZzLB3iNyu2qTmrJKZcuTV/Bn6lwjbH2sy2Ms1CugQVJDd+LQjJ7/ZaaoM
BmxUj1NfAQKBgQCLjSYI4/SpHciQxom/D1iflak9/33bmOBEGurN8kSl1XQbmP3C
c9uqIJHDxKkwYzEPU+BRI5Vw6AmhoS6xrtxV/vx287bU4x2h2Yd39Li2Gwz+HZXp
+7GoHW3ubLfzPfZH6uXDtPntUFqigLlfD1+gDjaKM4jCFLbOD5jDXx/P5QKBgQCM
BylCkY/Ca2ehQ27v/d50pbvLaCsX7/E3QS0aqDHfcUkeVclXX8RyrUnPZ5KouQhe
c4UfjcLTXROtuN7fbIePu2QAX4lXCDmskOsOOWW69hDf0ZG0z9A0PipZ31dgCz/w
RQu+b0c3X3iUZlpyI8hbas5YAZEeGuWg6uOzrcNmAQKBgAf15LHx7IVEeIfn8ta/
YnZnODsvN5+4ybMIIuX8yAPmXOy3klYvp1+8BH6jZMVHXAqQugooGirfik02RzQv
tCbCXDoTSMzMQU5iSlvnxtcug93rWa4VFvmU9nSqp7lpJQmiOBwWFf2/mmSwyLc9
MvOVZTA46IefrGuG64T83RRf
-----END PRIVATE KEY-----
13 changes: 13 additions & 0 deletions go/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const (
flagRateLimitSignupsInterval = "rate-limit-signups-interval"
flagRateLimitMemcacheServer = "rate-limit-memcache-server"
flagRateLimitMemcachePrefix = "rate-limit-memcache-prefix"
flagTurnstileSecret = "turnstile-secret"
)

func CommandServe() *cli.Command { //nolint:funlen,maintidx
Expand Down Expand Up @@ -554,6 +555,12 @@ func CommandServe() *cli.Command { //nolint:funlen,maintidx
Category: "rate-limit",
EnvVars: []string{"AUTH_RATE_LIMIT_MEMCACHE_PREFIX"},
},
&cli.StringFlag{ //nolint: exhaustruct
Name: flagTurnstileSecret,
Usage: "Turnstile secret. If passed, enable Cloudflare's turnstile for signup methods. The header `X-Cf-Turnstile-Response ` will have to be included in the request for verification",
Category: "turnstile",
EnvVars: []string{"AUTH_TURNSTILE_SECRET"},
},
},
Action: serve,
}
Expand Down Expand Up @@ -653,6 +660,12 @@ func getGoServer( //nolint:funlen
handlers = append(handlers, getRateLimiter(cCtx, logger))
}

if cCtx.String(flagTurnstileSecret) != "" {
handlers = append(handlers, middleware.Tunrstile(
cCtx.String(flagTurnstileSecret), cCtx.String(flagAPIPrefix)),
)
}

router.Use(handlers...)

emailer, err := getEmailer(cCtx, logger)
Expand Down
118 changes: 118 additions & 0 deletions go/middleware/turnstile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package middleware

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"

"github.com/gin-gonic/gin"
)

var ErrTurnstileFailed = errors.New("failed to pass turnstile")

const (
tunrstileURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
)

type TurnstileResponse struct {
Success bool `json:"success"`
ErrorCodes []string `json:"error-codes"`
Messages []string `json:"messages"`
Raw []byte
}

func makeTurnstileRequest(
ctx context.Context,
cl *http.Client,
secret string,
tokenResponse string,
) (*TurnstileResponse, error) {
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
tunrstileURL,
bytes.NewBufferString(`{"secret": "`+secret+`","response":"`+tokenResponse+`"}`),
)
if err != nil {
return nil, fmt.Errorf("failed to create turnstile request: %w", err)
}

request.Header.Set("Content-Type", "application/json")

response, err := cl.Do(request)
if err != nil {
return nil, fmt.Errorf("failed to send turnstile request: %w", err)
}
defer response.Body.Close()

b, err := io.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("failed to read turnstile response: %w", err)
}

var turnstileResponse TurnstileResponse
if err := json.Unmarshal(b, &turnstileResponse); err != nil {
return nil, fmt.Errorf("failed to unmarshal turnstile response: %w", err)
}

turnstileResponse.Raw = b

return &turnstileResponse, nil
}

func Tunrstile(secret string, prefix string) gin.HandlerFunc {
cl := http.Client{} //nolint:exhaustruct

return func(ctx *gin.Context) {
if !strings.HasPrefix(ctx.Request.URL.Path, prefix+"/signup/") ||
strings.HasSuffix(ctx.Request.URL.Path, "/verify") ||
strings.HasSuffix(ctx.Request.URL.Path, "/callback") {
ctx.Next()
return
}

token := ctx.Request.Header.Get("x-cf-turnstile-response")

if token == "" {
_ = ctx.Error(
fmt.Errorf("%w: missing x-cf-turnstile-response header", ErrTurnstileFailed),
)
ctx.AbortWithStatusJSON(
http.StatusForbidden,
gin.H{"error": "missing x-cf-turnstile-response header"},
)
return
}

turnstileResponse, err := makeTurnstileRequest(ctx.Request.Context(), &cl, secret, token)
if err != nil {
_ = ctx.Error(fmt.Errorf("%w: %w", ErrTurnstileFailed, err))
ctx.AbortWithStatusJSON(
http.StatusInternalServerError,
gin.H{"error": "internal server error when attempting to pass turnstile"},
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you missing a return here?

return
}

if !turnstileResponse.Success {
_ = ctx.Error(fmt.Errorf("%w: %s", ErrTurnstileFailed, string(turnstileResponse.Raw)))
ctx.AbortWithStatusJSON(
http.StatusForbidden,
gin.H{
"error": fmt.Sprintf(
"failed to pass turnstile: %v",
turnstileResponse.Messages,
),
},
)
return
}

ctx.Next()
}
}