Skip to content

Commit c9fecab

Browse files
Initial commit
0 parents  commit c9fecab

File tree

6 files changed

+398
-0
lines changed

6 files changed

+398
-0
lines changed

.gitignore

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# Created by .ignore support plugin (hsz.mobi)
2+
### Python template
3+
# Byte-compiled / optimized / DLL files
4+
__pycache__/
5+
*.py[cod]
6+
*$py.class
7+
8+
# C extensions
9+
*.so
10+
11+
# Distribution / packaging
12+
.Python
13+
build/
14+
develop-eggs/
15+
dist/
16+
downloads/
17+
eggs/
18+
.eggs/
19+
lib/
20+
lib64/
21+
parts/
22+
sdist/
23+
var/
24+
wheels/
25+
pip-wheel-metadata/
26+
share/python-wheels/
27+
*.egg-info/
28+
.installed.cfg
29+
*.egg
30+
MANIFEST
31+
32+
# PyInstaller
33+
# Usually these files are written by a python script from a template
34+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
35+
*.manifest
36+
*.spec
37+
38+
# Installer logs
39+
pip-log.txt
40+
pip-delete-this-directory.txt
41+
42+
# Unit test / coverage reports
43+
htmlcov/
44+
.tox/
45+
.nox/
46+
.coverage
47+
.coverage.*
48+
.cache
49+
nosetests.xml
50+
coverage.xml
51+
*.cover
52+
*.py,cover
53+
.hypothesis/
54+
.pytest_cache/
55+
cover/
56+
57+
# Translations
58+
*.mo
59+
*.pot
60+
61+
# Django stuff:
62+
*.log
63+
local_settings.py
64+
db.sqlite3
65+
db.sqlite3-journal
66+
67+
# Flask stuff:
68+
instance/
69+
.webassets-cache
70+
71+
# Scrapy stuff:
72+
.scrapy
73+
74+
# Sphinx documentation
75+
docs/_build/
76+
77+
# PyBuilder
78+
.pybuilder/
79+
target/
80+
81+
# Jupyter Notebook
82+
.ipynb_checkpoints
83+
84+
# IPython
85+
profile_default/
86+
ipython_config.py
87+
88+
# pyenv
89+
# For a library or package, you might want to ignore these files since the code is
90+
# intended to run in multiple environments; otherwise, check them in:
91+
# .python-version
92+
93+
# pipenv
94+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
95+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
96+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
97+
# install all needed dependencies.
98+
#Pipfile.lock
99+
100+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
101+
__pypackages__/
102+
103+
# Celery stuff
104+
celerybeat-schedule
105+
celerybeat.pid
106+
107+
# SageMath parsed files
108+
*.sage.py
109+
110+
# Environments
111+
.env
112+
.venv
113+
env/
114+
venv/
115+
ENV/
116+
env.bak/
117+
venv.bak/
118+
119+
# Spyder project settings
120+
.spyderproject
121+
.spyproject
122+
123+
# Rope project settings
124+
.ropeproject
125+
126+
# mkdocs documentation
127+
/site
128+
129+
# mypy
130+
.mypy_cache/
131+
.dmypy.json
132+
dmypy.json
133+
134+
# Pyre type checker
135+
.pyre/
136+
137+
# pytype static type analyzer
138+
.pytype/
139+
140+
# Cython debug symbols
141+
cython_debug/
142+
143+
.idea/
144+
.vscode/
145+
virtualenv/
146+
docs/build_*
147+
.DS_Store

LICENSE

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Copyright 2020 Steve McCartney
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Flask-SSS
2+
3+
Server-Side Sessions for Flask implemented as a SessionInterface.
4+
5+
## Data model
6+
7+
The user_id field allows for users to either list their open sessions and close other sessions they have open. It also enables administrators to log out all sessions for a user that needs to be suspended or deleted.
8+
9+
## Usage
10+
11+
```python
12+
from flask import Flask
13+
from flask_sss import SQLAlchemySessionInterface
14+
from flask_sqlalchemy import SQLAlchemy
15+
from sqlalchemy import Column, String, DateTime, Text
16+
from datetime import datetime
17+
import uuid
18+
19+
app = Flask(__name__)
20+
db = SQLAlchemy(app)
21+
22+
class UserSession(db.Model):
23+
__tablename__ = "user_session"
24+
25+
id = Column(String(length=255), primary_key=True)
26+
session_id = Column(String(length=255), unique=True)
27+
user_id = Column(String(length=255), nullable=True)
28+
created_at = Column(DateTime(), nullable=False, default=datetime.utcnow)
29+
updated_at = Column(DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
30+
expires_at = Column(DateTime(), nullable=False)
31+
data = Column(Text(), nullable=False)
32+
33+
def make_id():
34+
return str(uuid.uuid4())
35+
36+
app.session_interface = SQLAlchemySessionInterface(
37+
orm_session=db.session,
38+
sql_session_model=UserSession,
39+
make_id=make_id
40+
)
41+
```
42+
## Notes
43+
44+
A daily process to remove expired sessions is recommended to stop the session list expanding over time.

