Skip to content

Commit

Permalink
Merge pull request #110 from PlaidWeb/feature/109-email-link-previews
Browse files Browse the repository at this point in the history
Wrap email link callbacks in a POST form
  • Loading branch information
fluffy-critter committed Mar 7, 2023
2 parents dc9f73c + 22aa777 commit b1a18d7
Show file tree
Hide file tree
Showing 9 changed files with 154 additions and 14 deletions.
26 changes: 22 additions & 4 deletions authl/disposition.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def __init__(self, url: str):
self.url = url

def __str__(self):
return 'REDIR:' + self.url
return f'REDIR:{self.url}'


class Verified(Disposition):
Expand Down Expand Up @@ -63,7 +63,7 @@ def __init__(self, identity: str, redir: str, profile: Optional[dict] = None):
self.profile = profile or {}

def __str__(self):
return 'VERIFIED:' + self.identity
return f'VERIFIED:{self.identity}'


class Notify(Disposition):
Expand All @@ -81,7 +81,7 @@ def __init__(self, cdata):
self.cdata = cdata

def __str__(self):
return 'NOTIFY:' + str(self.cdata)
return f'NOTIFY:{str(self.cdata)}'


class Error(Disposition):
Expand All @@ -98,4 +98,22 @@ def __init__(self, message, redir: str):
self.redir = redir

def __str__(self):
return 'ERROR:' + self.message
return f'ERROR:{self.message}'


class NeedsPost(Disposition):
"""
Indicates that the callback needs to be re-triggered as a POST request.
:param str url: The URL that needs to be POSTed to
:param str message: A user-friendly message to display
:param dict data: POST data to be sent in the request, as key-value pairs
"""

def __init__(self, url: str, message, data: dict):
self.url = url
self.message = str(message)
self.data = data

