Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -U flake8 setuptools
pip install -U openapi-core uwsgi simplejson WSocket
pip install -U openapi-core uwsgi simplejson WSocket PyJWT
pip install -U pytest pytest-doctestplus pytest-pylint pytest-mypy requests websocket-client
pip install -U types-simplejson types-requests types-PyYAML
- name: Lint with flake8
Expand Down
9 changes: 9 additions & 0 deletions doc/ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
* Updated openapi3.py example to openapi-core 0.23+ API (Spec replaced
by OpenAPI)
* Drop Python 3.9 and 3.10 support, require Python >= 3.11
* Session base class - plain cookie wrapper for server-side session IDs or
JWTs; PoorSession inherits from Session
* PoorSession cookie encryption - ! existing cookies are invalidated !
- Self-contained encrypted session cookie using shake_256 XOF keystream
(1024 B) and byte-substitution (no external dependencies)
- HMAC-SHA256 authentication (Encrypt-then-MAC); tampered or forged
cookies are rejected
- Domain-separated key derivation for keystream, MAC and permutation
- Users will be logged out after upgrading from a previous version

==== 2.7.0 ====
* Reserved Request.db attribute for usage
Expand Down
78 changes: 72 additions & 6 deletions doc/documentation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1050,11 +1050,77 @@ multiple times.

Sessions
~~~~~~~~
Like mod_python, PoorSession is the session class of PoorWSGI. It's a
self-contained cookie with a data dictionary. Data are sent to the client
in a hidden, bzip2-compressed, base64-encoded format. PoorSession needs a ``secret_key``,
which can be set by the ``poor_SecretKey`` environment variable to the
Application.secret_key property.
PoorWSGI provides a ``Session`` base class and ``PoorSession`` which extends
it. Both share the same interface.

Session
```````
``Session`` is a thin wrapper around ``http.cookies.SimpleCookie``. It is
suitable when the cookie value is either a **server-side session ID** (the
server holds the real data) or a **JWT** (which provides its own signature).
No encryption is applied — the value is stored as-is in the cookie.

.. code:: python

from poorwsgi.session import Session

# Store a session-id issued by the server
session = Session(sid="SESSID", secure=True, same_site="Lax")
session.data = generate_session_id() # any string value
session.write()
session.header(response)

# Read back
session = Session()
session.load(req.cookies)
server_data = server_store[session.data]

The ``Session`` class accepts the following keyword arguments:
``sid``, ``expires``, ``max_age``, ``domain``, ``path``, ``secure``,
``same_site``. It exposes ``load()``, ``write()``, ``destroy()``, and
``header()`` methods.

.. note::

``Session`` does **not** encrypt or sign the cookie value. If you store a
predictable token, an attacker can forge it. Use a cryptographically random
session ID or a properly signed JWT as the value.

PoorSession
```````````
``PoorSession`` extends ``Session`` and stores data as an **encrypted and
authenticated** dictionary directly in the cookie. PoorSession needs a
``secret_key``, which can be set via the ``poor_SecretKey`` environment
variable or the ``Application.secret_key`` property.

**Security model** (XOR + substitution variant, no external dependencies):

* **Integrity** — The cookie is signed with HMAC-SHA256. Any modification
by the client is detected and the cookie is rejected. An attacker cannot
forge a valid cookie without knowing the secret key.

