Skip to content

Commit 43913b5

Browse files
authored
Merge pull request #109 from ScilifelabDataCentre/altcha-form-validation
SQ-927: Add Captcha validation to frontend forms
2 parents 67bb7f7 + a8caa04 commit 43913b5

17 files changed

Lines changed: 306 additions & 190 deletions

File tree

docker/divbase_compose.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ services:
195195
- ASYNC_DATABASE_URL=postgresql+asyncpg://divbase_user:badpassword@postgres:5432/divbase_db
196196
- SYNC_DATABASE_URL=postgresql+psycopg2://divbase_user:badpassword@postgres:5432/divbase_db # Needed for lifespan event to check migrations are at HEAD.
197197
- JWT_SECRET_KEY=a-string-secret-at-least-256-bits-long
198+
- ALTCHA_HMAC_SECRET=a-string-secret-at-least-256-bits-long
198199
- FIRST_ADMIN_EMAIL=admin@divbase.com
199200
- FIRST_ADMIN_PASSWORD=badpassword
200201
- S3_ENDPOINT_URL=http://host.docker.internal:9000

packages/divbase-api/pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ requires-python = ">=3.12"
66
dependencies = [
77
"fastapi[standard]==0.138.0",
88
"uvicorn==0.49.0",
9-
"asyncpg==0.31.0",
9+
"asyncpg==0.31.0",
1010
"pyjwt==2.13.0",
1111
"pwdlib[argon2]==0.3.0",
1212
"starlette-admin==0.16.1",
@@ -19,7 +19,8 @@ dependencies = [
1919
"prometheus-client==0.25.0",
2020
"psutil==7.2.2",
2121
"boto3==1.43.34",
22-
"structlog>=26.1.0",
22+
"structlog==26.1.0",
23+
"altcha==2.0.2",
2324
"divbase-lib", # always uses version in repo, handled by uv
2425
]
2526

packages/divbase-api/src/divbase_api/api_config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,20 @@ class GeneralSettings:
2929
log_to_file: bool = os.getenv("LOG_TO_FILE", "0") == "1"
3030
first_admin_email: str = os.getenv("FIRST_ADMIN_EMAIL", "NOT_SET")
3131
first_admin_password: SecretStr = SecretStr(os.getenv("FIRST_ADMIN_PASSWORD", "NOT_SET"))
32+
altcha_hmac_secret: SecretStr = SecretStr(os.getenv("ALTCHA_HMAC_SECRET", "NOT_SET"))
3233

3334
# versions before this are denied access to the API, until the user has upgraded
3435
minimum_cli_version: str = os.getenv("MINIMUM_CLI_VERSION", "0.1.0")
3536
# Used in deciding if to give the user an announcement on login that a new version of the CLI is available.
3637
# Whilst we are keeping version numbers identical across each component of divbase, this does not need to be manually set.
3738
latest_cli_version: str = lib_version
3839

40+
def __post_init__(self):
41+
if self.frontend_base_url.endswith("/"):
42+
self.frontend_base_url = self.frontend_base_url[:-1]
43+
if self.mkdocs_site_url.endswith("/"):
44+
self.mkdocs_site_url = self.mkdocs_site_url[:-1]
45+
3946

4047
@dataclass
4148
class DBSettings:
@@ -148,6 +155,7 @@ def validate_api_settings(self) -> None:
148155
"FRONTEND_BASE_URL": self.general.frontend_base_url,
149156
"MKDOCS_SITE_URL": self.general.mkdocs_site_url,
150157
"USER_SUPPORT_EMAIL": self.general.user_support_email,
158+
"ALTCHA_HMAC_SECRET": self.general.altcha_hmac_secret,
151159
"ASYNC_DATABASE_URL": self.database.url,
152160
"SYNC_DATABASE_URL": self.database.sync_url,
153161
"JWT_SECRET_KEY": self.jwt.secret_key,
@@ -158,6 +166,9 @@ def validate_api_settings(self) -> None:
158166
"S3_SERVICE_ACCOUNT_SECRET_KEY": self.s3.secret_key,
159167
}
160168
for setting_name, setting in required_fields.items():
169+
if setting_name == "ALTCHA_HMAC_SECRET" and self.general.environment == "test":
170+
# we do not run Captcha checks with ALTCHA in e2e tests.
171+
continue
161172
if isinstance(setting, str) and setting == "NOT_SET":
162173
raise ValueError(f"A required environment variable was not set: {setting_name=}")
163174
if isinstance(setting, SecretStr):

