From: Dessalines <dessalines@users.noreply.github.com>
Date: Wed, 29 Jul 2020 13:02:46 +0000 (-0400)
Subject: Adding visual captchas for register and login. (#1027)
X-Git-Url: http://these/git/%24%7Bsubmission.url%7D?a=commitdiff_plain;h=49bd28e2d498f75ca9eb186d7092f1f6a0441ce7;p=lemmy.git

Adding visual captchas for register and login. (#1027)

* Adding visual captchas for register and login.

* Adding audio wav file for Captcha using espeak.

* Lots of captcha fixes.

- Removed login captchas.
- Added settings to disable captchas, and change difficulty.
- Captchas can only be checked / used once, front end gives a new one on
  failure.
- Added front end button for regenerating captcha.
- Added a disabled / pause button audio playing.

* Some more fixes.
---

diff --git a/docker/dev/Dockerfile b/docker/dev/Dockerfile
index b86618d8..afbdbbbe 100644
--- a/docker/dev/Dockerfile
+++ b/docker/dev/Dockerfile
@@ -41,6 +41,9 @@ FROM alpine:3.12
 # Install libpq for postgres
 RUN apk add libpq
 
+# Install Espeak for captchas
+RUN apk add espeak
+
 # Copy resources
 COPY server/config/defaults.hjson /config/defaults.hjson
 COPY --from=rust /app/server/target/x86_64-unknown-linux-musl/debug/lemmy_server /app/lemmy
diff --git a/docker/prod/Dockerfile b/docker/prod/Dockerfile
index 77438740..845df88d 100644
--- a/docker/prod/Dockerfile
+++ b/docker/prod/Dockerfile
@@ -50,6 +50,10 @@ FROM alpine:3.12 as lemmy
 
 # Install libpq for postgres
 RUN apk add libpq
+
+# Install Espeak for captchas
+RUN apk add espeak
+
 RUN addgroup -g 1000 lemmy
 RUN adduser -D -s /bin/sh -u 1000 -G lemmy lemmy
 
diff --git a/docs/src/contributing_websocket_http_api.md b/docs/src/contributing_websocket_http_api.md
index 1a758804..7953bc9a 100644
--- a/docs/src/contributing_websocket_http_api.md
+++ b/docs/src/contributing_websocket_http_api.md
@@ -390,7 +390,9 @@ Only the first user will be able to be the admin.
     email: Option<String>,
     password: String,
     password_verify: String,
-    admin: bool
+    admin: bool,
+    captcha_uuid: Option<String>, // Only checked if these are enabled in the server
+    captcha_answer: Option<String>,
   }
 }
 ```
@@ -408,6 +410,34 @@ Only the first user will be able to be the admin.
 
 `POST /user/register`
 
+#### Get Captcha
+
+These expire after 10 minutes.
+
+##### Request
+```rust
+{
+  op: "GetCaptcha",
+}
+```
+##### Response
+```rust
+{
+  op: "GetCaptcha",
+  data: {
+    ok?: { // Will be undefined if captchas are disabled
+      png: String, // A Base64 encoded png
+      wav: Option<String>, // A Base64 encoded wav audio file
+      uuid: String,
+    }
+  }
+}
+```
+
+##### HTTP
+
+`GET /user/get_captcha`
+
 #### Get User Details
 ##### Request
 ```rust