* **Confidentiality** — The data are protected by a XOR stream cipher with a
1024-byte keystream (derived via ``shake_256``) combined with a
byte-substitution step. This is a custom construction, **not AES**.
A passive attacker who can collect a large number of cookies from different
users (roughly 512 or more) may be able to reconstruct the keystream via
a known-plaintext attack (JSON data always starts with ``{"``), and
subsequently read the contents of other cookies. **Do not store highly
sensitive data** (passwords, private keys, …) in the cookie. Store only
session identifiers, user IDs, or non-critical flags.

* **Upgrade notice** — Updating PoorWSGI to a version that changes the
encryption scheme (e.g. changes ``KEYSTREAM_SIZE``, switches from SHA-3
to shake, or adds HMAC) will **invalidate all existing cookies**. Users
will be logged out after a server restart / upgrade. This is expected
behaviour.

* **Cookie format**: ``base64(ciphertext).base64(hmac-sha256)``

The ``KEYSTREAM_SIZE`` constant in ``poorwsgi.session`` controls the keystream
length (default ``1024``). Increasing it makes known-plaintext attacks harder
at the cost of slightly larger memory usage per session instance. Changing it
invalidates all existing cookies.

.. code:: python

Expand All @@ -1074,7 +1140,7 @@ Application.secret_key property.
@wraps(fn) # using wraps make right/better /debug-info page
def handler(req):
cookie = PoorSession(app.secret_key)
cookie.load()
cookie.load(req.cookies)
if "passwd" not in cookie.data: # expires or didn't set
log.info("Login cookie not found.")
redirect("/login", message=b"Login required")
Expand Down
161 changes: 161 additions & 0 deletions examples/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""Session + JWT example.

Demonstrates ``Session`` (plain cookie) combined with PyJWT for stateless
authentication. The server issues a signed JWT on login and stores it in a
``Session`` cookie. On every protected request the JWT is read from the
cookie and verified — no server-side state is required.

Run::

pip install PyJWT
python examples/session.py

Then open http://127.0.0.1:8080 in your browser.
Login with any non-empty username and password ``secret``.
"""
import logging
from functools import wraps
from os import path, urandom
from sys import path as python_path
from time import time

import jwt # PyJWT

python_path.insert(
0, path.abspath(path.join(path.dirname(__file__), path.pardir)))

# pylint: disable=wrong-import-position
from poorwsgi import Application, state # noqa
from poorwsgi.response import HTTPException, RedirectResponse, redirect # noqa
from poorwsgi.session import Session # noqa

# pylint: disable=unused-argument

logging.getLogger().setLevel("DEBUG")

app = application = Application(__name__) # pylint: disable=invalid-name
app.debug = True

# Secret used both for the JWT signature and (optionally) session protection.
SECRET = urandom(32)
PASSWORD = "secret" # nosec # noqa: S105
JWT_EXPIRY = 3600 # seconds


# --- helpers -----------------------------------------------------------------

def get_header(title):
"""Returns HTML page header lines."""
return (
"<html>", "<head>",
'<meta http-equiv="content-type" content="text/html; charset=utf-8"/>',
f"<title>{title}</title>", "</head>", "<body>",
f"<h1>{title}</h1>")


def get_footer():
"""Returns HTML page footer lines."""
return ("<hr>", "<small>Session + JWT example — PoorWSGI</small>",
"</body>", "</html>")


def check_login(fn):
"""Decorator that reads and verifies the JWT stored in the session cookie.

Sets ``req.login`` to the JWT payload on success, otherwise redirects to
``/login``.
"""
@wraps(fn)
def handler(req):
session = Session()
session.load(req.cookies)
token = session.data
if not token:
redirect("/login", message=b"Login required")
try:
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
except jwt.PyJWTError:
redirect("/login", message=b"Session expired or invalid")
req.login = payload
return fn(req)
return handler


# --- routes ------------------------------------------------------------------

@app.route('/')
def root(_req):
"""Index page with navigation."""
body = ('<ul>',
'<li><a href="/login">/login</a> — log in</li>',
'<li><a href="/private">/private</a> — protected page</li>',
'<li><a href="/logout">/logout</a> — log out</li>',
'</ul>')
for line in get_header("Session + JWT demo") + body + get_footer():
yield line.encode() + b'\n'


@app.route('/login')
def login_form(_req):
"""GET: show login form."""
form = ('<form method="post">',
'<label>Username: '
'<input type="text" name="username"/></label><br/>',
'<label>Password: <input type="password" name="password"/>'
'</label><br/>',
'<button type="submit">Login</button>', '</form>',
'<p><em>Password is: secret</em></p>')
for line in get_header("Login") + form + get_footer():
yield line.encode() + b'\n'


@app.route('/login', method=state.METHOD_POST)
def login_post(req):
"""POST: validate credentials and issue JWT."""
username = req.form.getfirst('username', func=str) or ''
password = req.form.getfirst('password', func=str) or ''

if not username or password != PASSWORD:
redirect('/login')

token = jwt.encode(
{"sub": username, "exp": int(time()) + JWT_EXPIRY},
SECRET, algorithm="HS256")

response = RedirectResponse("/private")
session = Session(secure=False, same_site="Lax")
session.data = token
session.write()
session.header(response)
raise HTTPException(response)


@app.route('/private')
@check_login
def private(req):
"""A protected page — only accessible after login."""
user = req.login.get("sub", "?")
exp = req.login.get("exp", 0)
body = (f'<p>Hello, <strong>{user}</strong>!</p>',
f'<p>Your token expires at Unix time {exp}.</p>',
'<p><a href="/logout">Log out</a></p>')
for line in get_header("Private page") + body + get_footer():
yield line.encode() + b'\n'


@app.route('/logout')
def logout(_req):
"""Clears the session cookie."""
response = RedirectResponse("/login")
session = Session()
session.destroy()
session.header(response)
raise HTTPException(response)


if __name__ == '__main__':
from wsgiref.simple_server import make_server # noqa: E402
httpd = make_server( # pylint: disable=invalid-name
'127.0.0.1', 8080, app)
logging.info("Starting to serve on http://127.0.0.1:8080")
httpd.serve_forever()
Loading
Loading