Browse Source

first commit of PoW captcha rewrite

main
forest 11 months ago
parent
commit
20938d1026
  1. 2
      .dockerignore
  2. 11
      Dockerfile
  3. 12
      README.md
  4. 11
      dockerbuild/Dockerfile-amd64
  5. 11
      dockerbuild/Dockerfile-arm
  6. 11
      dockerbuild/Dockerfile-arm64
  7. 199
      index.js
  8. 2008
      package-lock.json
  9. 1
      package.json
  10. 82
      signup_inner.tmpl

2
.dockerignore

@ -0,0 +1,2 @@
node_modules
npm-debug.log

11
Dockerfile

@ -0,0 +1,11 @@
FROM node:14
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
CMD [ "node", "index.js" ]

12
README.md

@ -6,19 +6,11 @@ It's entirely based on the assumption that spambots just send the form submissio
## Installation
- Run this software
- Tell Nginx to proxy POST to the /user/sign_up path
- Tell Nginx to proxy the /user/sign_up path on the gitea domain to hit this software instead of gitea
```nginx
location /user/sign_up {
if ($request_method = POST ) {
proxy_pass http://localhost:8080; # gitea registration proxy port
}
# else
proxy_pass http://localhost:3000; # original gitea port
proxy_pass http://localhost:8080; # gitea registration proxy port
}
```
- Put the custom `signup_inner.tmpl` template at `custom/templates/user/auth/signup_inner.tmpl`
- Restart Gitea
- ???
- Profit!?!? (without spam)

11
dockerbuild/Dockerfile-amd64

@ -0,0 +1,11 @@
FROM node:14
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
CMD [ "node", "index.js" ]

11
dockerbuild/Dockerfile-arm

@ -0,0 +1,11 @@
FROM node:14
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
CMD [ "node", "index.js" ]

11
dockerbuild/Dockerfile-arm64

@ -0,0 +1,11 @@
FROM node:14
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
CMD [ "node", "index.js" ]

199
index.js

