diff --git a/config.ini.sample b/config.ini.sample index 71ae4b2..538eade 100644 --- a/config.ini.sample +++ b/config.ini.sample @@ -1,122 +1,128 @@ ; ; Configuration file for OpenXCAP ; ; The values in the commented lines represent the defaults built in the ; server software ; [Server] ; IP address to listen for requests ; 0.0.0.0 means any address of this host ; address = 0.0.0.0 +; port to use. If you use https for the root it should probably be 443 +; port = 80 + + ; This is a comma separated list of XCAP root URIs. The first is the ; primary XCAP root URI, while the others (if specified) are aliases. ; The primary root URI is used when generating xcap-diff -; If the scheme is https, then the server will listen for requests in TLS mode. +; If the scheme is https, then the server will listen for requests in TLS mode +; if a valid key and certificate are provided. The server supports operating +; behind a reverse proxy if the correct forward headers are set. root = http://xcap.example.com/xcap-root ; The backend to be used for storage and authentication. Current supported ; values are Database and OpenSIPS. OpenSIPS backend inherits all the settings ; from the Database backend but performs extra actions related to the ; integration with OpenSIPS for which it read the settings from [OpenSIPS] ; section backend = OpenSIPS ; Validate XCAP documents against XML schemas ; document_validation = Yes ; Allow URIs in pres-rules and resource-lists to point to lists not served ; by this server allow_external_references = No ; List os applications that won't be enabled on the server ;disabled_applications = test-app, org.openxcap.dialog-rules [Logging] ; Start, stop and major server error messages are always logged to syslog. ; This section can be used to log more details about XCAP clients accessing ; the server. The values in the commented lines represent the defaults built ; in the server software ; Directory where to write access.log file that will contain requests and/or ; responses to OpenXCAP server in Apache style. If set to an empty string, ; access logs will be printed to stdout if the server runs in no-fork mode ; or to syslog if the server runs in the background ; directory=/var/log/openxcap ; The following parameters control the logging of requests/responses based ; on the response code. The values must be a comma-separated list of HTTP ; response codes or one of the keywords 'all' or 'none' to match all or ; no response codes respectively. Default is none. ; log_request=none ; log_response=none [Authentication] ; The HTTP authentication type, this can be either 'basic' or 'digest'. The ; standard states 'digest' as the mandatory, however it can be changed to ; basic ; type = digest ; Specify if the passwords are stored as plain text - Yes ; or in a hashed format MD5('username:domain:password') - No ; cleartext_passwords = Yes ; The default authentication realm, if none indicated in the HTTP request ; URI default_realm = example.com ; A comma-separated list of hosts or networks to trust. ; The elements can be an IP address in CIDR format, a ; hostname or an IP address (in the latter 2 a mask of 32 ; is assumed), or the special keywords 'any' and 'none' ; (being equivalent to 0.0.0.0/0 and 0.0.0.0/32 ; respectively). ; trusted_peers = [TLS] ; Location of X509 certificate and private key that identify this server. ; The path is relative to /etc/openxcap, or it can be given as an absolute ; path. ; Server X509 certificate ; certificate = ; Server X509 private key ; private_key = [Database] ; The database connection URI for the datase with subscriber accounts authentication_db_uri = mysql://opensips:opensipsrw@localhost/opensips ; The database connection URI for the database that stores the XCAP documents storage_db_uri = mysql://opensips:opensipsrw@localhost/opensips ; Authentication and storage tables ; subscriber_table = subscriber ; xcap_table = xcap [OpenSIPS] ; Publish xcap-diff event (using a SIP PUBLISH) ; publish_xcapdiff = yes ; SIP proxy where the PUBLISH will be sent ; outbound_sip_proxy = sip.example.com diff --git a/xcap/authentication/auth.py b/xcap/authentication/auth.py index b4260d8..15bd769 100644 --- a/xcap/authentication/auth.py +++ b/xcap/authentication/auth.py @@ -1,241 +1,245 @@ import base64 import hashlib import socket import struct import time from typing import Dict, Optional from uuid import uuid4 from fastapi import HTTPException, Request from xcap import __version__ from xcap.appusage import ServerConfig as Backend from xcap.appusage import namespaces, public_get_applications from xcap.configuration import AuthenticationConfig, ServerConfig from xcap.errors import ResourceNotFound from xcap.http_utils import get_client_ip from xcap.uri import XCAPUri from xcap.xpath import DocumentSelectorError, NodeParsingError # In-memory nonce cache with expiration time (for demonstration purposes) nonce_cache: Dict[int, str] = {} NONCE_EXPIRATION_TIME = 900 # 15 minutes for nonce expiration WELCOME = ('Not Found' '

