Skip to content

Commit af9182f

Browse files
authored
Merge pull request #327 from librecaptcha/feature/auth-required-11385689050634936662
Add AuthRequired config option
2 parents 1845473 + aceed21 commit af9182f

10 files changed

Lines changed: 167 additions & 30 deletions

File tree

build.sbt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,6 @@ ThisBuild / assemblyMergeStrategy := {
3838
}
3939

4040
run / fork := true
41+
42+
Test / fork := true
43+
Test / forkOptions := ForkOptions().withRunJVMOptions(Vector("-Djava.awt.headless=true"))

src/main/java/org/limium/picoserve/Server.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ public String getMethod() {
8383
return exchange.getRequestMethod();
8484
}
8585

86+
public Map<String, List<String>> getHeaders() {
87+
return exchange.getRequestHeaders();
88+
}
89+
8690
public Map<String, List<String>> getQueryParams() {
8791
final var query = exchange.getRequestURI().getQuery();
8892
final var params = parseParams(query);

src/main/scala/lc/Main.scala

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ import lc.server.Server
55
import lc.background.BackgroundTask
66
import lc.database.Statements
77

8-
class LCFramework {
8+
class LCFramework(authKey: Option[String] = sys.env.get("AUTH_KEY")) {
99
private var backgroundTask: Option[BackgroundTask] = None
1010
private var server: Option[Server] = None
1111

1212
def start(configFilePath: String = "data/config.json"): Unit = {
1313
val config = new Config(configFilePath)
14+
15+
if (config.authRequired && authKey.isEmpty) {
16+
throw new Exception("AUTH_KEY environment variable is not specified, but authRequired is true.")
17+
}
18+
1419
Statements.maxAttempts = config.maxAttempts
1520
val captchaProviders = new CaptchaProviders(config = config)
1621
val captchaManager = new CaptchaManager(config = config, captchaProviders = captchaProviders)
@@ -23,7 +28,9 @@ class LCFramework {
2328
port = config.port,
2429
captchaManager = captchaManager,
2530
playgroundEnabled = config.playgroundEnabled,
26-
corsHeader = config.corsHeader
31+
corsHeader = config.corsHeader,
32+
authRequired = config.authRequired,
33+
authKey = authKey
2734
)
2835
srv.start()
2936
server = Some(srv)

src/main/scala/lc/core/config.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class Config(configFilePath: String) {
5151
val playgroundEnabled: Boolean = configFields.playgroundEnabledBool.getOrElse(true)
5252
val corsHeader: String = configFields.corsHeader.getOrElse("")
5353
val maxAttempts: Int = Math.max(1, (configFields.maxAttemptsRatioFloat.getOrElse(0.01f) * bufferCount).toInt)
54+
val authRequired: Boolean = configFields.authRequiredBool.getOrElse(false)
5455

5556
val captchaConfig: List[CaptchaConfig] = appConfig.captchas
5657
val allowedLevels: Set[String] = captchaConfig.flatMap(_.allowedLevels).toSet
@@ -70,6 +71,7 @@ class Config(configFilePath: String) {
7071
playgroundEnabled = Some(true),
7172
corsHeader = Some(""),
7273
maxAttemptsRatio = Some(0.01f),
74+
authRequired = Some(false),
7375
captchas = List(
7476
CaptchaConfig(
7577
name = "FilterChallenge",

src/main/scala/lc/core/models.scala

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,12 @@ case class AppConfig(
9595
playgroundEnabled: Option[Boolean] = None,
9696
corsHeader: Option[String] = None,
9797
maxAttemptsRatio: Option[Float] = None,
98+
authRequired: Option[Boolean] = None,
9899
captchas: List[CaptchaConfig] = List.empty
99100
) {
100101
def toConfigField: ConfigField = ConfigField(
101102
port, address, bufferCount, seed, captchaExpiryTimeLimit,
102-
threadDelay, playgroundEnabled, corsHeader, maxAttemptsRatio
103+
threadDelay, playgroundEnabled, corsHeader, maxAttemptsRatio, authRequired
103104
)
104105
}
105106
object AppConfig {
@@ -115,7 +116,8 @@ case class ConfigField(
115116
threadDelay: Option[Int] = None,
116117
playgroundEnabled: Option[Boolean] = None,
117118
corsHeader: Option[String] = None,
118-
maxAttemptsRatio: Option[Float] = None
119+
maxAttemptsRatio: Option[Float] = None,
120+
authRequired: Option[Boolean] = None
119121
) {
120122
lazy val portInt: Option[Int] = port
121123
lazy val bufferCountInt: Option[Int] = bufferCount
@@ -124,4 +126,5 @@ case class ConfigField(
124126
lazy val threadDelayInt: Option[Int] = threadDelay
125127
lazy val maxAttemptsRatioFloat: Option[Float] = maxAttemptsRatio
126128
lazy val playgroundEnabledBool: Option[Boolean] = playgroundEnabled.map(_ || false)
129+
lazy val authRequiredBool: Option[Boolean] = authRequired.map(_ || false)
127130
}

src/main/scala/lc/database/DB.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import java.sql.{Connection, DriverManager, Statement}
44

55
class DBConn() {
66
val con: Connection =
7-
DriverManager.getConnection("jdbc:h2:./data/H2/captcha3;MAX_COMPACT_TIME=8000;DB_CLOSE_ON_EXIT=FALSE", "sa", "")
7+
DriverManager.getConnection("jdbc:h2:./data/H2/captcha3;MAX_COMPACT_TIME=8000;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1", "sa", "")
88

99
def getStatement(): Statement = {
1010
con.createStatement()

src/main/scala/lc/server/Server.scala

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,55 +18,84 @@ class Server(
1818
port: Int,
1919
captchaManager: CaptchaManager,
2020
playgroundEnabled: Boolean,
21-
corsHeader: String
21+
corsHeader: String,
22+
authRequired: Boolean = false,
23+
authKey: Option[String] = None
2224
) {
2325
var headerMap: util.Map[String, util.List[String]] = null
2426
if (corsHeader.nonEmpty) {
2527
headerMap = Map("Access-Control-Allow-Origin" -> List(corsHeader).asJava).asJava
2628
}
29+
30+
private def checkAuth(request: picoserve.Server#Request): Boolean = {
31+
if (!authRequired) return true
32+
val headers = request.getHeaders()
33+
if (headers != null && headers.containsKey("Auth")) {
34+
val authHeaderValues = headers.get("Auth")
35+
if (authHeaderValues != null && authHeaderValues.size() > 0) {
36+
val authHeader = authHeaderValues.get(0)
37+
val expectedKey = authKey.getOrElse("")
38+
return authHeader == expectedKey
39+
}
40+
}
41+
false
42+
}
43+
2744
val serverBuilder: ServerBuilder = picoserve.Server
2845
.builder()
2946
.address(new InetSocketAddress(address, port))
3047
.backlog(32)
3148
.POST(
3249
"/v2/captcha",
3350
(request) => {
34-
val bodyStr = request.getBodyString().trim.replaceAll("\u0000", "")
35-
val paramEither = Parameters.codec.decode(ByteBuffer.wrap(bodyStr.getBytes("UTF-8")))
36-
paramEither match {
37-
case Right(param) =>
38-
val id = captchaManager.getChallenge(param)
39-
getResponse(id, headerMap)
40-
case Left(err) =>
41-
getResponse(Left(Error("Invalid parameters: " + err.toString)), headerMap)
51+
if (!checkAuth(request)) {
52+
new StringResponse(401, "Unauthorized", headerMap)
53+
} else {
54+
val bodyStr = request.getBodyString().trim.replaceAll("\u0000", "")
55+
val paramEither = Parameters.codec.decode(ByteBuffer.wrap(bodyStr.getBytes("UTF-8")))
56+
paramEither match {
57+
case Right(param) =>
58+
val id = captchaManager.getChallenge(param)
59+
getResponse(id, headerMap)
60+
case Left(err) =>
61+
getResponse(Left(Error("Invalid parameters: " + err.toString)), headerMap)
62+
}
4263
}
4364
}
4465
)
4566
.GET(
4667
"/v2/media",
4768
(request) => {
48-
val params = request.getQueryParams()
49-
val result = if (params.containsKey("id")) {
50-
val paramId = params.get("id").get(0)
51-
val id = Id(paramId)
52-
captchaManager.getCaptcha(id)
69+
if (!checkAuth(request)) {
70+
new StringResponse(401, "Unauthorized", headerMap)
5371
} else {
54-
Left(Error(ErrorMessageEnum.INVALID_PARAM.toString + "=> id"))
72+
val params = request.getQueryParams()
73+
val result = if (params.containsKey("id")) {
74+
val paramId = params.get("id").get(0)
75+
val id = Id(paramId)
76+
captchaManager.getCaptcha(id)
77+
} else {
78+
Left(Error(ErrorMessageEnum.INVALID_PARAM.toString + "=> id"))
79+
}
80+
getResponse(result, headerMap)
5581
}
56-
getResponse(result, headerMap)
5782
}
5883
)
5984
.POST(
6085
"/v2/answer",
6186
(request) => {
62-
val bodyStr = request.getBodyString().trim.replaceAll("\u0000", "")
63-
val answerEither = Answer.codec.decode(ByteBuffer.wrap(bodyStr.getBytes("UTF-8")))
64-
answerEither match {
65-
case Right(answer) =>
66-
val result = captchaManager.checkAnswer(answer)
67-
getResponse(result, headerMap)
68-
case Left(err) =>
69-
getResponse(Left(Error("Invalid answer format: " + err.toString)), headerMap)
87+
if (!checkAuth(request)) {
88+
new StringResponse(401, "Unauthorized", headerMap)
89+
} else {
90+
val bodyStr = request.getBodyString().trim.replaceAll("\u0000", "")
91+
val answerEither = Answer.codec.decode(ByteBuffer.wrap(bodyStr.getBytes("UTF-8")))
92+
answerEither match {
93+
case Right(answer) =>
94+
val result = captchaManager.checkAnswer(answer)
95+
getResponse(result, headerMap)
96+
case Left(err) =>
97+
getResponse(Left(Error("Invalid answer format: " + err.toString)), headerMap)
98+
}
7099
}
71100
}
72101
)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package lc.server
2+
3+
import org.scalatest.funsuite.AnyFunSuite
4+
import java.net.{HttpURLConnection, URL}
5+
import java.io.{BufferedReader, InputStreamReader, OutputStreamWriter}
6+
import lc.LCFramework
7+
import scala.jdk.CollectionConverters._
8+
9+
class ServerAuthSpec extends AnyFunSuite {
10+
11+
test("Server should require auth header when authRequired is true") {
12+
val authFramework = new LCFramework(authKey = Some("secret123"))
13+
// Ensure DB is not concurrently accessed by running tests sequentially
14+
// The previous failure was due to parallel test execution and the embedded H2 DB getting closed.
15+
authFramework.start("tests/auth-config.json")
16+
Thread.sleep(2000)
17+
18+
try {
19+
val url = new URL("http://localhost:8889/v2/captcha")
20+
21+
// 1. Test without auth header
22+
val connection1 = url.openConnection().asInstanceOf[HttpURLConnection]
23+
connection1.setRequestMethod("POST")
24+
connection1.setRequestProperty("Content-Type", "application/json")
25+
connection1.setDoOutput(true)
26+
val payload = """{"level":"debug","media":"image/png","input_type":"text","size":"350x100"}"""
27+
val out1 = new OutputStreamWriter(connection1.getOutputStream)
28+
out1.write(payload)
29+
out1.close()
30+
31+
var responseCode = connection1.getResponseCode
32+
assert(responseCode == 401, s"Expected 401 but got $responseCode")
33+
34+
// 2. Test with invalid auth header
35+
val connection2 = url.openConnection().asInstanceOf[HttpURLConnection]
36+
connection2.setRequestMethod("POST")
37+
connection2.setRequestProperty("Content-Type", "application/json")
38+
connection2.setRequestProperty("Auth", "wrongsecret")
39+
connection2.setDoOutput(true)
40+
val out2 = new OutputStreamWriter(connection2.getOutputStream)
41+
out2.write(payload)
42+
out2.close()
43+
44+
responseCode = connection2.getResponseCode
45+
assert(responseCode == 401, s"Expected 401 but got $responseCode")
46+
47+
// 3. Test with valid auth header
48+
val connection3 = url.openConnection().asInstanceOf[HttpURLConnection]
49+
connection3.setRequestMethod("POST")
50+
connection3.setRequestProperty("Content-Type", "application/json")
51+
connection3.setRequestProperty("Auth", "secret123")
52+
connection3.setDoOutput(true)
53+
val out3 = new OutputStreamWriter(connection3.getOutputStream)
54+
out3.write(payload)
55+
out3.close()
56+
57+
responseCode = connection3.getResponseCode
58+
assert(responseCode == 200, s"Expected 200 but got $responseCode")
59+
} finally {
60+
// Do not stop to avoid H2 shared database closure
61+
// authFramework.stop()
62+
}
63+
}
64+
}

src/test/scala/lc/ServerSpec.scala

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,24 @@ import org.scalatest.BeforeAndAfterAll
55
import java.net.{HttpURLConnection, URL}
66
import java.io.{BufferedReader, InputStreamReader, OutputStreamWriter}
77
import lc.LCFramework
8+
import scala.jdk.CollectionConverters._
89

910
class ServerSpec extends AnyFunSuite with BeforeAndAfterAll {
1011

1112
val framework = new LCFramework()
1213

1314
override def beforeAll(): Unit = {
1415
framework.start("tests/debug-config.json")
16+
1517
// Give the server a moment to start and generate some captchas
1618
Thread.sleep(2000)
1719
}
1820

1921
override def afterAll(): Unit = {
20-
framework.stop()
22+
// Cannot safely stop the framework because the single underlying H2 database connection
23+
// is closed when shutting down the framework, causing other tests to fail in parallel
24+
// or sequential runs inside the same forked JVM.
25+
// framework.stop()
2126
}
2227

2328
test("Server should respond with an id for a valid captcha request") {

tests/auth-config.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"randomSeed" : 20,
3+
"port" : 8889,
4+
"address" : "0.0.0.0",
5+
"captchaExpiryTimeLimit" : 5,
6+
"bufferCount" : 10,
7+
"threadDelay" : 2,
8+
"playgroundEnabled" : false,
9+
"authRequired" : true,
10+
"corsHeader" : "*",
11+
"maxAttemptsRatio" : 0.01,
12+
"captchas" : [ {
13+
"name" : "DebugCaptcha",
14+
"allowedLevels" : [ "debug" ],
15+
"allowedMedia" : [ "image/png" ],
16+
"allowedInputType" : [ "text" ],
17+
"allowedSizes" : [ "350x100" ],
18+
"config" : {}
19+
}]
20+
}

0 commit comments

Comments
 (0)