flask_sss/__init__.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""
2+
A Flask SessionInterface implementing server-side sessions stored in SQLAlchemy
3+
"""
4+
import base64
5+
import os
6+
from datetime import datetime, timezone
7+
from typing import Callable, Protocol, Optional, Type, Dict, Any
8+
9+
from flask import Flask, Response, Request, current_app
10+
from flask.json.tag import TaggedJSONSerializer
11+
from flask.sessions import SessionInterface, SessionMixin
12+
from sqlalchemy.orm import Session
13+
from werkzeug.datastructures import CallbackDict
14+
15+
16+
class UserSessionTableProtocol(Protocol):
17+
"""
18+
Defines the minimum set of fields necessary for a declarative SQLAlchemy model to work with the SessionInterface
19+
"""
20+
21+
id: str
22+
session_id: str
23+
expires_at: datetime
24+
data: str
25+
user_id: Optional[str]
26+
27+
28+
class SerializerProtocol(Protocol):
29+
def dumps(self, value: Dict[Any, Any]) -> str:
30+
pass
31+
32+
def loads(self, value: str) -> Dict[Any, Any]:
33+
pass
34+
35+
36+
def default_mint_session_id() -> str:
37+
return str(base64.b32encode(os.urandom(30)), encoding="utf8")
38+
39+
40+
class ServerSideSession(CallbackDict, SessionMixin):
41+
"""Baseclass for server-side based sessions."""
42+
43+
def __init__(self, sid: str, initial: Optional[Dict[Any, Any]] = None, permanent: Optional[bool] = None) -> None:
44+
def on_update(s: ServerSideSession) -> None:
45+
s.modified = True
46+
47+
super().__init__(initial, on_update)
48+
self.sid = sid
49+
self.modified = False
50+
if permanent:
51+
self.permanent = permanent
52+
53+
54+
class SQLAlchemySessionInterface(SessionInterface):
55+
session_class = ServerSideSession
56+
57+
def __init__(
58+
self,
59+
orm_session: Session,
60+
sql_session_model: Type,
61+
make_id: Callable[[], str],
62+
make_session_id: Callable[[], str] = default_mint_session_id,
63+
permanent: Optional[bool] = None,
64+
serializer: Optional[SerializerProtocol] = None,
65+
):
66+
self.permanent = permanent
67+
self.make_id = make_id
68+
self.make_session_id = make_session_id
69+
if serializer is None:
70+
serializer = TaggedJSONSerializer()
71+
self.serializer = serializer
72+
self.orm_session = orm_session
73+
self.sql_session_model = sql_session_model
74+
75+
def open_session(self, app: Flask, request: Request):
76+
"""This method has to be implemented and must either return ``None``
77+
in case the loading failed because of a configuration error or an
78+
instance of a session object which implements a dictionary like
79+
interface + the methods and attributes on :class:`SessionMixin`.
80+
"""
81+
sid = request.cookies.get(app.session_cookie_name)
82+
if not sid:
83+
sid = self.make_session_id()
84+
return self.session_class(sid=sid, permanent=self.permanent)
85+
86+
saved_session = (
87+
self.orm_session.query(self.sql_session_model).filter(self.sql_session_model.session_id == sid).first()
88+
)
89+
if saved_session and saved_session.expires_at <= datetime.now(timezone.utc):
90+
# delete the saved session if it has expired
91+
self.orm_session.delete(saved_session)
92+
self.orm_session.commit()
93+
saved_session = None
94+
95+
if saved_session:
96+
try:
97+
json_data = saved_session.data
98+
data = self.serializer.loads(json_data)
99+
return self.session_class(sid=sid, initial=data)
100+
except Exception:
101+
return self.session_class(sid=self.make_session_id(), permanent=self.permanent)
102+
103+
return self.session_class(sid=sid, permanent=self.permanent)
104+
105+
def save_session(self, app: Flask, session: ServerSideSession, response: Response) -> None:
106+
"""This is called for actual sessions returned by :meth:`open_session`
107+
at the end of the request. This is still called during a request
108+
context so if you absolutely need access to the request you can do
109+
that.
110+
"""
111+
domain = self.get_cookie_domain(app)
112+
path = self.get_cookie_path(app)
113+
sid = session.sid
114+
saved_session = (
115+
self.orm_session.query(self.sql_session_model).filter(self.sql_session_model.session_id == sid).first()
116+
)
117+
if not session:
118+
if session.modified:
119+
if saved_session:
120+
self.orm_session.delete(saved_session)
121+
self.orm_session.commit()
122+
response.delete_cookie(app.session_cookie_name, domain=domain, path=path)
123+
return
124+
125+
if not self.should_set_cookie(app, session):
126+
return
127+
128+
httponly = self.get_cookie_httponly(app)
129+
secure = self.get_cookie_secure(app)
130+
expires = self.get_expiration_time(app, session)
131+
132+
val = self.serializer.dumps(dict(session))
133+
if saved_session:
134+
saved_session.data = val
135+
saved_session.expiry = expires
136+
self.orm_session.commit()
137+
else:
138+
new_session: UserSessionTableProtocol = self.sql_session_model(
139+
id=self.make_id(), session_id=session.sid, data=val, expires_at=expires
140+
)
141+
self.orm_session.add(new_session)
142+
self.orm_session.commit()
143+
144+
session_id = session.sid
145+
response.set_cookie(
146+
app.session_cookie_name,
147+
session_id,
148+
expires=expires,
149+
httponly=httponly,
150+
domain=domain,
151+
path=path,
152+
secure=secure,
153+
samesite=current_app.config.get("SESSION_COOKIE_SAMESITE", "Strict"),
154+
)

flask_sss/__version__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
__title__ = 'Flask-SSS'
2+
__description__ = 'Server-Side Sessions for Flask implemented as a SessionInterface.'
3+
__version__ = '0.1.0'
4+
__author__ = 'Steve McCartney'
5+
__author_email__ = '[email protected]'
6+
__license__ = 'Apache 2.0'
7+
__url__ = 'https://github.com/stevemccartney/flask-sss'

0 commit comments

Comments
 (0)