Not Found

XCAP server does not serve anything ' 'directly under XCAP Root URL. You have to be more specific.' '

' '
OpenXCAP/%s
' '') % __version__ def parseNodeURI(node_uri: str, default_realm: str) -> XCAPUri: """Parses the given Node URI, containing the XCAP root, document selector, and node selector, and returns an XCAPUri instance if succesful.""" xcap_root = None for uri in ServerConfig.root.uris: if node_uri.startswith(uri): xcap_root = uri break if xcap_root is None: raise ResourceNotFound("XCAP root not found for URI: %s" % node_uri) resource_selector = node_uri[len(xcap_root):] if not resource_selector or resource_selector == '/': raise ResourceNotFound(WELCOME, "text/html") try: r = XCAPUri(xcap_root, resource_selector, namespaces) except NodeParsingError as e: raise HTTPException(status_code=e.status_code, detail=e.args) except DocumentSelectorError as e: raise HTTPException(status_code=e.status_code, detail=e.args) if r.user.domain is None: r.user.domain = default_realm return r class Credentials(object): def __init__(self, username, password=None, realm=None): self.username = username self.password = password self.realm = realm @property def hash(self): return hashlib.md5('{0.username}:{0.realm}:{0.password}'.format(self).encode()).hexdigest() def checkPassword(self, password: bytes) -> bool: return self.password == password def checkHash(self, digestHash): return digestHash == self.hash def is_valid(self, user): if AuthenticationConfig.cleartext_passwords: return self.checkPassword(user.password) else: return self.checkHash(user.ha1) class AuthenticationManager: def __init__(self): self.nonce_cache = nonce_cache self.trusted_peers = AuthenticationConfig.trusted_peers # Helper function to generate a nonce def generate_nonce(self) -> str: """Generate a new nonce (typically a random string with a timestamp).""" timestamp = int(time.time()) unique_nonce = f"{timestamp}-{uuid4()}" return base64.b64encode(unique_nonce.encode()).decode("utf-8") # Helper function to create the Digest response hash async def create_digest_response(self, username: str, nonce: str, uri: str, method: str, realm: str, cnonce: str, nc: str, qop: str) -> str: credentials = Credentials(username.split('@', 1)[0], realm=realm) user = await Backend.backend.PasswordChecker().query_user(credentials) if user is None: raise HTTPException(status_code=401, detail="Invalid credentials") ha1 = user[0].ha1 if AuthenticationConfig.cleartext_passwords: credentials.password = user[0].password ha1 = credentials.hash # Compute the ha2 hash (method:uri) ha2 = hashlib.md5(f"{method}:{uri}".encode()).hexdigest() # Compute the final response using the formula: MD5(HA1:nonce:nc:cnonce:qop:HA2) response = hashlib.md5(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode()).hexdigest() return response # Function to validate and clean up expired nonces def validate_nonce(self, nonce: str) -> bool: """Check if the nonce is valid and not expired.""" if nonce not in self.nonce_cache: return False timestamp, _ = self.nonce_cache[nonce] current_time = int(time.time()) if current_time - timestamp > NONCE_EXPIRATION_TIME: # Nonce expired, remove it from the cache del self.nonce_cache[nonce] return False return True # Digest Authentication Dependency async def digest_auth(self, request: Request, realm: str) -> None: auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Digest "): nonce = self.generate_nonce() opaque = "eee38d7sacbefv2a3450ciny7QMkPqMAFRtzCUYo5tdS" self.nonce_cache[nonce] = (int(time.time()), opaque) # Store nonce with timestamp www_authenticate_header = ( f'Digest realm="{realm}", nonce="{nonce}", opaque={opaque}, algorithm=MD5, qop=auth' ) raise HTTPException( status_code=401, detail="Digest authentication required", headers={"WWW-Authenticate": www_authenticate_header}, ) # Parse the Digest fields from the header try: auth_fields = {k: v.strip('"') for k, v in (field.split("=", 1) for field in auth_header[7:].split(", "))} except ValueError: raise HTTPException(status_code=401, detail="Invalid Digest authentication format") required_fields = ["username", "nonce", "response", "uri", "qop", "cnonce", "nc"] for field in required_fields: if field not in auth_fields: raise HTTPException(status_code=401, detail=f"Missing required Digest field: {field}") username = auth_fields["username"] nonce = auth_fields["nonce"] response = auth_fields["response"] uri = auth_fields['uri'] method = request.method cnonce = auth_fields['cnonce'] nc = auth_fields['nc'] qop = auth_fields['qop'] # Use the database session to check for user if not self.validate_nonce(nonce): raise HTTPException(status_code=401, detail="Invalid or expired nonce") # Validate the Digest response expected_response = await self.create_digest_response(username, nonce, uri, method, realm, cnonce, nc, qop) # expected_response = self.create_digest_response(username, nonce, uri, method, db, realm) if response != expected_response: raise HTTPException(status_code=401, detail="Invalid credentials or response") async def basic_auth(self, request: Request, realm: str) -> None: auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Basic "): raise HTTPException( status_code=401, detail="Basic authentication required", headers={"WWW-Authenticate": f"Basic realm=\"{realm}\""} ) auth_value = auth_header[6:].strip() decoded_value = base64.b64decode(auth_value).decode("utf-8") username, password = decoded_value.split(":") credentials = Credentials(username, password, realm) user = await Backend.backend.PasswordChecker().query_user(credentials) if user is None or not credentials.is_valid(user[0]): raise HTTPException( status_code=401, detail="Invalid credentials" ) # Function to check if the client IP is in the trusted peers list def is_ip_trusted(self, client_ip: Optional[str]) -> bool: """Check if the client IP is in the trusted peers list.""" if not self.trusted_peers or client_ip is None: return False # Iterate through each network range in the trusted_parties list for range in self.trusted_peers: # Convert the IP address to a 32-bit integer ip_int = struct.unpack('!L', socket.inet_aton(client_ip))[0] # Perform the bitwise comparison (IP address & network mask == base address) if ip_int & range[1] == range[0]: return True # If the IP address does not match any range, return False return False async def authenticate_request(self, request: Request) -> XCAPUri: """Authenticate a request by checking IP and applying Digest or Basic authentication as needed.""" client_ip = get_client_ip(request) - xcap_uri = parseNodeURI(str(request.url), AuthenticationConfig.default_realm) + proto = request.headers.get("X-Forwarded-Proto", request.url.scheme) + host = request.headers.get("X-Forwarded-Host", request.headers.get("Host", request.url.hostname)) + + full_url = f"{proto}://{host}{request.url.path}" + xcap_uri = parseNodeURI(str(full_url), AuthenticationConfig.default_realm) if xcap_uri.doc_selector.context == 'global': return xcap_uri realm = xcap_uri.user.domain if realm is None: raise ResourceNotFound('Unknown domain (the domain part of "username@domain" is required because this server has no default domain)') if request.method == "GET" and xcap_uri.application_id in public_get_applications: return xcap_uri if self.is_ip_trusted(client_ip): return xcap_uri if AuthenticationConfig.type == 'digest': await self.digest_auth(request, realm) elif AuthenticationConfig.type == 'basic': await self.basic_auth(request, realm) else: raise ValueError('Invalid authentication type: %r. Please check the configuration.' % AuthenticationConfig.type) return xcap_uri diff --git a/xcap/configuration/__init__.py b/xcap/configuration/__init__.py index 3c39c8b..98a3f50 100644 --- a/xcap/configuration/__init__.py +++ b/xcap/configuration/__init__.py @@ -1,80 +1,80 @@ from application.configuration import ConfigSection, ConfigSetting from application.configuration.datatypes import IPAddress, NetworkRangeList from xcap.configuration.datatypes import (DatabaseURI, Path, ResponseCodeList, XCAPRootURI) from xcap.tls import Certificate, PrivateKey class AuthenticationConfig(ConfigSection): __cfgfile__ = 'config.ini' __section__ = 'Authentication' type = 'digest' cleartext_passwords = True default_realm = ConfigSetting(type=str, value=None) trusted_peers = ConfigSetting(type=NetworkRangeList, value=NetworkRangeList('none')) class ServerConfig(ConfigSection): __cfgfile__ = 'config.ini' __section__ = 'Server' address = ConfigSetting(type=IPAddress, value='0.0.0.0') - port = ConfigSetting(type=int, value=8000) + port = ConfigSetting(type=int, value=80) root = ConfigSetting(type=XCAPRootURI, value=None) backend = ConfigSetting(type=str, value=None) allow_external_references = False tcp_port = ConfigSetting(type=int, value=35060) class TLSConfig(ConfigSection): __cfgfile__ = 'config.ini' __section__ = 'TLS' certificate = ConfigSetting(type=Certificate, value=None) private_key = ConfigSetting(type=PrivateKey, value=None) class DatabaseConfig(ConfigSection): __cfgfile__ = 'config.ini' __section__ = 'Database' authentication_db_uri = ConfigSetting(type=DatabaseURI, value=None) storage_db_uri = ConfigSetting(type=DatabaseURI, value=None) subscriber_table = 'subscriber' user_col = 'username' domain_col = 'domain' password_col = 'password' ha1_col = 'ha1' xcap_table = 'xcap' class OpensipsConfig(ConfigSection): __cfgfile__ = 'config.ini' __section__ = 'OpenSIPS' publish_xcapdiff = False outbound_sip_proxy = '' class LoggingConfig(ConfigSection): __cfgfile__ = 'config.ini' __section__ = 'Logging' directory = ConfigSetting(type=Path, value=Path('/var/log/openxcap')) log_request = ConfigSetting(type=ResponseCodeList, value=ResponseCodeList('none')) log_response = ConfigSetting(type=ResponseCodeList, value=ResponseCodeList('none')) class ThorNodeConfig(ConfigSection): __cfgfile__ = "config.ini" __section__ = 'ThorNetwork' domain = "" multiply = 1000 certificate = ConfigSetting(type=Certificate, value=None) private_key = ConfigSetting(type=PrivateKey, value=None) ca = ConfigSetting(type=Certificate, value=None) diff --git a/xcap/server.py b/xcap/server.py index 7a9bf41..e99f1ba 100644 --- a/xcap/server.py +++ b/xcap/server.py @@ -1,150 +1,149 @@ -import sys import threading from datetime import datetime import uvicorn from application import log from fastapi import FastAPI, Request, Response from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse from starlette.background import BackgroundTask, BackgroundTasks from starlette.middleware.base import BaseHTTPMiddleware from twisted.internet import asyncioreactor, reactor from xcap import __description__, __name__, __version__ from xcap.configuration import ServerConfig, TLSConfig from xcap.db.initialize import init_db from xcap.errors import HTTPError, ResourceNotFound, XCAPError from xcap.log import AccessLogRequest, AccessLogResponse, log_access class LogRequestMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): body = await request.body() request.state.body = body response = await call_next(request) response.headers['Date'] = datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT') chunks = [] async for chunk in response.body_iterator: chunks.append(chunk) res_body = b''.join(chunks) request_log = AccessLogRequest(dict(request.headers), body, response.status_code) response_log = AccessLogResponse(dict(response.headers), res_body, response.status_code) task = BackgroundTasks() task.add_task(BackgroundTask(log_access, request, response, res_body)) task.add_task(BackgroundTask(request_log.log)) task.add_task(BackgroundTask(response_log.log)) return Response(content=res_body, status_code=response.status_code, headers=dict(response.headers), media_type=response.media_type, background=task) class XCAPApp(FastAPI): backend: str = '' def __init__(self): super().__init__(title=__name__, description=__description__, version=__version__) self.add_middleware(LogRequestMiddleware) from xcap.routes import xcap_routes self.include_router(xcap_routes.router) # self.app.include_router(user_routes.router) # Uncomment if user_routes is needed self.on_event("startup")(self.startup) self.on_event("shutdown")(self.shutdown_reactor) self.add_exception_handler(ResourceNotFound, self.resource_not_found_handler) self.add_exception_handler(HTTPError, self.http_error_handler) self.add_exception_handler(XCAPError, self.http_error_handler) self.add_api_route("/", self.read_root, methods=["GET"]) async def http_error_handler(self, request: Request, exc: HTTPError) -> Response: return exc.response async def resource_not_found_handler(self, request: Request, exc: ResourceNotFound) -> Response: if exc.headers: content_type = exc.headers.get("Content-Type", "text/plain") if content_type == "application/json": return JSONResponse( content={"detail": exc.detail}, status_code=exc.status_code, headers=exc.headers ) elif content_type == "text/html": return HTMLResponse( content=f"

