Skip to content

Commit c8f4774

Browse files
committed
A generic persistence layer, some provides data encryption
Remove dummy and use Path(...).touch() instead Document schema_name and attributes Change getmtime() to time_last_modified() Rename 2 location into signal_location, with docs Refactor test cases using pytest.mark.skipif
1 parent 7708606 commit c8f4774

File tree

6 files changed

+311
-0
lines changed

6 files changed

+311
-0
lines changed

msal_extensions/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33

44
import sys
55

6+
from .persistence import (
7+
FilePersistence,
8+
FilePersistenceWithDataProtection,
9+
KeychainPersistence,
10+
LibsecretPersistence,
11+
)
12+
from .cache_lock import CrossPlatLock
13+
614
if sys.platform.startswith('win'):
715
from .token_cache import WindowsTokenCache as TokenCache
816
elif sys.platform.startswith('darwin'):

msal_extensions/persistence.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"""A generic persistence layer, optionally encrypted on Windows, OSX, and Linux.
2+
3+
Should a certain encryption is unavailable, exception will be raised at run-time,
4+
rather than at import time.
5+
6+
By successfully creating and using a certain persistence object,
7+
app developer would naturally know whether the data are protected by encryption.
8+
"""
9+
import abc
10+
import os
11+
import errno
12+
try:
13+
from pathlib import Path # Built-in in Python 3
14+
except:
15+
from pathlib2 import Path # An extra lib for Python 2
16+
17+
18+
try:
19+
ABC = abc.ABC
20+
except AttributeError: # Python 2.7, abc exists, but not ABC
21+
ABC = abc.ABCMeta("ABC", (object,), {"__slots__": ()}) # type: ignore
22+
23+
24+
def _mkdir_p(path):
25+
"""Creates a directory, and any necessary parents.
26+
27+
This implementation based on a Stack Overflow question that can be found here:
28+
https://stackoverflow.com/questions/600268/mkdir-p-functionality-in-python
29+
30+
If the path provided is an existing file, this function raises an exception.
31+
:param path: The directory name that should be created.
32+
"""
33+
if not path:
34+
return # NO-OP
35+
try:
36+
os.makedirs(path)
37+
except OSError as exp:
38+
if exp.errno == errno.EEXIST and os.path.isdir(path):
39+
pass
40+
else:
41+
raise
42+
43+
44+
class BasePersistence(ABC):
45+
"""An abstract persistence defining the common interface of this family"""
46+
47+
is_encrypted = False # Default to False. To be overridden by sub-classes.
48+
49+
@abc.abstractmethod
50+
def save(self, content):
51+
# type: (str) -> None
52+
"""Save the content into this persistence"""
53+
raise NotImplementedError
54+
55+
@abc.abstractmethod
56+
def load(self):
57+
# type: () -> str
58+
"""Load content from this persistence"""
59+
raise NotImplementedError
60+
61+
@abc.abstractmethod
62+
def time_last_modified(self):
63+
"""Get the last time when this persistence has been modified"""
64+
raise NotImplementedError
65+
66+
@abc.abstractmethod
67+
def get_location(self):
68+
"""Return the file path which this persistence stores (meta)data into"""
69+
raise NotImplementedError
70+
71+
72+
class FilePersistence(BasePersistence):
73+
"""A generic persistence, storing data in a plain-text file"""
74+
75+
def __init__(self, location):
76+
if not location:
77+
raise ValueError("Requires a file path")
78+
self._location = os.path.expanduser(location)
79+
_mkdir_p(os.path.dirname(self._location))
80+
81+
def save(self, content):
82+
# type: (str) -> None
83+
"""Save the content into this persistence"""
84+
with open(self._location, 'w+') as handle:
85+
handle.write(content)
86+
87+
def load(self):
88+
# type: () -> str
89+
"""Load content from this persistence"""
90+
with open(self._location, 'r') as handle:
91+
return handle.read()
92+
93+
def time_last_modified(self):
94+
return os.path.getmtime(self._location)
95+
96+
def touch(self):
97+
"""To touch this file-based persistence without writing content into it"""
98+
Path(self._location).touch() # For os.path.getmtime() to work
99+
100+
def get_location(self):
101+
return self._location
102+
103+
104+
class FilePersistenceWithDataProtection(FilePersistence):
105+
"""A generic persistence with data stored in a file,
106+
protected by Win32 encryption APIs on Windows"""
107+
is_encrypted = True
108+
109+
def __init__(self, location, entropy=''):
110+
"""Initialization could fail due to unsatisfied dependency"""
111+
# pylint: disable=import-outside-toplevel
112+
from .windows import WindowsDataProtectionAgent
113+
self._dp_agent = WindowsDataProtectionAgent(entropy=entropy)
114+
super(FilePersistenceWithDataProtection, self).__init__(location)
115+
116+
def save(self, content):
117+
super(FilePersistenceWithDataProtection, self).save(
118+
self._dp_agent.protect(content))
119+
120+
def load(self):
121+
return self._dp_agent.unprotect(
122+
super(FilePersistenceWithDataProtection, self).load())
123+
124+
125+
class KeychainPersistence(BasePersistence):
126+
"""A generic persistence with data stored in,
127+
and protected by native Keychain libraries on OSX"""
128+
is_encrypted = True
129+
130+
def __init__(self, signal_location, service_name, account_name):
131+
"""Initialization could fail due to unsatisfied dependency.
132+
133+
:param signal_location: See :func:`persistence.LibsecretPersistence.__init__`
134+
"""
135+
if not (service_name and account_name): # It would hang on OSX
136+
raise ValueError("service_name and account_name are required")
137+
from .osx import Keychain # pylint: disable=import-outside-toplevel
138+
self._file_persistence = FilePersistence(signal_location) # Favor composition
139+
self._Keychain = Keychain # pylint: disable=invalid-name
140+
self._service_name = service_name
141+
self._account_name = account_name
142+
143+
def save(self, content):
144+
with self._Keychain() as locker:
145+
locker.set_generic_password(
146+
self._service_name, self._account_name, content)
147+
self._file_persistence.touch() # For time_last_modified()
148+
149+
def load(self):
150+
with self._Keychain() as locker:
151+
return locker.get_generic_password(
152+
self._service_name, self._account_name)
153+
154+
def time_last_modified(self):
155+
return self._file_persistence.time_last_modified()
156+
157+
def get_location(self):
158+
return self._file_persistence.get_location()
159+
160+
161+
class LibsecretPersistence(BasePersistence):
162+
"""A generic persistence with data stored in,
163+
and protected by native libsecret libraries on Linux"""
164+
is_encrypted = True
165+
166+
def __init__(self, signal_location, schema_name, attributes, **kwargs):
167+
"""Initialization could fail due to unsatisfied dependency.
168+
169+
:param string signal_location:
170+
Besides saving the real payload into encrypted storage,
171+
this class will also touch this signal file.
172+
Applications may listen a FileSystemWatcher.Changed event for reload.
173+
https://docs.microsoft.com/en-us/dotnet/api/system.io.filesystemwatcher.changed?view=netframework-4.8#remarks
174+
:param string schema_name: See :func:`libsecret.LibSecretAgent.__init__`
175+
:param dict attributes: See :func:`libsecret.LibSecretAgent.__init__`
176+
"""
177+
# pylint: disable=import-outside-toplevel
178+
from .libsecret import ( # This uncertain import is deferred till runtime
179+
LibSecretAgent, trial_run)
180+
trial_run()
181+
self._agent = LibSecretAgent(schema_name, attributes, **kwargs)
182+
self._file_persistence = FilePersistence(signal_location) # Favor composition
183+
184+
def save(self, content):
185+
if self._agent.save(content):
186+
self._file_persistence.touch() # For time_last_modified()
187+
188+
def load(self):
189+
return self._agent.load()
190+
191+
def time_last_modified(self):
192+
return self._file_persistence.time_last_modified()
193+
194+
def get_location(self):
195+
return self._file_persistence.get_location()
196+
197+
# We could also have a KeyringPersistence() which can then be used together
198+
# with a FilePersistence to achieve
199+
# https://github.com/AzureAD/microsoft-authentication-extensions-for-python/issues/12
200+
# But this idea is not pursued at this time.
201+