@ -8,85 +8,152 @@ const debug = require("debug")("gitea-registration-proxy");
const express = require("express");
const Router = require("express-promise-router");
const toughCookie = require("tough-cookie");
const syllable = require("syllable");
const JSDOM = require("jsdom").JSDOM;
const setting = {
gitea: "127.0.0.1:3000",
port: 8080,
host: "127.0.0.1"
port: "8080",
host: "127.0.0.1",
powCaptchaPublicURL: "https://captcha.sequentialread.com",
powCaptchaAPIURL: "https://captcha.sequentialread.com",
powCaptchaAPIToken: "",
proofOfWorkDifficultyLevel: "8",
};
Object.keys(setting).forEach(k => {
const valueFromEnv = process.env[`REGPROXY_${k.toUpperCase()}`];
if(valueFromEnv !== undefined) {
setting[k] = valueFromEnv;
}
})
setting.version = require(__dirname + "/package.json").version;
debug("Starting gitea-registration-proxy %s", setting.version);
const app = express();
const captchaClient = bhttp.session({ headers: {"Authorization": `Bearer ${powCaptchaAPIToken}`} });
let captchaChallenges = [];
let isLoadingCaptchaChallenges = false;
const loadChallenges = () => {
isLoadingCaptchaChallenges = true;
return Promise.try(() => captchaClient.get(`${setting.powCaptchaAPIURL}/GetChallenges?difficultyLevel=${proofOfWorkDifficultyLevel}`))
.then(response => {
captchaChallenges = response.json;
isLoadingCaptchaChallenges = false;
});
}
const router = Router();
loadChallenges().then(() => {
app.use(router);
debug("captcha challenges loaded!");
/*
POST /user/sign_up
Form data:
_csrf
user_name
email
password
retype
*haiku*
*/
const app = express();
function splitLines(text) {
return text.split("\n").map((a) => a.trim());
}
const router = Router();
app.use(router);
/*
POST /user/sign_up
Form data:
_csrf
user_name
email
password
retype
challenge
nonce
*/
const getBhttpSessionFromRequest = req => {
let jar = new toughCookie.CookieJar();
let passedCookies = "i_like_gitea, session, lang, _csrf".split(", ");
passedCookies.forEach((cookieKey) => {
if (req.cookies[cookieKey] != undefined) {
jar.setCookie(new toughCookie.Cookie({key: cookieKey, value: req.cookies[cookieKey], secure: false}), setting.gitea);
}
});
return bhttp.session({cookieJar: jar});
};
router.get("/user/sign_up", (req, res) => {
return Promise.try(() => getBhttpSessionFromRequest(req).get(`${setting.gitea}/user/sign_up`))
.then(response => {
function validHaiku(text) {
let lines = splitLines(text);
if (lines.length != 3) {
return false;
} else if (syllable(lines[0]) != 5 || syllable(lines[1]) != 7 || syllable(lines[2]) != 5) {
return false;
} else {
debug("%O", lines);
return true;
}
}
let hasAtLeastOneCaptchaChallengePromise = Promise.resolve();
if(captchaChallenges.length == 0) {
debug("captchaChallenges.length == 0: blocking the request until captcha challenges loaded...");
hasAtLeastOneCaptchaChallengePromise = loadChallenges();
} else if(captchaChallenges.length <= 10 && !isLoadingCaptchaChallenges) {
debug("captchaChallenges.length <= 10: reloading captcha challenges in the background...");
loadChallenges();
}
router.post("/user/sign_up", bodyParser.urlencoded({ extended: false }), cookieParser(), (req, res) => {
const form = req.body;
debug(`Signup request: %s (%s)`, form.user_name, form.email);
if (form.haiku == "" || !validHaiku(form.haiku)) {
res.write("Haiku Validation Error, please try again or contact gitea@cthu.lu\n");
debug("Haiku failed validation:");
splitLines(form.haiku).forEach((line) => {
debug("%d %s", syllable(line), line);
res.write(`${syllable(line)}, ${line}\n`);
});
return res.end();
} else {
return Promise.try(() => {
debug("✅ valid Haiku, forwarding request");
let jar = new toughCookie.CookieJar();
let passedCookies = "i_like_gitea, session, lang, _csrf".split(", ");
passedCookies.forEach((cookieKey) => {
if (req.cookies[cookieKey] != undefined) {
jar.setCookie(new toughCookie.Cookie({key: cookieKey, value: req.cookies[cookieKey], secure: false}), setting.gitea);
}
});
hasAtLeastOneCaptchaChallengePromise.then(() => {
const dom = new JSDOM(response.body);
let passedForm = "_csrf, user_name, email, password, retype".split(", ");
let newForm = {};
passedForm.forEach((field) => {
if (form[field] != undefined) {
newForm[field] = form[field];
}
});
let session = bhttp.session({cookieJar: jar});
return session.post(`${setting.gitea}/user/sign_up`, newForm, {stream: true});
}).then((upstream) => {
upstream.pipe(res);
});
}
});
const captchaScriptElement = dom.window.document.createElement("script");
captchaScriptElement.textContent = `
document.addEventListener('DOMContentLoaded', function {
var submitButton = document.querySelector("form button.green");
submitButton.disabled = "true";
window.powCaptchaCallback = function() {
submitButton.removeAttribute("disabled");
};
});
`;
dom.window.document.head.appendChild(captchaScriptElement);
const captchaElement = dom.window.document.createElement("div");
captchaElement.className = "required inline field";
captchaElement.dataset.sqrCaptchaUrl = setting.powCaptchaPublicURL;
captchaElement.dataset.sqrCaptchaChallenge = captchaChallenges.pop();
captchaElement.dataset.sqrCaptchaCallback = 'powCaptchaCallback';
const allRequiredFields = dom.window.document.querySelectorAll('form .required.field');
const lastRequiredField = allRequiredFields[allRequiredFields.length-1];
lastRequiredField.insertAdjacentElement('afterend', captchaElement);
res.send(dom.serialize());
});
});
});
router.post("/user/sign_up", bodyParser.urlencoded({ extended: false }), cookieParser(), (req, res) => {
const form = req.body;
debug(`Signup request: %s (%s)`, form.user_name, form.email);
return Promise.try(() => captchaClient.post(`${setting.powCaptchaAPIURL}/Verify?challenge=${form.challenge}&nonce=${form.nonce}`))
.then((captchaResponse) => {
if (captchaResponse.statusCode != 200) {
res.write("PoW Captcha Validation Error, please try again or contact gitea@cthu.lu\n");
return res.end();
} else {
return Promise.try(() => {
debug("✅ valid captcha, forwarding request");
let passedForm = "_csrf, user_name, email, password, retype".split(", ");
let newForm = {};
passedForm.forEach((field) => {
if (form[field] != undefined) {
newForm[field] = form[field];
}
});
let session = bhttp.session({cookieJar: jar});
return getBhttpSessionFromRequest(req).post(`${setting.gitea}/user/sign_up`, newForm, {stream: true});
}).then((upstream) => {
upstream.pipe(res);
});
}
});
});
app.listen(Number(setting.port), setting.host);
app.listen(setting.port, setting.host);
})