{exc.detail}

", status_code=exc.status_code, headers=exc.headers ) else: # Default to plain text if no valid Content-Type is provided return PlainTextResponse( content=exc.detail, status_code=exc.status_code, headers=exc.headers ) async def startup(self): uvi_logger = log.get_logger('uvicorn.error') log.get_logger().setLevel(uvi_logger.level) log.Formatter.prefix_format = '{record.levelname:<8s} ' log.get_logger('aiosqlite').setLevel(log.level.INFO) init_db() if ServerConfig.backend in ['SIPThor', 'OpenSIPS']: twisted_thread = threading.Thread(target=self._start_reactor, daemon=True) twisted_thread.name = 'TwistedReactor' twisted_thread.start() self.backend = ServerConfig.backend log.info("OpenXCAP app is running...") async def shutdown_reactor(self): if reactor.running: if self.backend == 'SIPThor': from xcap.appusage import ServerConfig ServerConfig.backend.XCAPProvisioning().stop() else: reactor.callFromThread(reactor.stop) def _start_reactor(self): from xcap.appusage import ServerConfig reactor.run(installSignalHandlers=ServerConfig.backend.installSignalHandlers) async def read_root(self): return {"message": "Welcome to OpenXCAP!"} class XCAPServer(): def __init__(self): self.config = ServerConfig def run(self, debug=False): log_config = uvicorn.config.LOGGING_CONFIG log_config["loggers"]["uvicorn"] = {"handlers": []} log_config["loggers"]["uvicorn.error"] = {"handlers": []} log_config["loggers"]["uvicorn.access"] = {"handlers": []} config = { 'factory': True, 'host': self.config.address, - 'port': self.config.root.port, + 'port': self.config.port, 'reload': debug, 'log_level': 'debug' if debug else 'info', 'workers': 1, 'access_log': False, 'log_config': log_config } + certificate, private_key = TLSConfig.certificate, TLSConfig.private_key if self.config.root.startswith('https'): - certificate, private_key = TLSConfig.certificate, TLSConfig.private_key if certificate is None or private_key is None: log.critical('The TLS certificate/key could not be loaded') - sys.exit(1) - - config['ssl_certfile'] = certificate.filename - config['ssl_keyfile'] = private_key.filename + else: + log.info('Enabling HTTPS') + config['ssl_certfile'] = certificate.filename + config['ssl_keyfile'] = private_key.filename uvicorn.run("xcap.server:XCAPApp", **config)