sample/persistence_sample.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import sys
2+
import logging
3+
import json
4+
5+
from msal_extensions import *
6+
7+
8+
def build_persistence(location, fallback_to_plaintext=False):
9+
"""Build a suitable persistence instance based your current OS"""
10+
if sys.platform.startswith('win'):
11+
return FilePersistenceWithDataProtection(location)
12+
if sys.platform.startswith('darwin'):
13+
return KeychainPersistence(location, "my_service_name", "my_account_name")
14+
if sys.platform.startswith('linux'):
15+
try:
16+
return LibsecretPersistence(
17+
# By using same location as the fall back option below,
18+
# this would override the unencrypted data stored by the
19+
# fall back option. It is probably OK, or even desirable
20+
# (in order to aggressively wipe out plain-text persisted data),
21+
# unless there would frequently be a desktop session and
22+
# a remote ssh session being active simultaneously.
23+
location,
24+
schema_name="my_schema_name",
25+
attributes={"my_attr1": "foo", "my_attr2": "bar"},
26+
)
27+
except: # pylint: disable=bare-except
28+
if not fallback_to_plaintext:
29+
raise
30+
logging.exception("Encryption unavailable. Opting in to plain text.")
31+
return FilePersistence(location)
32+
33+
persistence = build_persistence("storage.bin")
34+
print("Is this persistence encrypted?", persistence.is_encrypted)
35+
36+
data = { # It can be anything, here we demonstrate an arbitrary json object
37+
"foo": "hello world",
38+
"bar": "",
39+
"service_principle_1": "blah blah...",
40+
}
41+
42+
with CrossPlatLock("my_another_lock.txt"):
43+
persistence.save(json.dumps(data))
44+
assert json.loads(persistence.load()) == data
45+

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
install_requires=[
2020
'msal>=0.4.1,<2.0.0',
2121
'portalocker~=1.6',
22+
"pathlib2;python_version<'3.0'",
2223
"pygobject>=3,<4;platform_system=='Linux'",
2324
],
2425
tests_require=['pytest'],

