Skip to content

Commit

Permalink
Merge pull request #267 from camptocamp/cookie
Browse files Browse the repository at this point in the history
Can use a cookie to authenticate the client
  • Loading branch information
Patrick Valsecchi authored Jan 23, 2019
2 parents 5b87da0 + bca504c commit d6a911c
Show file tree
Hide file tree
Showing 8 changed files with 93 additions and 74 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ A few REST APIs are added and can be seen with this URL (only enabled if C2C_BAS
`{C2C_BASE_PATH}`.

Some APIs are protected by a secret. This secret is specified in the `C2C_SECRET` variable or `c2c.secret`
property. It is either passed as the `secret` query parameter or the `X-API-Key` header.
property. It is either passed as the `secret` query parameter or the `X-API-Key` header. Once
accessed with a good secret, a cookie is stored and the secret can be omitted.


## Pyramid
Expand Down
5 changes: 5 additions & 0 deletions acceptance_tests/tests/tests/test_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ def test_with_secret(app_connection):
assert "Health checks" in content
assert "Debug" in content

# a cookie should keep us logged in
app_connection.get('c2c')
assert "Health checks" in content
assert "Debug" in content


def test_https(app_connection):
content = app_connection.get('c2c', params={'secret': 'changeme'},
Expand Down
60 changes: 44 additions & 16 deletions c2cwsgiutils/auth.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,64 @@
import hashlib
from pyramid.httpexceptions import HTTPForbidden
import pyramid.request
from typing import Optional
from typing import Optional, Any

# noinspection PyProtectedMember
from c2cwsgiutils._utils import env_or_settings, env_or_config, config_bool

COOKIE_AGE = 7 * 24 * 3600
SECRET_PROP = 'c2c.secret'
SECRET_ENV = 'C2C_SECRET'


def _get_secret(settings: dict, env_name: Optional[str]=None, config_name: Optional[str]=None) -> str:
secret = env_or_settings(settings, env_name, config_name) if \
env_name is not None or config_name is not None else None
if secret is None:
secret = env_or_settings(settings, SECRET_ENV, SECRET_PROP, False)
return secret
def get_expected_secret(request: pyramid.request.Request) -> str:
"""
Returns the secret expected from the client.
"""
settings = request.registry.settings
return env_or_settings(settings, SECRET_ENV, SECRET_PROP, False)


def is_auth(
request: pyramid.request.Request,
env_name: Optional[str]=None, config_name: Optional[str]=None) -> bool:
def _hash_secret(secret: str) -> str:
return hashlib.sha256(secret.encode()).hexdigest()


def is_auth(request: pyramid.request.Request, env_name: Any=None, config_name: Any=None) -> bool:
"""
Check if the client is authenticated with the C2C_SECRET
"""
expected = get_expected_secret(request)
secret = request.params.get('secret')
if secret is None:
secret = request.headers.get('X-API-Key')
return secret == _get_secret(request.registry.settings, env_name, config_name)

if secret is not None:
if secret == "":
# logout
request.response.delete_cookie(SECRET_ENV)
return False
if secret != expected:
return False
# login or refresh the cookie
request.response.set_cookie(SECRET_ENV, _hash_secret(secret), max_age=COOKIE_AGE,
httponly=True)
# since this could be used from outside c2cwsgiutils views, we cannot set the path to c2c
return True

# secret not found in the params or the headers => try with the cookie

secret = request.cookies.get(SECRET_ENV)
if secret is not None:
if secret != _hash_secret(expected):
return False
request.response.set_cookie(SECRET_ENV, secret, max_age=COOKIE_AGE, httponly=True)
return True
return False


def auth_view(
request: pyramid.request.Request,
env_name: Optional[str]=None, config_name: Optional[str]=None) -> None:
if not is_auth(request, env_name, config_name):
raise HTTPForbidden('Missing or invalid secret (parameter or X-API-Key header)')
def auth_view(request: pyramid.request.Request, env_name: Any=None, config_name: Any=None) -> None:
if not is_auth(request):
raise HTTPForbidden('Missing or invalid secret (parameter, X-API-Key header or cookie)')


def is_enabled(
Expand Down
17 changes: 7 additions & 10 deletions c2cwsgiutils/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,14 @@

from c2cwsgiutils import _utils, auth, broadcast

DEPRECATED_CONFIG_KEY = 'c2c.debug_view_secret'
DEPRECATED_ENV_KEY = 'DEBUG_VIEW_SECRET'
CONFIG_KEY = 'c2c.debug_view_enabled'
ENV_KEY = 'C2C_DEBUG_VIEW_ENABLED'

LOG = logging.getLogger(__name__)


def _dump_stacks(request: pyramid.request.Request) -> List[Mapping[str, Any]]:
auth.auth_view(request, DEPRECATED_ENV_KEY, DEPRECATED_CONFIG_KEY)
auth.auth_view(request)
result = broadcast.broadcast('c2c_dump_stacks', expect_answers=True)
assert result is not None
return _beautify_stacks(result)
Expand Down Expand Up @@ -70,15 +68,15 @@ def _dump_stacks_impl() -> Dict[str, Any]:


def _dump_memory(request: pyramid.request.Request) -> List[Mapping[str, Any]]:
auth.auth_view(request, DEPRECATED_ENV_KEY, DEPRECATED_CONFIG_KEY)
auth.auth_view(request)
limit = int(request.params.get('limit', '30'))
result = broadcast.broadcast('c2c_dump_memory', params={'limit': limit}, expect_answers=True)
assert result is not None
return result


def _dump_memory_diff(request: pyramid.request.Request) -> List:
auth.auth_view(request, DEPRECATED_ENV_KEY, DEPRECATED_CONFIG_KEY)
auth.auth_view(request)
limit = int(request.params.get('limit', '30'))
if 'path' in request.matchdict:
# deprecated
Expand Down Expand Up @@ -135,20 +133,20 @@ def _dump_memory_impl(limit: int) -> Mapping[str, Any]:


def _sleep(request: pyramid.request.Request) -> pyramid.response.Response:
auth.auth_view(request, DEPRECATED_ENV_KEY, DEPRECATED_CONFIG_KEY)
auth.auth_view(request)
timeout = float(request.params['time'])
time.sleep(timeout)
request.response.status_code = 204
return request.response


def _headers(request: pyramid.request.Request) -> Mapping[str, str]:
auth.auth_view(request, DEPRECATED_ENV_KEY, DEPRECATED_CONFIG_KEY)
auth.auth_view(request)
return dict(request.headers)


def _error(request: pyramid.request.Request) -> Any:
auth.auth_view(request, DEPRECATED_ENV_KEY, DEPRECATED_CONFIG_KEY)
auth.auth_view(request)
raise exception_response(int(request.params['status']), detail="Test")


Expand All @@ -159,8 +157,7 @@ def _add_view(config: pyramid.config.Configurator, name: str, path: str, view: C


def init(config: pyramid.config.Configurator) -> None:
if _utils.env_or_config(config, DEPRECATED_ENV_KEY, DEPRECATED_CONFIG_KEY, False) or \
auth.is_enabled(config, ENV_KEY, CONFIG_KEY):
if auth.is_enabled(config, ENV_KEY, CONFIG_KEY):
broadcast.subscribe('c2c_dump_memory', _dump_memory_impl)
broadcast.subscribe('c2c_dump_stacks', _dump_stacks_impl)

Expand Down
2 changes: 1 addition & 1 deletion c2cwsgiutils/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def _http_error(exception: HTTPException, request: pyramid.request.Request) -> A


def _include_dev_details(request: pyramid.request.Request) -> bool:
return DEVELOPMENT or auth.is_auth(request, DEPRECATED_ENV_KEY, DEPRECATED_CONFIG_KEY)
return DEVELOPMENT or auth.is_auth(request)


def _integrity_error(exception: sqlalchemy.exc.StatementError,
Expand Down
66 changes: 30 additions & 36 deletions c2cwsgiutils/index.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import html
import pyramid.config
import pyramid.request
import pyramid.response
from typing import Optional, List # noqa # pylint: disable=unused-import
from urllib.parse import quote_plus
from c2cwsgiutils.auth import is_auth
from c2cwsgiutils.debug import DEPRECATED_ENV_KEY, DEPRECATED_CONFIG_KEY
from c2cwsgiutils.auth import is_auth, get_expected_secret

from . import _utils

Expand All @@ -22,10 +19,10 @@ def _url(request: pyramid.request.Request, route: str) -> Optional[str]:


def _index(request: pyramid.request.Request) -> pyramid.response.Response:
secret = request.params.get('secret')
auth = is_auth(request, DEPRECATED_ENV_KEY, DEPRECATED_CONFIG_KEY)

response = request.response

auth = is_auth(request)

response.content_type = 'text/html'
response.text = """
<html>
Expand All @@ -34,28 +31,38 @@ def _index(request: pyramid.request.Request) -> pyramid.response.Response:
<body>
"""

response.text += "<h1>Authentication</h1>"
if not auth:
response.text += """
<form>
secret: <input type="text" name="secret">
<input type="submit" value="OK">
<input type="submit" value="Login">
</form>
"""
else:
response.text += """
<form>
<input type="hidden" name="secret" value="">
<input type="submit" value="Logout">
</form>
"""

response.text += _health_check(request)
response.text += _stats(request)
response.text += _versions(request)
if auth:
response.text += _debug(request, secret)
response.text += _logging(request, secret)
response.text += _sql_profiler(request, secret)
response.text += _debug(request)
response.text += _logging(request)
response.text += _sql_profiler(request)

if additional_title is not None and (auth or len(additional_noauth) > 0):
response.text += additional_title
response.text += "\n"

if auth:
secret = get_expected_secret(request)
response.text += "\n".join([e.format(
# TODO: remove both for v3 (issue #177)
secret=secret,
secret_qs=("secret=" + secret) if secret is not None else "",
) for e in additional_auth])
Expand Down Expand Up @@ -91,23 +98,22 @@ def _stats(request: pyramid.request.Request) -> str:
return ""


def _sql_profiler(request: pyramid.request.Request, secret: str) -> str:
def _sql_profiler(request: pyramid.request.Request) -> str:
sql_profiler_url = _url(request, 'c2c_sql_profiler')
if sql_profiler_url:
return """
<h1>SQL profiler</h1>
<a href="{url}{secret_query_sting}" target="_blank">status</a>
<a href="{url}{secret_query_sting}enable=1" target="_blank">enable</a>
<a href="{url}{secret_query_sting}enable=0" target="_blank">disable</a>
<a href="{url}" target="_blank">status</a>
<a href="{url}?enable=1" target="_blank">enable</a>
<a href="{url}?enable=0" target="_blank">disable</a>
""".format(
url=sql_profiler_url,
secret_query_sting="?" if secret is None else "?secret={}&".format(quote_plus(secret)),
url=sql_profiler_url
)
else:
return ""


def _logging(request: pyramid.request.Request, secret: str) -> str:
def _logging(request: pyramid.request.Request) -> str:
logging_url = _url(request, 'c2c_logging_level')
if logging_url:
return """
Expand All @@ -116,54 +122,45 @@ def _logging(request: pyramid.request.Request, secret: str) -> str:
<li><form action="{logging_url}" target="_blank">
<input type="submit" value="Get">
name: <input type="text" name="name" value="c2cwsgiutils">
{secret_input}
</form></li>
<li><form action="{logging_url}" target="_blank">
<input type="submit" value="Set">
name: <input type="text" name="name" value="c2cwsgiutils">
level: <input type="text" name="level" value="INFO">
{secret_input}
</form></li>
<li><a href="{logging_url}{secret_query_sting}" target="_blank">List overrides</a>
<li><a href="{logging_url}" target="_blank">List overrides</a>
</ul>
""".format(
logging_url=logging_url,
secret_input="" if secret is None else
'<input type="hidden" name="secret" value="{}">'.format(html.escape(secret)),
secret_query_sting="" if secret is None else "?secret=" + quote_plus(secret),
logging_url=logging_url
)
else:
return ""


def _debug(request: pyramid.request.Request, secret: str) -> str:
def _debug(request: pyramid.request.Request) -> str:
dump_memory_url = _url(request, 'c2c_debug_memory')
if dump_memory_url:
return """
<h1>Debug</h1>
<ul>
<li><a href="{dump_stack_url}{secret_query_sting}" target="_blank">Stack traces</a></li>
<li><a href="{dump_stack_url}" target="_blank">Stack traces</a></li>
<li><form action="{dump_memory_url}" target="_blank">
<input type="submit" value="Dump memory usage">
limit: <input type="text" name="limit" value="30">
{secret_input}
</form></li>
<li><form action="{memory_diff_url}" target="_blank">
<input type="submit" value="Memory diff">
path: <input type="text" name="path">
limit: <input type="text" name="limit" value="30">
{secret_input}
</form></li>
<li><form action="{sleep_url}" target="_blank">
<input type="submit" value="Sleep">
time: <input type="text" name="time" value="1">
{secret_input}
</form></li>
<li><a href="{dump_headers_url}{secret_query_sting}" target="_blank">HTTP headers</a></li>
<li><a href="{dump_headers_url}" target="_blank">HTTP headers</a></li>
<li><form action="{error_url}" target="_blank">
<input type="submit" value="Generate an HTTP error">
status: <input type="text" name="status" value="500">
{secret_input}
</form></li>
</ul>
""".format(
Expand All @@ -172,10 +169,7 @@ def _debug(request: pyramid.request.Request, secret: str) -> str:
memory_diff_url=_url(request, 'c2c_debug_memory_diff'),
sleep_url=_url(request, 'c2c_debug_sleep'),
dump_headers_url=_url(request, 'c2c_debug_headers'),
error_url=_url(request, 'c2c_debug_error'),
secret_query_sting="" if secret is None else "?secret=" + quote_plus(secret),
secret_input="" if secret is None else
'<input type="hidden" name="secret" value="{}">'.format(html.escape(secret)),
error_url=_url(request, 'c2c_debug_error')
)
else:
return ""
Expand Down
7 changes: 2 additions & 5 deletions c2cwsgiutils/logging_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
from c2cwsgiutils import _utils, auth, broadcast

LOG = logging.getLogger(__name__)
DEPRECATED_CONFIG_KEY = 'c2c.log_view_secret'
DEPRECATED_ENV_KEY = 'LOG_VIEW_SECRET'
CONFIG_KEY = 'c2c.log_view_enabled'
ENV_KEY = 'C2C_LOG_VIEW_ENABLED'
REDIS_PREFIX = 'c2c_logging_level_'
Expand All @@ -16,8 +14,7 @@ def install_subscriber(config: pyramid.config.Configurator) -> None:
"""
Install the view to configure the loggers, if configured to do so.
"""
if _utils.env_or_config(config, DEPRECATED_ENV_KEY, DEPRECATED_CONFIG_KEY, False) or \
auth.is_enabled(config, ENV_KEY, CONFIG_KEY):
if auth.is_enabled(config, ENV_KEY, CONFIG_KEY):
config.add_route("c2c_logging_level", _utils.get_base_path(config) + r"/logging/level",
request_method="GET")
config.add_view(_logging_change_level, route_name="c2c_logging_level", renderer="fast_json",
Expand All @@ -27,7 +24,7 @@ def install_subscriber(config: pyramid.config.Configurator) -> None:


def _logging_change_level(request: pyramid.request.Request) -> Mapping[str, Any]:
auth.auth_view(request, DEPRECATED_ENV_KEY, DEPRECATED_CONFIG_KEY)
auth.auth_view(request)
name = request.params.get('name')
if name is not None:
level = request.params.get('level')
Expand Down
Loading

0 comments on commit d6a911c

Please sign in to comment.