Skip to content

Commit

Permalink
Port register, login and update services to the new ECS style (#923)
Browse files Browse the repository at this point in the history
Move login, register and update services from the old monolith to the new ECS Style

This commit moves the vpn services in ooniprobe to its dedicated file, adds new endpoints for 
update, login, and register
  • Loading branch information
LDiazN authored Jan 22, 2025
1 parent 9fa38c9 commit 7cb00cf
Show file tree
Hide file tree
Showing 12 changed files with 362 additions and 80 deletions.
10 changes: 10 additions & 0 deletions ooniapi/services/ooniauth/src/ooniauth/routers/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,16 @@ async def user_login(
settings: Settings = Depends(get_settings),
):
"""Auth Services: login using a registration/login link"""

# **IMPORTANT** You have to compute this token using a different key
# to the one used in ooniprobe service, because you could allow
# a login bypass attack if you don't.
#
# The token used in ooniprobe is generated regardless of any authentication,
# because it's a toy token to please old probes.
#
# We set this up in terraform

try:
dec = decode_jwt(
token=token, key=settings.jwt_encryption_key, audience="register"
Expand Down
10 changes: 10 additions & 0 deletions ooniapi/services/ooniauth/src/ooniauth/routers/v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,16 @@ async def create_user_session(
settings: Settings = Depends(get_settings),
):
"""Auth Services: login using a registration/login link"""

# **IMPORTANT** You have to compute this token using a different key
# to the one used in ooniprobe service, because you could allow
# a login bypass attack if you don't.
#
# The token used in ooniprobe is generated regardless of any authentication,
# because it's a toy token to please old probes.
#
# We set this up in terraform

if req and req.login_token:
user_session = get_user_session_from_login_token(
login_token=req.login_token,
Expand Down
12 changes: 6 additions & 6 deletions ooniapi/services/ooniprobe/src/ooniprobe/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
from prometheus_fastapi_instrumentator import Instrumentator

from . import models
from .routers import v2
from .routers.v2 import vpn
from .routers.v1 import probe_services

from .dependencies import get_postgresql_session
from .common.dependencies import get_settings
Expand Down Expand Up @@ -45,7 +46,8 @@ async def lifespan(app: FastAPI):
allow_headers=["*"],
)

app.include_router(v2.router, prefix="/api")
app.include_router(vpn.router, prefix="/api")
app.include_router(probe_services.router, prefix="/api")


@app.get("/version")
Expand Down Expand Up @@ -93,7 +95,5 @@ async def health(
@app.get("/")
async def root():
# TODO(art): fix this redirect by pointing health monitoring to /health
#return RedirectResponse("/docs")
return {
"msg": "hello from ooniprobe"
}
# return RedirectResponse("/docs")
return {"msg": "hello from ooniprobe"}
Empty file.
144 changes: 144 additions & 0 deletions ooniapi/services/ooniprobe/src/ooniprobe/routers/v1/probe_services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import logging
from datetime import datetime, timezone, timedelta
import time
from typing import List

from fastapi import APIRouter, Depends, HTTPException, Response

from ...common.dependencies import get_settings
from ...common.routers import BaseModel
from ...common.auth import create_jwt, decode_jwt, jwt
from ...common.config import Settings
from ...common.utils import setnocacheresponse

router = APIRouter(prefix="/v1")

log = logging.getLogger(__name__)


class ProbeLogin(BaseModel):
# Allow None username and password
# to deliver informational 401 error when they're missing
username: str | None = None
# not actually used but necessary to be compliant with the old API schema
password: str | None = None


class ProbeLoginResponse(BaseModel):
token: str
expire: str


@router.post("/login", tags=["ooniprobe"], response_model=ProbeLoginResponse)
def probe_login_post(
probe_login: ProbeLogin,
response: Response,
settings: Settings = Depends(get_settings),
) -> ProbeLoginResponse:

if probe_login.username is None or probe_login.password is None:
raise HTTPException(status_code=401, detail="Missing credentials")

token = probe_login.username
# TODO: We have to find a way to explicitly log metrics with prometheus.
# We're currently using the instrumentator default metrics, like http response counts
# Maybe using the same exporter as the instrumentator?
try:
dec = decode_jwt(token, audience="probe_login", key=settings.jwt_encryption_key)
registration_time = dec["iat"]
log.info("probe login: successful")
# metrics.incr("probe_login_successful")
except jwt.exceptions.MissingRequiredClaimError:
log.info("probe login: invalid or missing claim")
# metrics.incr("probe_login_failed")
raise HTTPException(status_code=401, detail="Invalid credentials")
except jwt.exceptions.InvalidSignatureError:
log.info("probe login: invalid signature")
# metrics.incr("probe_login_failed")
raise HTTPException(status_code=401, detail="Invalid credentials")
except jwt.exceptions.DecodeError:
# Not a JWT token: treat it as a "legacy" login
# return jerror("Invalid or missing credentials", code=401)
log.info("probe login: legacy login successful")
# metrics.incr("probe_legacy_login_successful")
registration_time = None

exp = datetime.now(timezone.utc) + timedelta(days=7)
payload = {"registration_time": registration_time, "aud": "probe_token"}
token = create_jwt(payload, key=settings.jwt_encryption_key)
# expiration string used by the probe e.g. 2006-01-02T15:04:05Z
expire = exp.strftime("%Y-%m-%dT%H:%M:%SZ")
login_response = ProbeLoginResponse(token=token, expire=expire)
setnocacheresponse(response)

return login_response


class ProbeRegister(BaseModel):
# None of this values is actually used, but I add them
# to keep it compliant with the old api
password: str
platform: str
probe_asn: str
probe_cc: str
software_name: str
software_version: str
supported_tests: List[str]


class ProbeRegisterResponse(BaseModel):
client_id: str


@router.post("/register", tags=["ooniprobe"], response_model=ProbeRegisterResponse)
def probe_register_post(
probe_register: ProbeRegister,
response: Response,
settings: Settings = Depends(get_settings),
) -> ProbeRegisterResponse:
"""Probe Services: Register
Probes send a random string called password and receive a client_id
The client_id/password tuple is saved by the probe and long-lived
Note that most of the request body arguments are not actually
used but are kept here to use the same API as the old version
"""

# **IMPORTANT** You have to compute this token using a different key
# to the one used in ooniauth service, because you could allow
# a login bypass attack if you don't.
#
# Note that this token is generated regardless of any authentication,
# so if you use the same jwt_encryption_key for ooniauth, you give users
# an auth token for free
#
# We set this up in the terraform level

# client_id is a JWT token with "issued at" claim and
# "audience" claim. The "issued at" claim is rounded up.
issued_at = int(time.time())
payload = {"iat": issued_at, "aud": "probe_login"}
client_id = create_jwt(payload, key=settings.jwt_encryption_key)
log.info("register successful")

register_response = ProbeRegisterResponse(client_id=client_id)
setnocacheresponse(response)

return register_response


class ProbeUpdate(BaseModel):
pass


class ProbeUpdateResponse(BaseModel):
status: str


@router.put("/update/{client_id}", tags=["ooniprobe"])
def probe_update_post(probe_update: ProbeUpdate) -> ProbeUpdateResponse:
log.info("update successful")
return ProbeUpdateResponse(status="ok")
Empty file.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta, timezone, date
from datetime import datetime, timedelta, timezone
import random
from typing import Dict, List
import logging
Expand All @@ -7,22 +7,22 @@
from sqlalchemy.orm import Session
from fastapi import APIRouter, Depends, HTTPException

from .. import models
from ... import models

from ..utils import (
from ...utils import (
fetch_openvpn_config,
fetch_openvpn_endpoints,
format_endpoint,
upsert_endpoints,
)
from ..common.routers import BaseModel
from ..common.dependencies import get_settings
from ..dependencies import get_postgresql_session
from ...common.routers import BaseModel
from ...common.dependencies import get_settings
from ...dependencies import get_postgresql_session

router = APIRouter(prefix="/v2")

log = logging.getLogger(__name__)

router = APIRouter()
log = logging.getLogger(__name__)


class VPNConfig(BaseModel):
Expand Down Expand Up @@ -113,7 +113,7 @@ def get_or_update_riseupvpn(
raise HTTPException(status_code=500, detail="error updating provider")


@router.get("/v2/ooniprobe/vpn-config/{provider_name}", tags=["ooniprobe"])
@router.get("/ooniprobe/vpn-config/{provider_name}", tags=["ooniprobe"])
def get_vpn_config(
provider_name: str,
db=Depends(get_postgresql_session),
Expand Down
49 changes: 31 additions & 18 deletions ooniapi/services/ooniprobe/src/ooniprobe/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Insert VPN credentials into database.
"""

import base64
from datetime import datetime, timezone
import itertools
Expand All @@ -27,11 +28,13 @@ class OpenVPNConfig(TypedDict):
cert: str
key: str


class OpenVPNEndpoint(TypedDict):
address: str
protocol: str
transport: str


def fetch_riseup_ca() -> str:
r = httpx.get(RISEUP_CA_URL)
r.raise_for_status()
Expand All @@ -50,6 +53,7 @@ def fetch_openvpn_config() -> OpenVPNConfig:
key, cert = pem.parse(pem_cert)
return OpenVPNConfig(ca=ca, cert=cert.as_text(), key=key.as_text())


def fetch_openvpn_endpoints() -> List[OpenVPNEndpoint]:
endpoints = []

Expand All @@ -59,38 +63,47 @@ def fetch_openvpn_endpoints() -> List[OpenVPNEndpoint]:
for ep in j["gateways"]:
ip = ep["ip_address"]
# TODO(art): do we want to store this metadata somewhere?
#location = ep["location"]
#hostname = ep["host"]
# location = ep["location"]
# hostname = ep["host"]
for t in ep["capabilities"]["transport"]:
if t["type"] != "openvpn":
continue
for transport, port in itertools.product(t["protocols"], t["ports"]):
endpoints.append(OpenVPNEndpoint(
address=f"{ip}:{port}",
protocol="openvpn",
transport=transport
))
endpoints.append(
OpenVPNEndpoint(
address=f"{ip}:{port}", protocol="openvpn", transport=transport
)
)
return endpoints


def format_endpoint(provider_name: str, ep: OONIProbeVPNProviderEndpoint) -> str:
return f"{ep.protocol}://{provider_name}.corp/?address={ep.address}&transport={ep.transport}"

def upsert_endpoints(db: Session, new_endpoints: List[OpenVPNEndpoint], provider: OONIProbeVPNProvider):
new_endpoints_map = {f'{ep["address"]}-{ep["protocol"]}-{ep["transport"]}': ep for ep in new_endpoints}

def upsert_endpoints(
db: Session, new_endpoints: List[OpenVPNEndpoint], provider: OONIProbeVPNProvider
):
new_endpoints_map = {
f'{ep["address"]}-{ep["protocol"]}-{ep["transport"]}': ep
for ep in new_endpoints
}
for endpoint in provider.endpoints:
key = f'{endpoint.address}-{endpoint.protocol}-{endpoint.transport}'
key = f"{endpoint.address}-{endpoint.protocol}-{endpoint.transport}"
if key in new_endpoints_map:
endpoint.date_updated = datetime.now(timezone.utc)
new_endpoints_map.pop(key)
else:
db.delete(endpoint)

for ep in new_endpoints_map.values():
db.add(OONIProbeVPNProviderEndpoint(
date_created=datetime.now(timezone.utc),
date_updated=datetime.now(timezone.utc),
protocol=ep["protocol"],
address=ep["address"],
transport=ep["transport"],
provider=provider
))
db.add(
OONIProbeVPNProviderEndpoint(
date_created=datetime.now(timezone.utc),
date_updated=datetime.now(timezone.utc),
protocol=ep["protocol"],
address=ep["address"],
transport=ep["transport"],
provider=provider,
)
)
10 changes: 9 additions & 1 deletion ooniapi/services/ooniprobe/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,21 @@ def client_with_bad_settings():
yield client


JWT_ENCRYPTION_KEY = "super_secure"


@pytest.fixture
def client(alembic_migration):
app.dependency_overrides[get_settings] = make_override_get_settings(
postgresql_url=alembic_migration,
jwt_encryption_key="super_secure",
jwt_encryption_key=JWT_ENCRYPTION_KEY,
prometheus_metrics_password="super_secure",
)

client = TestClient(app)
yield client


@pytest.fixture
def jwt_encryption_key():
return JWT_ENCRYPTION_KEY
Loading

0 comments on commit 7cb00cf

Please sign in to comment.