tests/test_persistence.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import os
2+
import sys
3+
import shutil
4+
import tempfile
5+
import logging
6+
7+
import pytest
8+
9+
from msal_extensions.persistence import *
10+
11+
12+
is_running_on_travis_ci = bool( # (WTF) What-The-Finding:
13+
# The bool(...) is necessary, otherwise skipif(...) would treat "true" as
14+
# string conditions and then raise an undefined "true" exception.
15+
# https://docs.pytest.org/en/latest/historical-notes.html#string-conditions
16+
os.getenv("TRAVIS"))
17+
18+
@pytest.fixture
19+
def temp_location():
20+
test_folder = tempfile.mkdtemp(prefix="test_persistence_roundtrip")
21+
yield os.path.join(test_folder, 'persistence.bin')
22+
shutil.rmtree(test_folder, ignore_errors=True)
23+
24+
def _test_persistence_roundtrip(persistence):
25+
payload = 'arbitrary content'
26+
persistence.save(payload)
27+
assert persistence.load() == payload
28+
29+
def test_file_persistence(temp_location):
30+
_test_persistence_roundtrip(FilePersistence(temp_location))
31+
32+
@pytest.mark.skipif(
33+
is_running_on_travis_ci or not sys.platform.startswith('win'),
34+
reason="Requires Windows Desktop")
35+
def test_file_persistence_with_data_protection(temp_location):
36+
_test_persistence_roundtrip(FilePersistenceWithDataProtection(temp_location))
37+
38+
@pytest.mark.skipif(
39+
not sys.platform.startswith('darwin'),
40+
reason="Requires OSX. Whether running on TRAVIS CI does not seem to matter.")
41+
def test_keychain_persistence(temp_location):
42+
_test_persistence_roundtrip(KeychainPersistence(
43+
temp_location, "my_service_name", "my_account_name"))
44+
45+
@pytest.mark.skipif(
46+
is_running_on_travis_ci or not sys.platform.startswith('linux'),
47+
reason="Requires Linux Desktop. Headless or SSH session won't work.")
48+
def test_libsecret_persistence(temp_location):
49+
_test_persistence_roundtrip(LibsecretPersistence(
50+
temp_location,
51+
"my_schema_name",
52+
{"my_attr_1": "foo", "my_attr_2": "bar"},
53+
))
54+

tox.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,7 @@ envlist = py27,py35,py36,py37,py38
33

44
[testenv]
55
deps = pytest
6+
passenv =
7+
TRAVIS
68
commands =
79
pytest

0 commit comments

Comments
 (0)