Skip to content

Commit 59d6dd7

Browse files
authored
Add captcha for user registration (#1099)
* Add captcha for user registration.
1 parent 543b817 commit 59d6dd7

File tree

6 files changed

+112
-19
lines changed

6 files changed

+112
-19
lines changed

.github/workflows/cabal-mtl-2.3.yml

+4-5
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,10 @@ jobs:
3030
echo "GHC_VER=${GHC_VER}" >> "${GITHUB_ENV}"
3131
echo "CABAL_VER=${CABAL_VER}" >> "${GITHUB_ENV}"
3232
33-
# Brotli is already installed on ubuntu-22.04
34-
# - name: Install the brotli library
35-
# run: |
36-
# sudo apt-get update
37-
# sudo apt-get install -y libbrotli-dev
33+
- name: Install necessary deps
34+
run: |
35+
sudo apt-get update
36+
sudo apt-get install -y libgd-dev libpng-dev libjpeg-dev libfontconfig-dev libfreetype-dev libexpat1-dev
3837
3938
- uses: actions/checkout@v3
4039

.github/workflows/haskell-ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ jobs:
7171
"$HOME/.ghcup/bin/ghcup" install ghc "$HCVER" || (cat "$HOME"/.ghcup/logs/*.* && false)
7272
"$HOME/.ghcup/bin/ghcup" install cabal 3.6.2.0 || (cat "$HOME"/.ghcup/logs/*.* && false)
7373
apt-get update
74-
apt-get install -y libbrotli-dev
74+
apt-get install -y libbrotli-dev libgd-dev libpng-dev libjpeg-dev libfontconfig-dev libfreetype-dev libexpat1-dev
7575
env:
7676
HCKIND: ${{ matrix.compilerKind }}
7777
HCNAME: ${{ matrix.compiler }}

datafiles/templates/UserSignupReset/SignupRequest.html.st

+31
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ In particular you need an account to be able to upload or help maintain packages
3333
<td><label for="email">Your email address</label>
3434
<td><input type="text" name="email" id="email">
3535
<td>e.g. jnovak@example.com (but do <b>not</b> use the style "Jan Novák" &lt;jnovak@example.com&gt;)
36+
37+
<tr>
38+
<td><label for="captcha">Captcha</label>
39+
<td><input type="text" name="captcha" id="captcha">
40+
<td><a href="javascript:changeCaptcha();"><img src="$base64image$" alt="captcha" id="image"/></a>
41+
The captcha will expire in 10 minutes. Click on the image to change one.
42+
<input type="hidden" name="timestamp" id="timestamp" value="$timestamp$">
43+
<input type="hidden" name="hash" id="hash" value="$hash$">
3644
</table>
3745

3846
<p>Your email address will be used to confirm your account (and if you ever
@@ -48,4 +56,27 @@ can set your password and activate your account.
4856
</form>
4957

5058
</div>
59+
<script type="text/javascript">
60+
var image = document.getElementById("image");
61+
var timestamp = document.getElementById("timestamp");
62+
var hash = document.getElementById("hash");
63+
64+
function changeCaptcha() {
65+
var xmlHttp = new XMLHttpRequest();
66+
xmlHttp.onreadystatechange = function() {
67+
if (xmlHttp.readyState == 4 && xmlHttp.status == 200) {
68+
var res = JSON.parse(xmlHttp.responseText);
69+
if (typeof res == "object" && typeof res.timestamp == "string" && typeof res.hash == "string" && typeof res.base64image == "string") {
70+
image.setAttribute("src", res.base64image);
71+
timestamp.setAttribute("value", res.timestamp);
72+
hash.setAttribute("value", res.hash);
73+
} else {
74+
console.error("Invalid response from /users/register/captcha");
75+
}
76+
}
77+
}
78+
xmlHttp.open("GET", "/users/register/captcha", true);
79+
xmlHttp.send(null);
80+
}
81+
</script>
5182
</body></html>

hackage-server.cabal

+1
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@ library lib-server
410410
-- see https://github.com/haskell/hackage-server/issues/1128
411411
, happstack-server ^>= 7.7.1 || ^>= 7.8.0
412412
, hashable ^>= 1.3 || ^>= 1.4
413+
, hs-captcha ^>= 1.0
413414
, hslogger ^>= 1.3.1
414415
, lifted-base ^>= 0.2.1
415416
, mime-mail ^>= 0.5

shell.nix

+12-3
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ let
77

88
pkgs = import nixpkgs { config = { }; };
99

10-
in
11-
pkgs.mkShell {
12-
buildInputs = with pkgs; [
10+
in
11+
with pkgs; pkgs.mkShell rec {
12+
buildInputs = [
1313
# Haskell development
1414
cabal-install
1515
ghc
@@ -22,5 +22,14 @@ pkgs.mkShell {
2222
cryptodev
2323
pkg-config
2424
brotli
25+
26+
gd
27+
libpng
28+
libjpeg
29+
fontconfig
30+
freetype
31+
expat
2532
];
33+
34+
# LD_LIBRARY_PATH = lib.makeLibraryPath buildInputs;
2635
}

src/Distribution/Server/Features/UserSignup.hs

+63-10
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,28 @@ import Data.Map (Map)
2828
import qualified Data.Map as Map
2929
import Data.Text (Text)
3030
import qualified Data.Text as T
31+
import qualified Data.Text.Encoding as T
3132
import qualified Data.ByteString.Char8 as BS -- Only used for ASCII data
32-
33+
import qualified Data.ByteString.Lazy as BSL
3334
import Data.Typeable (Typeable)
3435
import Control.Monad.Reader (ask)
3536
import Control.Monad.State (get, put, modify)
3637
import Data.SafeCopy (base, deriveSafeCopy)
3738

3839
import Distribution.Text (display)
39-
import Data.Time (UTCTime(..), getCurrentTime, addDays)
40+
import Data.Time
4041
import Text.CSV (CSV, Record)
4142
import Network.Mail.Mime
4243
import Network.URI (URI(..), URIAuth(..))
44+
import Graphics.Captcha
45+
import qualified Data.ByteString.Base64 as Base64
46+
import qualified Crypto.Hash.SHA256 as SHA256
47+
import Data.String
48+
import Data.Char
49+
import Text.Read (readMaybe)
50+
import Data.Aeson
51+
import qualified Data.Aeson.KeyMap as KeyMap
52+
import qualified Data.Aeson.Key as Key
4353

4454

4555
-- | A feature to allow open account signup, and password reset,
@@ -306,6 +316,7 @@ userSignupFeature ServerEnv{serverBaseURI, serverCron}
306316
userSignupFeatureInterface = (emptyHackageFeature "user-signup-reset") {
307317
featureDesc = "Extra information about user accounts, email addresses etc."
308318
, featureResources = [signupRequestsResource,
319+
captchaResource,
309320
signupRequestResource,
310321
resetRequestsResource,
311322
resetRequestResource]
@@ -325,6 +336,12 @@ userSignupFeature ServerEnv{serverBaseURI, serverCron}
325336
, resourceGet = [ ("", handlerGetSignupRequestNew) ]
326337
, resourcePost = [ ("", handlerPostSignupRequestNew) ]
327338
}
339+
340+
captchaResource =
341+
(resourceAt "/users/register/captcha") {
342+
resourceDesc = [ (GET, "Get a new captcha") ]
343+
, resourceGet = [ ("json", handlerGetCaptcha) ]
344+
}
328345

329346
signupRequestResource =
330347
(resourceAt "/users/register-request/:nonce") {
@@ -413,20 +430,44 @@ userSignupFeature ServerEnv{serverBaseURI, serverCron}
413430
[MText $ "The " ++ thing ++ " token does not exist. It could be that it "
414431
++ "has been used already, or that it has expired."]
415432

433+
hashTimeAndCaptcha :: UTCTime -> String -> BS.ByteString
434+
hashTimeAndCaptcha timestamp captcha = Base64.encode (SHA256.hash (fromString (show timestamp ++ map toUpper captcha)))
435+
436+
makeCaptchaHash :: IO (UTCTime, BS.ByteString, BS.ByteString)
437+
makeCaptchaHash = do
438+
(code, image) <- makeCaptcha
439+
timestamp <- getCurrentTime
440+
pure (timestamp, hashTimeAndCaptcha timestamp code, fromString "data:image/png;base64," <> Base64.encode image)
441+
416442
handlerGetSignupRequestNew :: DynamicPath -> ServerPartE Response
417443
handlerGetSignupRequestNew _ = do
444+
(timestamp, hash, base64image) <- liftIO makeCaptchaHash
418445
template <- getTemplate templates "SignupRequest.html"
419-
ok $ toResponse $ template []
446+
ok $ toResponse $ template
447+
[ "timestamp" $= timestamp
448+
, "hash" $= hash
449+
, "base64image" $= base64image
450+
]
451+
452+
handlerGetCaptcha :: DynamicPath -> ServerPartE Response
453+
handlerGetCaptcha _ = do
454+
(timestamp, hash, base64image) <- liftIO makeCaptchaHash
455+
ok $ toResponse $ Object $ KeyMap.fromList $
456+
[ (Key.fromString "timestamp" , String (T.pack (show timestamp)))
457+
, (Key.fromString "hash" , String (T.decodeUtf8 hash))
458+
, (Key.fromString "base64image", String (T.decodeUtf8 base64image))
459+
]
420460

421461
handlerPostSignupRequestNew :: DynamicPath -> ServerPartE Response
422462
handlerPostSignupRequestNew _ = do
423463
templateEmail <- getTemplate templates "SignupConfirmation.email"
424464
templateConfirmation <- getTemplate templates "SignupEmailSent.html"
425465

426-
(username, realname, useremail) <- lookUserNameEmail
466+
timestamp <- liftIO getCurrentTime
467+
468+
(username, realname, useremail) <- lookValidFields timestamp
427469

428470
nonce <- liftIO (newRandomNonce 10)
429-
timestamp <- liftIO getCurrentTime
430471
let signupInfo = SignupInfo {
431472
signupUserName = username,
432473
signupRealName = realname,
@@ -462,17 +503,29 @@ userSignupFeature ServerEnv{serverBaseURI, serverCron}
462503
templateConfirmation
463504
[ "useremail" $= useremail ]
464505
where
465-
lookUserNameEmail = do
466-
(username, realname, useremail) <-
467-
msum [ body $ (,,) <$> lookText' "username"
468-
<*> lookText' "realname"
469-
<*> lookText' "email"
506+
lookValidFields now = do
507+
(username, realname, useremail, captcha, timestampStr, hash) <-
508+
msum [ body $ (,,,,,) <$> lookText' "username"
509+
<*> lookText' "realname"
510+
<*> lookText' "email"
511+
<*> look "captcha"
512+
<*> look "timestamp"
513+
<*> lookBS "hash"
470514
, errBadRequest "Missing form fields" [] ]
471515

472516
guardValidLookingUserName username
473517
guardValidLookingName realname
474518
guardValidLookingEmail useremail
475519

520+
timestamp <- maybe (errBadRequest "Invalid request" [MText "Seems something went wrong with your request."])
521+
pure (readMaybe timestampStr)
522+
523+
when (diffUTCTime now timestamp > secondsToNominalDiffTime (10 * 60)) $
524+
errBadRequest "Problem with captcha" [MText "Oops, The captcha has expired. Please be quick next time!"]
525+
526+
unless (hashTimeAndCaptcha timestamp captcha == BSL.toStrict hash) $
527+
errBadRequest "Problem with captcha" [MText "Sorry, the captcha is wrong. Please try sign up again."]
528+
476529
return (username, realname, useremail)
477530

478531
handlerGetSignupRequestOutstanding :: DynamicPath -> ServerPartE Response

0 commit comments

Comments
 (0)