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)