2008
package-lock.json generated

File diff suppressed because it is too large Load Diff

1
package.json

@ -17,6 +17,7 @@
"eslint": "^7.27.0",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"jsdom": "^17.0.0",
"pretty-bytes": "^5.6.0",
"supports-color": "^9.0.2",
"syllable": "4.0.0",

82
signup_inner.tmpl

@ -1,82 +0,0 @@
<div class="ui container column fluid{{if .LinkAccountMode}} icon{{end}}">
<h4 class="ui top attached header center">
{{if .LinkAccountMode}}
{{.i18n.Tr "auth.oauth_signup_title"}}
{{else}}
{{.i18n.Tr "sign_up"}}
{{end}}
</h4>
<div class="ui attached segment">
<form class="ui form" action="{{.SignUpLink}}" method="post">
{{.CsrfTokenHtml}}
{{if or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeRegister)}}
{{template "base/alert" .}}
{{end}}
{{if .DisableRegistration}}
<p>{{.i18n.Tr "auth.disable_register_prompt"}}</p>
{{else}}
<div class="required inline field {{if and (.Err_UserName) (or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeRegister))}}error{{end}}">
<label for="user_name">{{.i18n.Tr "username"}}</label>
<input id="user_name" type="text" name="user_name" value="{{.user_name}}" autofocus required>
</div>
<div class="required inline field {{if .Err_Email}}error{{end}}">
<label for="email">{{.i18n.Tr "email"}}</label>
<input id="email" name="email" type="email" value="{{.email}}" required>
</div>
<div class="required inline field">
<label for="haiku">Please write a Haiku</label>
<textarea rows="3" id="haiku" name="haiku" value="" required></textarea>
</div>
{{if not .DisablePassword}}
<div class="required inline field {{if and (.Err_Password) (or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeRegister))}}error{{end}}">
<label for="password">{{.i18n.Tr "password"}}</label>
<input id="password" name="password" type="password" value="{{.password}}" autocomplete="new-password" required>
</div>
<div class="required inline field {{if and (.Err_Password) (or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeRegister))}}error{{end}}">
<label for="retype">{{.i18n.Tr "re_type"}}</label>
<input id="retype" name="retype" type="password" value="{{.retype}}" autocomplete="new-password" required>
</div>
{{end}}
{{if and .EnableCaptcha (eq .CaptchaType "image")}}
<div class="inline field">
<label></label>
{{.Captcha.CreateHTML}}
</div>
<div class="required inline field {{if .Err_Captcha}}error{{end}}">
<label for="captcha">{{.i18n.Tr "captcha"}}</label>
<input id="captcha" name="captcha" value="{{.captcha}}" autocomplete="off">
</div>
{{end}}
{{if and .EnableCaptcha (eq .CaptchaType "recaptcha")}}
<div class="inline field required">
<div class="g-recaptcha" data-sitekey="{{ .RecaptchaSitekey }}"></div>
</div>
{{end}}
{{if and .EnableCaptcha (eq .CaptchaType "hcaptcha")}}
<div class="inline field required">
<div class="h-captcha" data-sitekey="{{ .HcaptchaSitekey }}"></div>
</div>
{{end}}
<div class="inline field">
<label></label>
<button class="ui green button">
{{if .LinkAccountMode}}
{{.i18n.Tr "auth.oauth_signup_submit"}}
{{else}}
{{.i18n.Tr "auth.create_new_account"}}
{{end}}
</button>
</div>
{{if not .LinkAccountMode}}
<div class="inline field">
<label></label>
<a href="{{AppSubUrl}}/user/login">{{.i18n.Tr "auth.register_helper_msg"}}</a>
</div>
{{end}}
{{end}}
</form>
</div>
</div>
Loading…
Cancel
Save