packages/divbase-api/src/divbase_api/crud/auth.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,28 @@
11
"""
22
Authentication-related CRUD operations.
3+
4+
Covers:
5+
- email+password validation
6+
- JWT access and refresh token creation and validation
7+
- various user validation helpers (e.g. is user account valid).
8+
- Altcha captcha generation and verification
9+
10+
NOTE: For Altcha captcha generation and verification, we are using the V1 API,
11+
consistent with the Django implementation https://github.com/aboutcode-org/django-altcha/
312
"""
413

5-
from datetime import datetime, timezone
14+
from datetime import datetime, timedelta, timezone
615

716
import structlog
17+
from altcha import (
18+
ChallengeOptionsV1, # pyright: ignore[reportPrivateImportUsage]
19+
create_challenge_v1, # pyright: ignore[reportPrivateImportUsage]
20+
verify_solution_v1, # pyright: ignore[reportPrivateImportUsage]
21+
)
822
from fastapi import Response
923
from sqlalchemy.ext.asyncio import AsyncSession
1024

25+
from divbase_api.api_config import api_settings
1126
from divbase_api.crud.revoked_tokens import token_is_revoked
1227
from divbase_api.crud.users import get_user_by_email, get_user_by_id, get_user_by_id_or_raise
1328
from divbase_api.exceptions import AuthenticationError
@@ -17,6 +32,9 @@
1732

1833
logger = structlog.get_logger(__name__)
1934

35+
# Altcha (Captcha for e.g. registration + password reset) is disabled in test environments
36+
ALTCHA_ENABLED = api_settings.general.environment != "test"
37+
2038

2139
async def authenticate_user(db: AsyncSession, email: str, password: str) -> UserDB:
2240
"""
@@ -38,8 +56,9 @@ async def authenticate_user(db: AsyncSession, email: str, password: str) -> User
3856
raise AuthenticationError(message=generic_error_msg)
3957

4058
if not user.email_verified:
59+
verify_url = f"{api_settings.general.frontend_base_url}/resend-email-verification"
4160
raise AuthenticationError(
42-
message="Email address not verified, check your inbox or visit the DivBase website to resend a new verification email."
61+
message=f"Email address not verified, check your inbox or visit {verify_url} to get a new verification email."
4362
)
4463

4564
return user
@@ -130,3 +149,39 @@ def delete_auth_cookies(response: Response) -> Response:
130149
response.delete_cookie(TokenType.ACCESS.value)
131150
response.delete_cookie(TokenType.REFRESH.value)
132151
return response
152+
153+
154+
def generate_altcha_challenge() -> dict:
155+
"""Generate a new Altcha captcha challenge."""
156+
expires = datetime.now(tz=timezone.utc) + timedelta(minutes=1)
157+
options = ChallengeOptionsV1(
158+
algorithm="SHA-256",
159+
hmac_key=api_settings.general.altcha_hmac_secret.get_secret_value(),
160+
expires=expires,
161+
)
162+
challenge = create_challenge_v1(options)
163+
return challenge.to_dict()
164+
165+
166+
def verify_altcha_solution(altcha: str | None, email: str) -> bool:
167+
"""
168+
Verify the Altcha captcha solution. Returns True for a valid solution.
169+
Validation skipped if in test environment (to avoid having to deal with Altcha in e2e playwright tests).
170+
"""
171+
if not ALTCHA_ENABLED:
172+
logger.info("Skipping Altcha verification as in test mode.")
173+
return True
174+
175+
if not altcha:
176+
logger.warning("Altcha verification failed: no solution provided.")
177+
return False
178+
179+
verified, error = verify_solution_v1(
180+
payload=altcha,
181+
hmac_key=api_settings.general.altcha_hmac_secret.get_secret_value(),
182+
check_expires=True,
183+
)
184+
if not verified:
185+
logger.warning(f"Altcha verification failed for email {email} with error message: {error}")
186+
return False
187+
return True

0 commit comments

Comments
 (0)