diff --git a/server/Cargo.lock b/server/Cargo.lock
index 2054c7a3..3f19827b 100644
--- a/server/Cargo.lock
+++ b/server/Cargo.lock
@@ -31,7 +31,7 @@ checksum = "a9028932f36d45df020c92317ccb879ab77d8f066f57ff143dd5bee93ba3de0d"
 dependencies = [
  "actix-rt",
  "actix_derive",
- "bitflags",
+ "bitflags 1.2.1",
  "bytes",
  "crossbeam-channel",
  "derive_more",
@@ -54,7 +54,7 @@ version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "09e55f0a5c2ca15795035d90c46bd0e73a5123b72f68f12596d6ba5282051380"
 dependencies = [
- "bitflags",
+ "bitflags 1.2.1",
  "bytes",
  "futures-core",
  "futures-sink",
@@ -94,7 +94,7 @@ dependencies = [
  "actix-http",
  "actix-service",
  "actix-web",
- "bitflags",
+ "bitflags 1.2.1",
  "bytes",
  "derive_more",
  "futures-core",
@@ -120,7 +120,7 @@ dependencies = [
  "actix-tls",
  "actix-utils",
  "base64 0.12.3",
- "bitflags",
+ "bitflags 1.2.1",
  "brotli2",
  "bytes",
  "cookie",
@@ -280,7 +280,7 @@ dependencies = [
  "actix-codec",
  "actix-rt",
  "actix-service",
- "bitflags",
+ "bitflags 1.2.1",
  "bytes",
  "either",
  "futures",
@@ -382,6 +382,12 @@ version = "0.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ee2a4ec343196209d6594e19543ae87a39f96d5534d7174822a3ad825dd6ed7e"
 
+[[package]]
+name = "adler32"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "567b077b825e468cc974f0020d4082ee6e03132512f207ef1a02fd5d00d1f32d"
+
 [[package]]
 name = "aho-corasick"
 version = "0.7.13"
@@ -496,6 +502,15 @@ version = "0.2.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1b20b618342cf9891c292c4f5ac2cde7287cc5c87e87e9c769d617793607dec1"
 
+[[package]]
+name = "base64"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30e93c03064e7590d0466209155251b90c22e37fab1daf2771582598b5827557"
+dependencies = [
+ "byteorder",
+]
+
 [[package]]
 name = "base64"
 version = "0.9.3"
@@ -539,6 +554,12 @@ dependencies = [
  "getrandom",
 ]
 
+[[package]]
+name = "bitflags"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aad18937a628ec6abcd26d1489012cc0e18c21798210f491af69ded9b881106d"
+
 [[package]]
 name = "bitflags"
 version = "1.2.1"
@@ -642,6 +663,12 @@ version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7"
 
+[[package]]
+name = "bytemuck"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db7a1029718df60331e557c9e83a55523c955e5dd2a7bfeffad6bbd50b538ae9"
+
 [[package]]
 name = "byteorder"
 version = "1.3.4"
@@ -663,6 +690,26 @@ dependencies = [
  "bytes",
 ]
 
+[[package]]
+name = "c_vec"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8a318911dce53b5f1ca6539c44f5342c632269f0fa7ea3e35f32458c27a7c30"
+
+[[package]]
+name = "captcha"
+version = "0.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d060a3be43adb2fe89d3448e9a193149806139b1ce99281865fcab7aeaf04ed"
+dependencies = [
+ "base64 0.5.2",
+ "image",
+ "lodepng",
+ "rand 0.3.23",
+ "serde_json",
+ "time 0.1.43",
+]
+
 [[package]]
 name = "cc"
 version = "1.0.58"
@@ -695,7 +742,7 @@ checksum = "bdfa80d47f954d53a35a64987ca1422f495b8d6483c0fe9f7117b36c2a792129"
 dependencies = [
  "ansi_term",
  "atty",
- "bitflags",
+ "bitflags 1.2.1",
  "strsim 0.8.0",
  "textwrap",
  "unicode-width",
@@ -708,7 +755,7 @@ version = "0.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f"
 dependencies = [
- "bitflags",
+ "bitflags 1.2.1",
 ]
 
 [[package]]
@@ -717,9 +764,15 @@ version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4344512281c643ae7638bbabc3af17a11307803ec8f0fcad9fae512a8bf36467"
 dependencies = [
- "bitflags",
+ "bitflags 1.2.1",
 ]
 
+[[package]]
+name = "color_quant"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dbbb57365263e881e805dc77d94697c9118fd94d8da011240555aa7b23445bd"
+
 [[package]]
 name = "comrak"
 version = "0.7.0"
@@ -806,6 +859,43 @@ dependencies = [
  "crossbeam-utils",
 ]
 
+[[package]]
+name = "crossbeam-deque"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f02af974daeee82218205558e51ec8768b48cf524bd01d550abe5573a608285"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "maybe-uninit",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "058ed274caafc1f60c4997b5fc07bf7dc7cca454af7c6e81edffe5f33f70dace"
+dependencies = [
+ "autocfg 1.0.0",
+ "cfg-if",
+ "crossbeam-utils",
+ "lazy_static",
+ "maybe-uninit",
+ "memoffset",
+ "scopeguard",
+]
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "774ba60a54c213d409d5353bda12d49cd68d14e45036a285234c8d6f91f92570"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "maybe-uninit",
+]
+
 [[package]]
 name = "crossbeam-utils"
 version = "0.7.2"
@@ -852,6 +942,16 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "deflate"
+version = "0.7.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "707b6a7b384888a70c8d2e8650b3e60170dfc6a67bb4aa67b6dfca57af4bedb4"
+dependencies = [
+ "adler32",
+ "byteorder",
+]
+
 [[package]]
 name = "derive_builder"
 version = "0.9.0"
@@ -894,7 +994,7 @@ version = "1.4.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3e2de9deab977a153492a1468d1b1c0662c1cf39e5ea87d0c060ecd59ef18d8c"
 dependencies = [
- "bitflags",
+ "bitflags 1.2.1",
  "byteorder",
  "chrono",
  "diesel_derives",
@@ -1072,6 +1172,15 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "enum_primitive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be4551092f4d519593039259a9ed8daedf0da12e5109c5280338073eaeb81180"
+dependencies = [
+ "num-traits 0.1.43",
+]
+
 [[package]]
 name = "env_logger"
 version = "0.7.1"
@@ -1167,7 +1276,7 @@ version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82"
 dependencies = [
- "bitflags",
+ "bitflags 1.2.1",
  "fuchsia-zircon-sys",
 ]
 
@@ -1311,6 +1420,16 @@ dependencies = [
  "wasi",
 ]
 
+[[package]]
+name = "gif"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2e41945ba23db3bf51b24756d73d81acb4f28d85c3dccc32c6fae904438c25f"
+dependencies = [
+ "color_quant",
+ "lzw",
+]
+
 [[package]]
 name = "gimli"
 version = "0.22.0"
@@ -1455,6 +1574,23 @@ dependencies = [
  "unicode-normalization",
 ]
 
+[[package]]
+name = "image"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c3f4f5ea213ed9899eca760a8a14091d4b82d33e27cf8ced336ff730e9f6da8"
+dependencies = [
+ "byteorder",
+ "enum_primitive",
+ "gif",
+ "jpeg-decoder",
+ "num-iter",
+ "num-rational",
+ "num-traits 0.1.43",
+ "png",
+ "scoped_threadpool",
+]
+
 [[package]]
 name = "indexmap"
 version = "1.5.0"
@@ -1465,6 +1601,12 @@ dependencies = [
  "hashbrown",
 ]
 
+[[package]]
+name = "inflate"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1238524675af3938a7c74980899535854b88ba07907bb1c944abe5b8fc437e5"
+
 [[package]]
 name = "instant"
 version = "0.1.6"
@@ -1507,6 +1649,16 @@ version = "0.4.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dc6f3ad7b9d11a0c00842ff8de1b60ee58661048eb8049ed33c73594f359d7e6"
 
+[[package]]
+name = "jpeg-decoder"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc797adac5f083b8ff0ca6f6294a999393d76e197c36488e2ef732c4715f6fa3"
+dependencies = [
+ "byteorder",
+ "rayon",
+]
+
 [[package]]
 name = "js-sys"
 version = "0.3.42"
@@ -1585,6 +1737,7 @@ dependencies = [
  "awc",
  "base64 0.12.3",
  "bcrypt",
+ "captcha",
  "chrono",
  "diesel",
  "diesel_migrations",
@@ -1673,7 +1826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "db65c6da02e61f55dae90a0ae427b2a5f6b3e8db09f58d10efab23af92592616"
 dependencies = [
  "arrayvec",
- "bitflags",
+ "bitflags 1.2.1",
  "cfg-if",
  "ryu",
  "static_assertions",
@@ -1719,6 +1872,18 @@ dependencies = [
  "scopeguard",
 ]
 
+[[package]]
+name = "lodepng"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ac1dfdf85b7d5dea61a620e12c051a72078189366a0b3c0ab331e30847def2f"
+dependencies = [
+ "c_vec",
+ "cc",
+ "libc",
+ "rgb",
+]
+
 [[package]]
 name = "log"
 version = "0.4.11"
@@ -1737,6 +1902,12 @@ dependencies = [
  "linked-hash-map 0.5.3",
 ]
 
+[[package]]
+name = "lzw"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d947cbb889ed21c2a84be6ffbaebf5b4e0f4340638cba0444907e38b56be084"
+
 [[package]]
 name = "maplit"
 version = "1.0.2"
@@ -1755,12 +1926,27 @@ version = "0.1.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08"
 
+[[package]]
+name = "maybe-uninit"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00"
+
 [[package]]
 name = "memchr"
 version = "2.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400"
 
+[[package]]
+name = "memoffset"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c198b026e1bbf08a937e94c6c60f9ec4a2267f5b0d2eec9c1b21b061ce2be55f"
+dependencies = [
+ "autocfg 1.0.0",
+]
+
 [[package]]
 name = "migrations_internals"
 version = "1.4.1"
@@ -1920,6 +2106,27 @@ dependencies = [
  "num-traits 0.2.12",
 ]
 
+[[package]]
+name = "num-iter"
+version = "0.1.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a6e6b7c748f995c4c29c5f5ae0248536e04a5739927c74ec0fa564805094b9f"
+dependencies = [
+ "autocfg 1.0.0",
+ "num-integer",
+ "num-traits 0.2.12",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.1.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee314c74bd753fc86b4780aa9475da469155f3848473a261d2d18e35245a784e"
+dependencies = [
+ "num-integer",
+ "num-traits 0.2.12",
+]
+
 [[package]]
 name = "num-traits"
 version = "0.1.43"
@@ -1978,7 +2185,7 @@ version = "0.10.30"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8d575eff3665419f9b83678ff2815858ad9d11567e082f5ac1814baba4e2bcb4"
 dependencies = [
- "bitflags",
+ "bitflags 1.2.1",
  "cfg-if",
  "foreign-types",
  "lazy_static",
@@ -2153,6 +2360,18 @@ version = "0.3.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d36492546b6af1463394d46f0c834346f31548646f6ba10849802c9c9a27ac33"
 
+[[package]]
+name = "png"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48f397b84083c2753ba53c7b56ad023edb94512b2885ffe227c66ff7edb61868"
+dependencies = [
+ "bitflags 0.7.0",
+ "deflate",
+ "inflate",
+ "num-iter",
+]
+
 [[package]]
 name = "ppv-lite86"
 version = "0.2.8"
@@ -2225,6 +2444,16 @@ dependencies = [
  "scheduled-thread-pool",
 ]
 
+[[package]]
+name = "rand"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64ac302d8f83c0c1974bf758f6b041c6c8ada916fbb44a609158ca8b064cc76c"
+dependencies = [
+ "libc",
+ "rand 0.4.6",
+]
+
 [[package]]
 name = "rand"
 version = "0.4.6"
@@ -2385,6 +2614,31 @@ dependencies = [
  "rand_core 0.3.1",
 ]
 
+[[package]]
+name = "rayon"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62f02856753d04e03e26929f820d0a0a337ebe71f849801eea335d464b349080"
+dependencies = [
+ "autocfg 1.0.0",
+ "crossbeam-deque",
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e92e15d89083484e11353891f1af602cc661426deb9564c298b270c726973280"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-queue",
+ "crossbeam-utils",
+ "lazy_static",
+ "num_cpus",
+]
+
 [[package]]
 name = "rdrand"
 version = "0.4.0"
@@ -2437,6 +2691,15 @@ dependencies = [
  "quick-error",
 ]
 
+[[package]]
+name = "rgb"
+version = "0.8.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90ef54b45ae131327a88597e2463fee4098ad6c88ba7b6af4b3987db8aad4098"
+dependencies = [
+ "bytemuck",
+]
+
 [[package]]
 name = "ring"
 version = "0.16.15"
@@ -2521,6 +2784,12 @@ dependencies = [
  "parking_lot 0.11.0",
 ]
 
+[[package]]
+name = "scoped_threadpool"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8"
+
 [[package]]
 name = "scopeguard"
 version = "1.1.0"
@@ -2543,7 +2812,7 @@ version = "0.4.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "64808902d7d99f78eaddd2b4e2509713babc3dc3c85ad6f4c447680f3c01e535"
 dependencies = [
- "bitflags",
+ "bitflags 1.2.1",
  "core-foundation",
  "core-foundation-sys",
  "libc",
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 356cbced..3a652f29 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -51,3 +51,4 @@ itertools = "0.9.0"
 uuid = { version = "0.8", features = ["serde", "v4"] }
 sha2 = "0.9"
 async-trait = "0.1.36"
+captcha = "0.0.7"
diff --git a/server/config/defaults.hjson b/server/config/defaults.hjson
index 348368f1..5238455a 100644
--- a/server/config/defaults.hjson
+++ b/server/config/defaults.hjson
@@ -59,6 +59,10 @@
     # comma seperated list of instances with which federation is allowed
     allowed_instances: ""
   }
+  captcha: {
+    enabled: true
+    difficulty: medium # Can be easy, medium, or hard
+  }
 #  # email sending configuration
 #  email: {
 #    # hostname and port of the smtp server
diff --git a/server/lemmy_utils/src/settings.rs b/server/lemmy_utils/src/settings.rs
index 10291477..b7cc2c45 100644
--- a/server/lemmy_utils/src/settings.rs
+++ b/server/lemmy_utils/src/settings.rs
@@ -17,6 +17,7 @@ pub struct Settings {
   pub rate_limit: RateLimitConfig,
   pub email: Option<EmailConfig>,
   pub federation: Federation,
+  pub captcha: CaptchaConfig,
 }
 
 #[derive(Debug, Deserialize, Clone)]
@@ -46,6 +47,12 @@ pub struct EmailConfig {
   pub use_tls: bool,
 }
 
+#[derive(Debug, Deserialize, Clone)]
+pub struct CaptchaConfig {
+  pub enabled: bool,
+  pub difficulty: String, // easy, medium, or hard
+}
+
 #[derive(Debug, Deserialize, Clone)]
 pub struct Database {
   pub user: String,
diff --git a/server/src/api/site.rs b/server/src/api/site.rs
index 3b8b9693..adade080 100644
--- a/server/src/api/site.rs
+++ b/server/src/api/site.rs
@@ -370,6 +370,8 @@ impl Perform for Oper<GetSite> {
         password_verify: setup.admin_password.to_owned(),
         admin: true,
         show_nsfw: true,
+        captcha_uuid: None,
+        captcha_answer: None,
       };
       let login_response = Oper::new(register, self.client.clone())
         .perform(pool, websocket_info.clone())
diff --git a/server/src/api/user.rs b/server/src/api/user.rs
index f6548f8c..c2b6955b 100644
--- a/server/src/api/user.rs
+++ b/server/src/api/user.rs
@@ -2,8 +2,9 @@ use crate::{
   api::{claims::Claims, is_admin, APIError, Oper, Perform},
   apub::ApubObjectType,
   blocking,
+  captcha_espeak_wav_base64,
   websocket::{
-    server::{JoinUserRoom, SendAllMessage, SendUserRoomMessage},
+    server::{CaptchaItem, CheckCaptcha, JoinUserRoom, SendAllMessage, SendUserRoomMessage},
     UserOperation,
     WebsocketInfo,
   },
@@ -11,6 +12,8 @@ use crate::{
   LemmyError,
 };
 use bcrypt::verify;
+use captcha::{gen, Difficulty};
+use chrono::Duration;
 use lemmy_db::{
   comment::*,
   comment_view::*,
@@ -66,6 +69,23 @@ pub struct Register {
   pub password_verify: String,
   pub admin: bool,
   pub show_nsfw: bool,
+  pub captcha_uuid: Option<String>,
+  pub captcha_answer: Option<String>,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct GetCaptcha {}
+
+#[derive(Serialize, Deserialize)]
+pub struct GetCaptchaResponse {
+  ok: Option<CaptchaResponse>,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct CaptchaResponse {
+  png: String,         // A Base64 encoded png
+  wav: Option<String>, // A Base64 encoded wav audio
+  uuid: String,
 }
 
 #[derive(Serialize, Deserialize)]
@@ -303,7 +323,7 @@ impl Perform for Oper<Register> {
   async fn perform(
     &self,
     pool: &DbPool,
-    _websocket_info: Option<WebsocketInfo>,
+    websocket_info: Option<WebsocketInfo>,
   ) -> Result<LoginResponse, LemmyError> {
     let data: &Register = &self.data;
 
@@ -320,6 +340,31 @@ impl Perform for Oper<Register> {
       return Err(APIError::err("passwords_dont_match").into());
     }
 
+    // If its not the admin, check the captcha
+    if !data.admin && Settings::get().captcha.enabled {
+      match websocket_info {
+        Some(ws) => {
+          let check = ws
+            .chatserver
+            .send(CheckCaptcha {
+              uuid: data
+                .captcha_uuid
+                .to_owned()
+                .unwrap_or_else(|| "".to_string()),
+              answer: data
+                .captcha_answer
+                .to_owned()
+                .unwrap_or_else(|| "".to_string()),
+            })
+            .await?;
+          if !check {
+            return Err(APIError::err("captcha_incorrect").into());
+          }
+        }
+        None => return Err(APIError::err("captcha_incorrect").into()),
+      };
+    }
+
     if let Err(slurs) = slur_check(&data.username) {
       return Err(APIError::err(&slurs_vec_to_str(slurs)).into());
     }
@@ -439,6 +484,54 @@ impl Perform for Oper<Register> {
   }
 }
 
+#[async_trait::async_trait(?Send)]
+impl Perform for Oper<GetCaptcha> {
+  type Response = GetCaptchaResponse;
+
+  async fn perform(
+    &self,
+    _pool: &DbPool,
+    websocket_info: Option<WebsocketInfo>,
+  ) -> Result<Self::Response, LemmyError> {
+    let captcha_settings = Settings::get().captcha;
+
+    if !captcha_settings.enabled {
+      return Ok(GetCaptchaResponse { ok: None });
+    }
+
+    let captcha = match captcha_settings.difficulty.as_str() {
+      "easy" => gen(Difficulty::Easy),
+      "medium" => gen(Difficulty::Medium),
+      "hard" => gen(Difficulty::Hard),
+      _ => gen(Difficulty::Medium),
+    };
+
+    let answer = captcha.chars_as_string();
+
+    let png_byte_array = captcha.as_png().expect("failed to generate captcha");
+
+    let png = base64::encode(png_byte_array);
+
+    let uuid = uuid::Uuid::new_v4().to_string();
+
+    let wav = captcha_espeak_wav_base64(&answer).ok();
+
+    let captcha_item = CaptchaItem {
+      answer,
+      uuid: uuid.to_owned(),
+      expires: naive_now() + Duration::minutes(10), // expires in 10 minutes
+    };
+
+    if let Some(ws) = websocket_info {
+      ws.chatserver.do_send(captcha_item);
+    }
+
+    Ok(GetCaptchaResponse {
+      ok: Some(CaptchaResponse { png, uuid, wav }),
+    })
+  }
+}
+
 #[async_trait::async_trait(?Send)]
 impl Perform for Oper<SaveUserSettings> {
   type Response = LoginResponse;
diff --git a/server/src/lib.rs b/server/src/lib.rs
index 5dff2ccb..682efc77 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -7,7 +7,9 @@ pub extern crate lazy_static;
 pub extern crate failure;
 pub extern crate actix;
 pub extern crate actix_web;
+pub extern crate base64;
 pub extern crate bcrypt;
+pub extern crate captcha;
 pub extern crate chrono;
 pub extern crate diesel;
 pub extern crate dotenv;
@@ -35,6 +37,7 @@ use lemmy_utils::{get_apub_protocol_string, settings::Settings};
 use log::error;
 use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
 use serde::Deserialize;
+use std::process::Command;
 
 pub type DbPool = diesel::r2d2::Pool<diesel::r2d2::ConnectionManager<diesel::PgConnection>>;
 pub type ConnectionId = usize;
@@ -224,9 +227,56 @@ where
   Ok(res)
 }
 
+pub fn captcha_espeak_wav_base64(captcha: &str) -> Result<String, LemmyError> {
+  let mut built_text = String::new();
+
+  // Building proper speech text for espeak
+  for mut c in captcha.chars() {
+    let new_str = if c.is_alphabetic() {
+      if c.is_lowercase() {
+        c.make_ascii_uppercase();
+        format!("lower case {} ... ", c)
+      } else {
+        c.make_ascii_uppercase();
+        format!("capital {} ... ", c)
+      }
+    } else {
+      format!("{} ...", c)
+    };
+
+    built_text.push_str(&new_str);
+  }
+
+  espeak_wav_base64(&built_text)
+}
+
+pub fn espeak_wav_base64(text: &str) -> Result<String, LemmyError> {
+  // Make a temp file path
+  let uuid = uuid::Uuid::new_v4().to_string();
+  let file_path = format!("/tmp/lemmy_espeak_{}.wav", &uuid);
+
+  // Write the wav file
+  Command::new("espeak")
+    .arg("-w")
+    .arg(&file_path)
+    .arg(text)
+    .status()?;
+
+  // Read the wav file bytes
+  let bytes = std::fs::read(&file_path)?;
+
+  // Delete the file
+  std::fs::remove_file(file_path)?;
+
+  // Convert to base64
+  let base64 = base64::encode(bytes);
+
+  Ok(base64)
+}
+
 #[cfg(test)]
 mod tests {
-  use crate::is_image_content_type;
+  use crate::{captcha_espeak_wav_base64, is_image_content_type};
 
   #[test]
   fn test_image() {
@@ -241,6 +291,11 @@ mod tests {
     });
   }
 
+  #[test]
+  fn test_espeak() {
+    assert!(captcha_espeak_wav_base64("WxRt2l").is_ok())
+  }
+
   // These helped with testing
   // #[test]
   // fn test_iframely() {
diff --git a/server/src/routes/api.rs b/server/src/routes/api.rs
index 31888156..f524cf41 100644
--- a/server/src/routes/api.rs
+++ b/server/src/routes/api.rs
@@ -140,6 +140,7 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimit) {
           .route("/ban", web::post().to(route_post::<BanUser>))
           // Account actions. I don't like that they're in /user maybe /accounts
           .route("/login", web::post().to(route_post::<Login>))
+          .route("/get_captcha", web::get().to(route_post::<GetCaptcha>))
           .route(
             "/delete_account",
             web::post().to(route_post::<DeleteAccount>),
diff --git a/server/src/websocket/mod.rs b/server/src/websocket/mod.rs
index 5f3157b1..2b0cd1bd 100644
--- a/server/src/websocket/mod.rs
+++ b/server/src/websocket/mod.rs
@@ -20,6 +20,7 @@ use std::{
 pub enum UserOperation {
   Login,
   Register,
+  GetCaptcha,
   CreateCommunity,
   CreatePost,
   ListCommunities,
diff --git a/server/src/websocket/server.rs b/server/src/websocket/server.rs
index cc6de115..6c3eed4b 100644
--- a/server/src/websocket/server.rs
+++ b/server/src/websocket/server.rs
@@ -16,6 +16,7 @@ use crate::{
   UserId,
 };
 use actix_web::client::Client;
+use lemmy_db::naive_now;
 
 /// Chat server sends this messages to session
 #[derive(Message)]
@@ -134,6 +135,21 @@ pub struct SessionInfo {
   pub ip: IPAddr,
 }
 
+#[derive(Message, Debug)]
+#[rtype(result = "()")]
+pub struct CaptchaItem {
+  pub uuid: String,
+  pub answer: String,
+  pub expires: chrono::NaiveDateTime,
+}
+
+#[derive(Message)]
+#[rtype(bool)]
+pub struct CheckCaptcha {
+  pub uuid: String,
+  pub answer: String,
+}
+
 /// `ChatServer` manages chat rooms and responsible for coordinating chat
 /// session.
 pub struct ChatServer {
@@ -158,6 +174,9 @@ pub struct ChatServer {
   /// Rate limiting based on rate type and IP addr
   rate_limiter: RateLimit,
 
+  /// A list of the current captchas
+  captchas: Vec<CaptchaItem>,
+
   /// An HTTP Client
   client: Client,
 }
@@ -176,6 +195,7 @@ impl ChatServer {
       rng: rand::thread_rng(),
       pool,
       rate_limiter,
+      captchas: Vec::new(),
       client,
     }
   }
@@ -441,6 +461,7 @@ impl ChatServer {
         // User ops
         UserOperation::Login => do_user_operation::<Login>(args).await,
         UserOperation::Register => do_user_operation::<Register>(args).await,
+        UserOperation::GetCaptcha => do_user_operation::<GetCaptcha>(args).await,
         UserOperation::GetUserDetails => do_user_operation::<GetUserDetails>(args).await,
         UserOperation::GetReplies => do_user_operation::<GetReplies>(args).await,
         UserOperation::AddAdmin => do_user_operation::<AddAdmin>(args).await,
@@ -635,7 +656,7 @@ impl Handler<StandardMessage> for ChatServer {
     Box::pin(async move {
       match fut.await {
         Ok(m) => {
-          info!("Message Sent: {}", m);
+          // info!("Message Sent: {}", m);
           Ok(m)
         }
         Err(e) => {
@@ -774,3 +795,30 @@ where
   };
   Ok(serde_json::to_string(&response)?)
 }
+
+impl Handler<CaptchaItem> for ChatServer {
+  type Result = ();
+
+  fn handle(&mut self, msg: CaptchaItem, _: &mut Context<Self>) {
+    self.captchas.push(msg);
+  }
+}
+
+impl Handler<CheckCaptcha> for ChatServer {
+  type Result = bool;
+
+  fn handle(&mut self, msg: CheckCaptcha, _: &mut Context<Self>) -> Self::Result {
+    // Remove all the ones that are past the expire time
+    self.captchas.retain(|x| x.expires.gt(&naive_now()));
+
+    let check = self
+      .captchas
+      .iter()
+      .any(|r| r.uuid == msg.uuid && r.answer == msg.answer);
+
+    // Remove this uuid so it can't be re-checked (Checks only work once)
+    self.captchas.retain(|x| x.uuid != msg.uuid);
+
+    check
+  }
+}
diff --git a/ui/src/components/create-post.tsx b/ui/src/components/create-post.tsx
index 9d6cbb89..eb86d8f8 100644
--- a/ui/src/components/create-post.tsx
+++ b/ui/src/components/create-post.tsx
@@ -110,7 +110,7 @@ export class CreatePost extends Component<any, CreatePostState> {
         return lastLocation.split('/c/')[1];
       }
     }
-    return undefined;
+    return;
   }
 
   handlePostCreate(id: number) {
diff --git a/ui/src/components/login.tsx b/ui/src/components/login.tsx
index 94de499e..bdee3a97 100644
--- a/ui/src/components/login.tsx
+++ b/ui/src/components/login.tsx
@@ -9,6 +9,7 @@ import {
   UserOperation,
   PasswordResetForm,
   GetSiteResponse,
+  GetCaptchaResponse,
   WebSocketJsonResponse,
   Site,
 } from '../interfaces';
@@ -21,11 +22,8 @@ interface State {
   registerForm: RegisterForm;
   loginLoading: boolean;
   registerLoading: boolean;
-  mathQuestion: {
-    a: number;
-    b: number;
-    answer: number;
-  };
+  captcha: GetCaptchaResponse;
+  captchaPlaying: boolean;
   site: Site;
 }
 
@@ -43,14 +41,13 @@ export class Login extends Component<any, State> {
       password_verify: undefined,
       admin: false,
       show_nsfw: false,
+      captcha_uuid: undefined,
+      captcha_answer: undefined,
     },
     loginLoading: false,
     registerLoading: false,
-    mathQuestion: {
-      a: Math.floor(Math.random() * 10) + 1,
-      b: Math.floor(Math.random() * 10) + 1,
-      answer: undefined,
-    },
+    captcha: undefined,
+    captchaPlaying: false,
     site: {
       id: undefined,
       name: undefined,
@@ -81,6 +78,7 @@ export class Login extends Component<any, State> {
       );
 
     WebSocketService.Instance.getSite();
+    WebSocketService.Instance.getCaptcha();
   }
 
   componentWillUnmount() {
@@ -172,6 +170,7 @@ export class Login extends Component<any, State> {
       </div>
     );
   }
+
   registerForm() {
     return (
       <form onSubmit={linkEvent(this, this.handleRegisterSubmit)}>
@@ -258,23 +257,37 @@ export class Login extends Component<any, State> {
             />
           </div>
         </div>
-        <div class="form-group row">
-          <label class="col-sm-10 col-form-label" htmlFor="register-math">
-            {i18n.t('what_is')}{' '}
-            {`${this.state.mathQuestion.a} + ${this.state.mathQuestion.b}?`}
-          </label>
 
-          <div class="col-sm-2">
-            <input
-              type="number"
-              id="register-math"
-              class="form-control"
-              value={this.state.mathQuestion.answer}
-              onInput={linkEvent(this, this.handleMathAnswerChange)}
-              required
-            />
+        {this.state.captcha && (
+          <div class="form-group row">
+            <label class="col-sm-2" htmlFor="register-captcha">
+              <span class="mr-2">{i18n.t('enter_code')}</span>
+              <button
+                type="button"
+                class="btn btn-secondary"
+                onClick={linkEvent(this, this.handleRegenCaptcha)}
+              >
+                <svg class="icon icon-refresh-cw">
+                  <use xlinkHref="#icon-refresh-cw"></use>
+                </svg>
+              </button>
+            </label>
+            {this.showCaptcha()}
+            <div class="col-sm-6">
+              <input
+                type="text"
+                class="form-control"
+                id="register-captcha"
+                value={this.state.registerForm.captcha_answer}
+                onInput={linkEvent(
+                  this,
+                  this.handleRegisterCaptchaAnswerChange
+                )}
+                required
+              />
+            </div>
           </div>
-        </div>
+        )}
         {this.state.site.enable_nsfw && (
           <div class="form-group row">
             <div class="col-sm-10">
@@ -295,11 +308,7 @@ export class Login extends Component<any, State> {
         )}
         <div class="form-group row">
           <div class="col-sm-10">
-            <button
-              type="submit"
-              class="btn btn-secondary"
-              disabled={this.mathCheck}
-            >
+            <button type="submit" class="btn btn-secondary">
               {this.state.registerLoading ? (
                 <svg class="icon icon-spinner spin">
                   <use xlinkHref="#icon-spinner"></use>
@@ -314,6 +323,36 @@ export class Login extends Component<any, State> {
     );
   }
 
+  showCaptcha() {
+    return (
+      <div class="col-sm-4">
+        {this.state.captcha.ok && (
+          <>
+            <img
+              class="rounded-top img-fluid"
+              src={this.captchaPngSrc()}
+              style="border-bottom-right-radius: 0; border-bottom-left-radius: 0;"
+            />
+            {this.state.captcha.ok.wav && (
+              <button
+                class="rounded-bottom btn btn-sm btn-secondary btn-block"
+                style="border-top-right-radius: 0; border-top-left-radius: 0;"
+                title={i18n.t('play_captcha_audio')}
+                onClick={linkEvent(this, this.handleCaptchaPlay)}
+                type="button"
+                disabled={this.state.captchaPlaying}
+              >
+                <svg class="icon icon-play">
+                  <use xlinkHref="#icon-play"></use>
+                </svg>
+              </button>
+            )}
+          </>
+        )}
+      </div>
+    );
+  }
+
   handleLoginSubmit(i: Login, event: any) {
     event.preventDefault();
     i.state.loginLoading = true;
@@ -335,10 +374,7 @@ export class Login extends Component<any, State> {
     event.preventDefault();
     i.state.registerLoading = true;
     i.setState(i.state);
-
-    if (!i.mathCheck) {
-      WebSocketService.Instance.register(i.state.registerForm);
-    }
+    WebSocketService.Instance.register(i.state.registerForm);
   }
 
   handleRegisterUsernameChange(i: Login, event: any) {
@@ -369,11 +405,16 @@ export class Login extends Component<any, State> {
     i.setState(i.state);
   }
 
-  handleMathAnswerChange(i: Login, event: any) {
-    i.state.mathQuestion.answer = event.target.value;
+  handleRegisterCaptchaAnswerChange(i: Login, event: any) {
+    i.state.registerForm.captcha_answer = event.target.value;
     i.setState(i.state);
   }
 
+  handleRegenCaptcha(_i: Login, _event: any) {
+    event.preventDefault();
+    WebSocketService.Instance.getCaptcha();
+  }
+
   handlePasswordReset(i: Login) {
     event.preventDefault();
     let resetForm: PasswordResetForm = {
@@ -382,11 +423,21 @@ export class Login extends Component<any, State> {
     WebSocketService.Instance.passwordReset(resetForm);
   }
 
-  get mathCheck(): boolean {
-    return (
-      this.state.mathQuestion.answer !=
-      this.state.mathQuestion.a + this.state.mathQuestion.b
-    );
+  handleCaptchaPlay(i: Login) {
+    event.preventDefault();
+    let snd = new Audio('data:audio/wav;base64,' + i.state.captcha.ok.wav);
+    snd.play();
+    i.state.captchaPlaying = true;
+    i.setState(i.state);
+    snd.addEventListener('ended', () => {
+      snd.currentTime = 0;
+      i.state.captchaPlaying = false;
+      i.setState(this.state);
+    });
+  }
+
+  captchaPngSrc() {
+    return `data:image/png;base64,${this.state.captcha.ok.png}`;
   }
 
   parseMessage(msg: WebSocketJsonResponse) {
@@ -394,6 +445,9 @@ export class Login extends Component<any, State> {
     if (msg.error) {
       toast(i18n.t(msg.error), 'danger');
       this.state = this.emptyState;
+      this.state.registerForm.captcha_answer = undefined;
+      // Refetch another captcha
+      WebSocketService.Instance.getCaptcha();
       this.setState(this.state);
       return;
     } else {
@@ -412,6 +466,13 @@ export class Login extends Component<any, State> {
         UserService.Instance.login(data);
         WebSocketService.Instance.userJoin();
         this.props.history.push('/communities');
+      } else if (res.op == UserOperation.GetCaptcha) {
+        let data = res.data as GetCaptchaResponse;
+        if (data.ok) {
+          this.state.captcha = data;
+          this.state.registerForm.captcha_uuid = data.ok.uuid;
+          this.setState(this.state);
+        }
       } else if (res.op == UserOperation.PasswordReset) {
         toast(i18n.t('reset_password_mail_sent'));
       } else if (res.op == UserOperation.GetSite) {
diff --git a/ui/src/components/setup.tsx b/ui/src/components/setup.tsx
index 196a2e56..7da14379 100644
--- a/ui/src/components/setup.tsx
+++ b/ui/src/components/setup.tsx
@@ -29,6 +29,9 @@ export class Setup extends Component<any, State> {
       password_verify: undefined,
       admin: true,
       show_nsfw: true,
+      // The first admin signup doesn't need a captcha
+      captcha_uuid: '',
+      captcha_answer: '',
     },
     doneRegisteringUser: false,
     userLoading: false,
diff --git a/ui/src/components/symbols.tsx b/ui/src/components/symbols.tsx
index 48d5a5b6..bd702143 100644
--- a/ui/src/components/symbols.tsx
+++ b/ui/src/components/symbols.tsx
@@ -15,6 +15,12 @@ export class Symbols extends Component<any, any> {
         xmlnsXlink="http://www.w3.org/1999/xlink"
       >
         <defs>
+          <symbol id="icon-refresh-cw" viewBox="0 0 24 24">
+            <path d="M4.453 9.334c0.737-2.083 2.247-3.669 4.096-4.552s4.032-1.059 6.114-0.322c1.186 0.42 2.206 1.088 2.983 1.88l2.83 2.66h-3.476c-0.552 0-1 0.448-1 1s0.448 1 1 1h5.997c0.005 0 0.009 0 0.014 0 0.137-0.001 0.268-0.031 0.386-0.082 0.119-0.051 0.229-0.126 0.324-0.225 0.012-0.013 0.024-0.026 0.036-0.039 0.075-0.087 0.133-0.183 0.173-0.285s0.064-0.211 0.069-0.326c0.001-0.015 0.001-0.029 0.001-0.043v-6c0-0.552-0.448-1-1-1s-1 0.448-1 1v3.689l-2.926-2.749c-0.992-1.010-2.271-1.843-3.743-2.364-2.603-0.921-5.335-0.699-7.643 0.402s-4.199 3.086-5.12 5.689c-0.185 0.52 0.088 1.091 0.608 1.276s1.092-0.088 1.276-0.609zM2 16.312l2.955 2.777c1.929 1.931 4.49 2.908 7.048 2.909s5.119-0.975 7.072-2.927c1.104-1.104 1.901-2.407 2.361-3.745 0.18-0.522-0.098-1.091-0.621-1.271s-1.091 0.098-1.271 0.621c-0.361 1.050-0.993 2.091-1.883 2.981-1.563 1.562-3.609 2.342-5.657 2.342s-4.094-0.782-5.679-2.366l-2.8-2.633h3.475c0.552 0 1-0.448 1-1s-0.448-1-1-1h-5.997c-0.005 0-0.009 0-0.014 0-0.137 0.001-0.268 0.031-0.386 0.082-0.119 0.051-0.229 0.126-0.324 0.225-0.012 0.013-0.024 0.026-0.036 0.039-0.075 0.087-0.133 0.183-0.173 0.285s-0.064 0.211-0.069 0.326c-0.001 0.015-0.001 0.029-0.001 0.043v6c0 0.552 0.448 1 1 1s1-0.448 1-1z"></path>
+          </symbol>
+          <symbol id="icon-play" viewBox="0 0 24 24">
+            <path d="M5.541 2.159c-0.153-0.1-0.34-0.159-0.541-0.159-0.552 0-1 0.448-1 1v18c-0.001 0.182 0.050 0.372 0.159 0.541 0.299 0.465 0.917 0.599 1.382 0.3l14-9c0.114-0.072 0.219-0.174 0.3-0.3 0.299-0.465 0.164-1.083-0.3-1.382zM6 4.832l11.151 7.168-11.151 7.168z"></path>
+          </symbol>
           <symbol id="icon-strikethrough" viewBox="0 0 28 28">
             <path d="M27.5 14c0.281 0 0.5 0.219 0.5 0.5v1c0 0.281-0.219 0.5-0.5 0.5h-27c-0.281 0-0.5-0.219-0.5-0.5v-1c0-0.281 0.219-0.5 0.5-0.5h27zM7.547 13c-0.297-0.375-0.562-0.797-0.797-1.25-0.5-1.016-0.75-2-0.75-2.938 0-1.906 0.703-3.5 2.094-4.828s3.437-1.984 6.141-1.984c0.594 0 1.453 0.109 2.609 0.297 0.688 0.125 1.609 0.375 2.766 0.75 0.109 0.406 0.219 1.031 0.328 1.844 0.141 1.234 0.219 2.187 0.219 2.859 0 0.219-0.031 0.453-0.078 0.703l-0.187 0.047-1.313-0.094-0.219-0.031c-0.531-1.578-1.078-2.641-1.609-3.203-0.922-0.953-2.031-1.422-3.281-1.422-1.188 0-2.141 0.313-2.844 0.922s-1.047 1.375-1.047 2.281c0 0.766 0.344 1.484 1.031 2.188s2.141 1.375 4.359 2.016c0.75 0.219 1.641 0.562 2.703 1.031 0.562 0.266 1.062 0.531 1.484 0.812h-11.609zM15.469 17h6.422c0.078 0.438 0.109 0.922 0.109 1.437 0 1.125-0.203 2.234-0.641 3.313-0.234 0.578-0.594 1.109-1.109 1.625-0.375 0.359-0.938 0.781-1.703 1.266-0.781 0.469-1.563 0.828-2.391 1.031-0.828 0.219-1.875 0.328-3.172 0.328-0.859 0-1.891-0.031-3.047-0.359l-2.188-0.625c-0.609-0.172-0.969-0.313-1.125-0.438-0.063-0.063-0.125-0.172-0.125-0.344v-0.203c0-0.125 0.031-0.938-0.031-2.438-0.031-0.781 0.031-1.328 0.031-1.641v-0.688l1.594-0.031c0.578 1.328 0.844 2.125 1.016 2.406 0.375 0.609 0.797 1.094 1.25 1.469s1 0.672 1.641 0.891c0.625 0.234 1.328 0.344 2.063 0.344 0.656 0 1.391-0.141 2.172-0.422 0.797-0.266 1.437-0.719 1.906-1.344 0.484-0.625 0.734-1.297 0.734-2.016 0-0.875-0.422-1.687-1.266-2.453-0.344-0.297-1.062-0.672-2.141-1.109z"></path>
           </symbol>
@@ -193,10 +199,10 @@ export class Symbols extends Component<any, any> {
             <path d="M 23.296875 22.394531 L 22.082031 22.394531 L 22.082031 17.007812 C 22.453125 16.699219 22.664062 16.261719 22.664062 15.796875 L 22.664062 13.984375 C 22.664062 12.996094 21.785156 12.191406 20.703125 12.191406 L 19.785156 12.191406 L 19.785156 7.785156 C 19.785156 7.050781 19.1875 6.449219 18.449219 6.449219 L 18.367188 6.449219 L 18.367188 5.96875 C 19.199219 5.675781 19.796875 4.882812 19.796875 3.957031 C 19.796875 3.644531 19.703125 3.117188 18.996094 1.800781 C 18.632812 1.121094 18.273438 0.550781 18.257812 0.527344 C 18.128906 0.320312 17.90625 0.199219 17.664062 0.199219 C 17.421875 0.199219 17.199219 0.320312 17.070312 0.527344 C 17.054688 0.550781 16.695312 1.121094 16.332031 1.800781 C 15.621094 3.117188 15.53125 3.644531 15.53125 3.957031 C 15.53125 4.882812 16.128906 5.675781 16.960938 5.96875 L 16.960938 6.449219 L 16.878906 6.449219 C 16.140625 6.449219 15.542969 7.050781 15.542969 7.785156 L 15.542969 12.191406 L 14.121094 12.191406 L 14.121094 7.785156 C 14.121094 7.050781 13.523438 6.449219 12.785156 6.449219 L 12.703125 6.449219 L 12.703125 5.96875 C 13.535156 5.675781 14.132812 4.882812 14.132812 3.957031 C 14.132812 3.644531 14.039062 3.117188 13.332031 1.800781 C 12.96875 1.121094 12.609375 0.550781 12.59375 0.527344 C 12.464844 0.320312 12.242188 0.199219 12 0.199219 C 11.757812 0.199219 11.535156 0.320312 11.40625 0.527344 C 11.390625 0.550781 11.03125 1.121094 10.667969 1.800781 C 9.960938 3.117188 9.867188 3.644531 9.867188 3.957031 C 9.867188 4.882812 10.464844 5.675781 11.296875 5.96875 L 11.296875 6.449219 L 11.214844 6.449219 C 10.476562 6.449219 9.878906 7.050781 9.878906 7.785156 L 9.878906 12.191406 L 8.457031 12.191406 L 8.457031 7.785156 C 8.457031 7.050781 7.859375 6.449219 7.121094 6.449219 L 7.039062 6.449219 L 7.039062 5.96875 C 7.871094 5.675781 8.46875 4.882812 8.46875 3.957031 C 8.46875 3.644531 8.378906 3.117188 7.667969 1.800781 C 7.304688 1.121094 6.945312 0.550781 6.929688 0.527344 C 6.800781 0.320312 6.578125 0.199219 6.335938 0.199219 C 6.09375 0.199219 5.871094 0.320312 5.742188 0.527344 C 5.726562 0.550781 5.367188 1.121094 5.003906 1.800781 C 4.296875 3.117188 4.203125 3.644531 4.203125 3.957031 C 4.203125 4.882812 4.800781 5.675781 5.632812 5.96875 L 5.632812 6.449219 L 5.550781 6.449219 C 4.8125 6.449219 4.214844 7.050781 4.214844 7.785156 L 4.214844 12.191406 L 3.296875 12.191406 C 2.214844 12.191406 1.335938 12.996094 1.335938 13.984375 L 1.335938 15.796875 C 1.335938 16.261719 1.546875 16.699219 1.917969 17.007812 L 1.917969 22.394531 L 0.703125 22.394531 C 0.316406 22.394531 0 22.710938 0 23.097656 C 0 23.488281 0.316406 23.800781 0.703125 23.800781 L 23.296875 23.800781 C 23.683594 23.800781 24 23.488281 24 23.097656 C 24 22.710938 23.683594 22.394531 23.296875 22.394531 Z M 16.9375 3.957031 C 16.941406 3.730469 17.246094 3.054688 17.664062 2.289062 C 18.082031 3.054688 18.382812 3.730469 18.390625 3.957031 C 18.390625 4.355469 18.0625 4.679688 17.664062 4.679688 C 17.265625 4.679688 16.9375 4.355469 16.9375 3.957031 Z M 16.949219 7.855469 L 18.378906 7.855469 L 18.378906 12.1875 L 16.949219 12.1875 Z M 11.273438 3.957031 C 11.277344 3.730469 11.582031 3.054688 12 2.289062 C 12.417969 3.054688 12.722656 3.730469 12.726562 3.957031 C 12.726562 4.355469 12.398438 4.679688 12 4.679688 C 11.601562 4.679688 11.273438 4.355469 11.273438 3.957031 Z M 11.285156 7.855469 L 12.714844 7.855469 L 12.714844 12.1875 L 11.285156 12.1875 Z M 5.609375 3.957031 C 5.613281 3.730469 5.917969 3.054688 6.335938 2.289062 C 6.753906 3.054688 7.058594 3.730469 7.0625 3.957031 C 7.0625 4.355469 6.734375 4.679688 6.335938 4.679688 C 5.9375 4.679688 5.609375 4.355469 5.609375 3.957031 Z M 5.621094 7.855469 L 7.050781 7.855469 L 7.050781 12.1875 L 5.621094 12.1875 Z M 20.675781 22.394531 L 3.324219 22.394531 L 3.324219 17.414062 C 3.433594 17.398438 3.546875 17.378906 3.652344 17.347656 L 5.429688 16.820312 C 6.453125 16.515625 7.582031 16.515625 8.609375 16.820312 L 10.011719 17.234375 C 10.652344 17.425781 11.324219 17.519531 12 17.519531 C 12.675781 17.519531 13.347656 17.425781 13.988281 17.234375 L 15.390625 16.820312 C 16.417969 16.515625 17.546875 16.515625 18.570312 16.820312 L 20.347656 17.347656 C 20.453125 17.378906 20.5625 17.398438 20.675781 17.414062 Z M 21.257812 15.796875 C 21.257812 15.855469 21.210938 15.902344 21.171875 15.933594 C 21.082031 16 20.925781 16.050781 20.746094 15.996094 L 18.972656 15.472656 C 17.6875 15.09375 16.273438 15.09375 14.992188 15.472656 L 13.589844 15.886719 C 12.566406 16.191406 11.433594 16.191406 10.410156 15.886719 L 9.007812 15.472656 C 8.367188 15.28125 7.691406 15.1875 7.019531 15.1875 C 6.34375 15.1875 5.671875 15.28125 5.027344 15.472656 L 3.253906 15.996094 C 3.074219 16.050781 2.917969 16 2.828125 15.933594 C 2.789062 15.902344 2.742188 15.855469 2.742188 15.796875 L 2.742188 13.984375 C 2.742188 13.800781 2.96875 13.597656 3.296875 13.597656 L 20.703125 13.597656 C 21.03125 13.597656 21.257812 13.800781 21.257812 13.984375 Z M 21.257812 15.796875 " />
           </symbol>
           <symbol id="icon-subscript" viewBox="0 0 20 20">
-            <path d="M13.68 16h-2.42a.67.67 0 0 1-.46-.15 1.33 1.33 0 0 1-.28-.34l-2.77-4.44a2.65 2.65 0 0 1-.28.69L5 15.51a2.22 2.22 0 0 1-.29.34.58.58 0 0 1-.42.15H2l4.15-6.19L2.17 4h2.42a.81.81 0 0 1 .41.09.8.8 0 0 1 .24.26L8 8.59a2.71 2.71 0 0 1 .33-.74L10.6 4.4a.69.69 0 0 1 .6-.4h2.32l-4 5.71zm3.82-4h.5v-1h-.5a1.49 1.49 0 0 0-1 .39 1.49 1.49 0 0 0-1-.39H15v1h.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-.5.5H15v1h.5a1.49 1.49 0 0 0 1-.39 1.49 1.49 0 0 0 1 .39h.5v-1h-.5a.5.5 0 0 1-.5-.5v-6a.5.5 0 0 1 .5-.5z"/>
+            <path d="M13.68 16h-2.42a.67.67 0 0 1-.46-.15 1.33 1.33 0 0 1-.28-.34l-2.77-4.44a2.65 2.65 0 0 1-.28.69L5 15.51a2.22 2.22 0 0 1-.29.34.58.58 0 0 1-.42.15H2l4.15-6.19L2.17 4h2.42a.81.81 0 0 1 .41.09.8.8 0 0 1 .24.26L8 8.59a2.71 2.71 0 0 1 .33-.74L10.6 4.4a.69.69 0 0 1 .6-.4h2.32l-4 5.71zm3.82-4h.5v-1h-.5a1.49 1.49 0 0 0-1 .39 1.49 1.49 0 0 0-1-.39H15v1h.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-.5.5H15v1h.5a1.49 1.49 0 0 0 1-.39 1.49 1.49 0 0 0 1 .39h.5v-1h-.5a.5.5 0 0 1-.5-.5v-6a.5.5 0 0 1 .5-.5z" />
           </symbol>
           <symbol id="icon-superscript" viewBox="0 0 20 20">
-            <path d="M17.5 1h.5V0h-.5a1.49 1.49 0 0 0-1 .39 1.49 1.49 0 0 0-1-.39H15v1h.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-.5.5H15v1h.5a1.49 1.49 0 0 0 1-.39 1.49 1.49 0 0 0 1 .39h.5V8h-.5a.5.5 0 0 1-.5-.5v-6a.5.5 0 0 1 .5-.5zm-3.82 15h-2.42a.67.67 0 0 1-.46-.15 1.33 1.33 0 0 1-.28-.34l-2.77-4.44a2.65 2.65 0 0 1-.28.69L5 15.51a2.22 2.22 0 0 1-.29.34.58.58 0 0 1-.42.15H2l4.15-6.19L2.17 4h2.42a.81.81 0 0 1 .41.09.8.8 0 0 1 .24.26L8 8.59a2.71 2.71 0 0 1 .33-.74L10.6 4.4a.69.69 0 0 1 .6-.4h2.32l-4 5.71z"/>
+            <path d="M17.5 1h.5V0h-.5a1.49 1.49 0 0 0-1 .39 1.49 1.49 0 0 0-1-.39H15v1h.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-.5.5H15v1h.5a1.49 1.49 0 0 0 1-.39 1.49 1.49 0 0 0 1 .39h.5V8h-.5a.5.5 0 0 1-.5-.5v-6a.5.5 0 0 1 .5-.5zm-3.82 15h-2.42a.67.67 0 0 1-.46-.15 1.33 1.33 0 0 1-.28-.34l-2.77-4.44a2.65 2.65 0 0 1-.28.69L5 15.51a2.22 2.22 0 0 1-.29.34.58.58 0 0 1-.42.15H2l4.15-6.19L2.17 4h2.42a.81.81 0 0 1 .41.09.8.8 0 0 1 .24.26L8 8.59a2.71 2.71 0 0 1 .33-.74L10.6 4.4a.69.69 0 0 1 .6-.4h2.32l-4 5.71z" />
           </symbol>
         </defs>
       </svg>
diff --git a/ui/src/interfaces.ts b/ui/src/interfaces.ts
index b8804522..87aa400a 100644
--- a/ui/src/interfaces.ts
+++ b/ui/src/interfaces.ts
@@ -1,6 +1,7 @@
 export enum UserOperation {
   Login,
   Register,
+  GetCaptcha,
   CreateCommunity,
   CreatePost,
   ListCommunities,
@@ -572,6 +573,16 @@ export interface RegisterForm {
   password_verify: string;
   admin: boolean;
   show_nsfw: boolean;
+  captcha_uuid?: string;
+  captcha_answer?: string;
+}
+
+export interface GetCaptchaResponse {
+  ok?: {
+    png: string;
+    wav?: string;
+    uuid: string;
+  };
 }
 
 export interface LoginResponse {
@@ -1010,6 +1021,7 @@ type ResponseType =
   | CommentResponse
   | UserMentionResponse
   | LoginResponse
+  | GetCaptchaResponse
   | GetModlogResponse
   | SearchResponse
   | BanFromCommunityResponse
diff --git a/ui/src/services/WebSocketService.ts b/ui/src/services/WebSocketService.ts
index 5d991660..03233ef9 100644
--- a/ui/src/services/WebSocketService.ts
+++ b/ui/src/services/WebSocketService.ts
@@ -115,6 +115,10 @@ export class WebSocketService {
     this.ws.send(this.wsSendWrapper(UserOperation.Register, registerForm));
   }
 
+  public getCaptcha() {
+    this.ws.send(this.wsSendWrapper(UserOperation.GetCaptcha, {}));
+  }
+
   public createCommunity(form: CommunityForm) {
     this.setAuth(form);
     this.ws.send(this.wsSendWrapper(UserOperation.CreateCommunity, form));
diff --git a/ui/translations/en.json b/ui/translations/en.json
index c22c97c8..4ee0907a 100644
--- a/ui/translations/en.json
+++ b/ui/translations/en.json
@@ -264,6 +264,8 @@
     "password_incorrect": "Password incorrect.",
     "passwords_dont_match": "Passwords do not match.",
     "no_password_reset": "You will not be able to reset your password without an email.",
+    "captcha_incorrect": "Captcha incorrect.",
+    "enter_code": "Enter Code",
     "invalid_username": "Invalid username.",
     "admin_already_created": "Sorry, there's already an admin.",
     "user_already_exists": "User already exists.",
@@ -281,5 +283,6 @@
     "cake_day_title": "Cake day:",
     "cake_day_info": "It's {{ creator_name }}'s cake day today!",
     "invalid_post_title": "Invalid post title",
-    "invalid_url": "Invalid URL."
+    "invalid_url": "Invalid URL.",
+    "play_captcha_audio": "Play Captcha Audio"
 }