def __str__(self):
return f'NEEDS-POST:{self.message}'
34 changes: 31 additions & 3 deletions authl/flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,15 @@ class AuthlFlask:
* ``cdata``: the client data for the handler
:param function post_form_render_func: The function to call to render a
necessary post-login form; if not specified a default will be provided.
This function takes the following arguments:
* ``message``: the notification message for the user
* ``data``: the data to pass along in the POST request
* ``url``: the URL to send the POST request to
:param str session_auth_name: The session parameter to use for the
authenticated user. Set to None if you want to use your own session
management.
Expand Down Expand Up @@ -243,6 +252,7 @@ def __init__(self,
tester_path: Optional[str] = None,
login_render_func: Optional[typing.Callable] = None,
notify_render_func: Optional[typing.Callable] = None,
post_form_render_func: Optional[typing.Callable] = None,
session_auth_name: typing.Optional[str] = 'me',
force_https: bool = False,
stylesheet: Optional[typing.Union[str, typing.Callable]] = None,
Expand All @@ -269,6 +279,7 @@ def __init__(self,
self._tester_path = tester_path
self._login_render_func = login_render_func
self._notify_render_func = notify_render_func
self._post_form_render_func = post_form_render_func
self._session_auth_name = session_auth_name
self.force_https = force_https
self._stylesheet = stylesheet
Expand Down Expand Up @@ -335,20 +346,37 @@ def _handle_disposition(self, disp: disposition.Disposition):
# The user's login failed
return self.render_login_form(destination=disp.redir, error=disp.message)

if isinstance(disp, disposition.NeedsPost):
# A POST request is required to proceed
return self._render_post_form(url=disp.url, message=disp.message, data=disp.data)

# unhandled disposition
raise http_error.InternalServerError("Unknown disposition type " + str(type(disp)))
raise http_error.InternalServerError(f"Unknown disposition type {str(type(disp))}")

@_nocache()
def _render_notify(self, cdata):
if self._notify_render_func:
result = self._notify_render_func(cdata)
result = self._notify_render_func(cdata=cdata)
if result:
return result

return flask.render_template_string(load_template('notify.html'),
cdata=cdata,
stylesheet=self.stylesheet)

@_nocache()
def _render_post_form(self, url, message, data):
if self._post_form_render_func:
result = self._post_form_render_func(url=url, message=message, data=data)
if result:
return result

return flask.render_template_string(load_template('post-needed.html'),
url=url,
message=message,
data=data,
stylesheet=self.stylesheet)

def render_login_form(self, destination: str, error: typing.Optional[str] = None):
"""
Renders the login form. This might be called by the Flask app if, for
Expand Down Expand Up @@ -425,7 +453,7 @@ def _callback_endpoint(self, hid: str):
if not handler:
return self._handle_disposition(disposition.Error("Invalid handler", ''))
return self._handle_disposition(
handler.check_callback(request.url, request.args, request.form))
handler.check_callback(request.base_url, request.args, request.form))

@ property
def stylesheet(self) -> str:
Expand Down
35 changes: 35 additions & 0 deletions authl/flask_templates/post-needed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>

<head>
<title>Complete your login</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{{stylesheet}}">

<script>
window.addEventListener("load", () => {
document.forms['proxy'].submit();
});
</script>

<body>
<div id="login">
<h1>Next Step</h1>

<div id="notify">
{{message}}

<form id="proxy" method="POST" action="{{url}}">
{% for name,value in data.items() %}
<input type="hidden" name="{{name}}" value="{{value}}">
{% endfor %}
<input type="submit" value="Log In">
</form>
</div>

<div id="powered">
<p>Powered by <a href="https://github.com/PlaidWeb/Authl">Authl</a></p>
</div>
</div>
</body>
</html>
11 changes: 10 additions & 1 deletion authl/handlers/email_addr.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,13 @@ def initiate_auth(self, id_url, callback_uri, redir):
token = self._token_store.put((dest_addr, redir, time.time()))
self._pending[dest_addr] = token

LOGGER.debug("Generated token %s", token)

link_url = (callback_uri + ('&' if '?' in callback_uri else '?') +
urllib.parse.urlencode({'t': token}))

LOGGER.debug("Link URL %s", link_url)

msg = email.message.EmailMessage()
msg['To'] = dest_addr

Expand All @@ -160,7 +164,10 @@ def initiate_auth(self, id_url, callback_uri, redir):
return disposition.Notify(self._cdata)

def check_callback(self, url, get, data):
token = get.get('t')
if 't' in get:
return disposition.NeedsPost(url, "Complete your login", {'t': get['t']})

token = data.get('t')

if not token:
return disposition.Error('Missing token', None)
Expand All @@ -175,6 +182,8 @@ def check_callback(self, url, get, data):
if time.time() > when + self._lifetime:
return disposition.Error("Login timed out", redir)

LOGGER.debug("addr=%s redir=%s when=%s", email_addr, redir, when)

return disposition.Verified('mailto:' + email_addr, redir, {'email': email_addr})


Expand Down
2 changes: 1 addition & 1 deletion authl/webfinger.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,5 @@ def get_profiles(url: str, timeout: int = 30) -> typing.Set[str]:
return {link['href'] for link in profile['links']
if link['rel'] in ('http://webfinger.net/rel/profile-page', 'profile', 'self')}
except Exception: # pylint:disable=broad-except
LOGGER.exception("Failed to decode %s profile", resource)
LOGGER.info("Failed to decode %s profile", resource)
return set()
2 changes: 1 addition & 1 deletion test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def on_login(verified):
authl.flask.setup(
app,
{
'EMAIL_SENDMAIL': print,
'EMAIL_SENDMAIL': lambda message: print(message.get_payload(decode=True).decode('utf-8')),
'EMAIL_FROM': 'nobody@example.com',
'EMAIL_SUBJECT': 'Log in to authl test',
'EMAIL_CHECK_MESSAGE': 'Use the link printed to the test console',
Expand Down
14 changes: 11 additions & 3 deletions tests/handlers/test_emailaddr.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ def do_callback(message):
result = handler.check_callback(url, parse_args(url), {})
LOGGER.info('check_callback(%s,%s): %s', url, args, result)

assert isinstance(result, disposition.NeedsPost)

result = handler.check_callback(result.url, {}, result.data)

assert isinstance(result, disposition.Verified)

store['result'] = result

store['is_done'] = result.identity
Expand Down Expand Up @@ -105,7 +110,7 @@ def accept(message):

# check for missing or invalid tokens
assert 'Missing token' in str(handler.check_callback('foo', {}, {}))
assert 'Invalid token' in str(handler.check_callback('foo', {'t': 'bogus'}, {}))
assert 'Invalid token' in str(handler.check_callback('foo', {}, {'t': 'bogus'}))

def initiate(addr, redir):
result = handler.initiate_auth('mailto:' + addr, 'http://example/', redir)
Expand All @@ -114,7 +119,10 @@ def initiate(addr, redir):

def check_pending(addr):
url = pending[addr]
return handler.check_callback(url, parse_args(url), {})
result = handler.check_callback(url, parse_args(url), {})
if isinstance(result, disposition.NeedsPost):
result = handler.check_callback(result.url, {}, result.data)
return result

# check for timeout failure
mock_time = mocker.patch('time.time')
Expand Down Expand Up @@ -239,7 +247,7 @@ def test_please_wait(mocker):
assert token_value == pending['foo@bar.com']

# Using the link should remove the pending item
handler.check_callback('http://example/', {'t': pending['foo@bar.com']}, {})
handler.check_callback('http://example/', {}, {'t': pending['foo@bar.com']})
assert 'foo@bar.com' not in pending

# Next auth should call mock_send again
Expand Down
1 change: 1 addition & 0 deletions tests/test_disposition.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ def test_dispositions():
assert 'foo' in str(disposition.Verified('foo', None))
assert 'foo' in str(disposition.Notify('foo'))
assert 'foo' in str(disposition.Error('foo', None))
assert 'foo' in str(disposition.NeedsPost('', 'foo', {}))
43 changes: 42 additions & 1 deletion tests/test_flask_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,16 @@ def initiate_auth(self, id_url, callback_uri, redir):
return disposition.Notify(redir)
if id_url == 'error':
return disposition.Error('something', redir)
if id_url == 'posty':
return disposition.NeedsPost('http://foo.bar/', 'foo', {'val': 123})
if id_url == 'invalid':
return InvalidDisposition()
raise ValueError("nope")

notify_render = mocker.Mock(return_value="notified")
login_render = mocker.Mock(return_value="login form")
on_verified = mocker.Mock(return_value="verified")
post_render = mocker.Mock(return_value="postform")

app = flask.Flask(__name__)
app.secret_key = __name__
Expand All @@ -80,6 +83,7 @@ def initiate_auth(self, id_url, callback_uri, redir):
session_auth_name=None,
notify_render_func=notify_render,
login_render_func=login_render,
post_form_render_func=post_render,
on_verified=on_verified)
instance.add_handler(Dispositioner())

Expand All @@ -95,7 +99,7 @@ def initiate_auth(self, id_url, callback_uri, redir):

with app.test_client() as client:
assert client.get(login_url + '/bobble?me=notify').data == b'notified'
notify_render.assert_called_with('/bobble')
notify_render.assert_called_with(cdata='/bobble')

with app.test_client() as client:
assert client.get(login_url + '/chomp?me=error').data == b"login form"
Expand All @@ -107,6 +111,13 @@ def initiate_auth(self, id_url, callback_uri, redir):
redir='/chomp'
)

with app.test_client() as client:
assert client.get(login_url + '/chomp?me=posty').data == b"postform"
post_render.assert_called_with(url="http://foo.bar/",
message="foo",
data={'val': 123}
)

with app.test_client() as client:
assert client.get(login_url + '/chomp?me=invalid').status_code == 500

Expand Down Expand Up @@ -301,3 +312,33 @@ def on_verified(disp):
assert 'me' not in flask.session
assert isinstance(stash['v'], disposition.Verified)
assert stash['v'].identity == 'test:poiu'


def test_post_form_render():
app = flask.Flask(__name__)
app.secret_key = 'qwer'

def no_login(*args, **kwargs):
raise ValueError(f"Got spurious login func. args={args} kwargs={kwargs}")

aflask = authl.flask.AuthlFlask(app, {}, login_render_func=no_login)

class PostProxyHandler(TestHandler):
@property
def cb_id(self):
return 'posty'

def check_callback(self, url, get, data):
return disposition.NeedsPost('fake-url', 'This is a message', {'proxied': get['bogus']})

aflask.authl.add_handler(PostProxyHandler())

with app.test_request_context('https://foo.bar/'):
cb_url = flask.url_for('authl.callback', hid='posty', bogus='fancy')

with app.test_client() as client:
response = client.get(cb_url)
soup = BeautifulSoup(response.data, 'html.parser')
assert soup.find('form', method='POST', action='fake-url')
assert 'This is a message' in soup.find('div', id='notify').text
assert soup.find('input', {'type': 'hidden', 'name': 'proxied', 'value': 'fancy'})

0 comments on commit b1a18d7

Please sign in to comment.