added authentication-only flow

- Added a separate 'authenticate' flow to provide explicit support for
  calling out to indieauth without requesting any sort of access
  token.
- Redirect_url is now determined automatically based on the
  authenticated_handler or authorized_handler annotations
This commit is contained in:
Kyle Mahan 2015-02-07 09:40:31 -08:00
parent c8494277a8
commit 474458c623
5 changed files with 203 additions and 57 deletions

11
CHANGELOG.md Normal file
View File

@ -0,0 +1,11 @@
# Change Log
All notable changes to this project will be documented in this file.
## 0.2.0 - 2015-02-07
### Changed
- Started keeping a changelog!
- Added a separate 'authenticate' flow to provide explicit support for
calling out to indieauth without requesting any sort of access
token.
- Redirect_url is now determined automatically based on the
authenticated_handler or authorized_handler annotations

View File

@ -2,20 +2,48 @@
A Flask extension to support IndieAuth and Micropub clients.
## Authentication
Authentication uses the
[IndieAuth](https://indiewebcamp.com/IndieAuth) flow to confirm a user
controls a particular URL, without requesting any sort of permissions
or access token. Annotate an endpoint with
`@micropub.authenticated_handler` and then call
`micropub.authenticate` to initiate the login.
## Authorization
Authorization uses the full
[Micropub](https://indiewebcamp.com/Micropub) flow to authenticate a
user and then request an access token with which to make micropub
requests. Annotate an endpoint with `@micropub.authorized_handler` and
then call `micropub.authorize` to initiate the login.
## CSRF
MicropubClient provides a simple mechanism to deter Cross-Site Request
Forgery. Based on
[this Flask snippet](http://flask.pocoo.org/snippets/3/), we generate
a random string, pass it to the indieauth service via the state
parameter, and then confirm we get the same random string back later.
This helps prevent malicious sites from sending users to your
indieauth endpoint against their will.
## Example
```python
from flask import Flask, request, url_for
from flask.ext.micropub import Micropub
from flask.ext.micropub import MicropubClient
app = Flask(__name__)
micropub = Micropub(app)
micropub = MicropubClient(app)
@app.route('/login')
def login():
return micropub.authorize(
me, redirect_url=url_for('micropub_callback', _external=True),
scope=request.args.get('scope'))
me, scope=request.args.get('scope'))
@app.route('/micropub-callback')
@ -25,4 +53,6 @@ def micropub_callback(resp):
```
See details at https://indiewebcamp.com/IndieAuth and https://indiewebcamp.com/Micropub
See example.py for a more thorough example. Protocol details at
https://indiewebcamp.com/IndieAuth and
https://indiewebcamp.com/Micropub

View File

@ -7,6 +7,61 @@ app.config['SECRET_KEY'] = 'my super secret key'
micropub = MicropubClient(app)
@app.route('/')
def index():
return """
<!DOCTYPE html>
<html>
<body>
<form action="/authenticate" method="GET">
<input type="text" name="me" placeholder="your domain.com"/>
<button type="submit">Authenticate</button>
</form>
<form action="/authorize" method="GET">
<input type="text" name="me" placeholder="your domain.com"/>
<select name="scope">
<option>read</option>
<option>post</option>
<option>comment</option>
</select>
<button type="submit">Authorize</button>
</form>
</body>
</html>
"""
@app.route('/authenticate')
def authenticate():
return micropub.authenticate(
request.args.get('me'), next_url=url_for('index'))
@app.route('/authorize')
def authorize():
return micropub.authorize(
request.args.get('me'), next_url=url_for('index'),
scope=request.args.get('scope'))
@app.route('/indieauth-callback')
@micropub.authenticated_handler
def indieauth_callback(resp):
return """
<!DOCTYPE html>
<html>
<body>
Authenticated:
<ul>
<li>me: {}</li>
<li>next: {}</li>
<li>error: {}</li>
</ul>
</body>
</html>
""".format(resp.me, resp.next_url, resp.error)
@app.route('/micropub-callback')
@micropub.authorized_handler
def micropub_callback(resp):
@ -14,6 +69,7 @@ def micropub_callback(resp):
<!DOCTYPE html>
<html>
<body>
Authorized:
<ul>
<li>me: {}</li>
<li>endpoint: {}</li>
@ -27,30 +83,5 @@ def micropub_callback(resp):
resp.next_url, resp.error)
@app.route('/')
def index():
me = request.args.get('me')
if me:
return micropub.authorize(
me, redirect_url=url_for('micropub_callback', _external=True),
next_url=url_for('index'), scope=request.args.get('scope'))
return """
<!DOCTYPE html>
<html>
<body>
<form action="" method="GET">
<input type="text" name="me" placeholder="your domain.com"/>
<select name="scope">
<option>read</option>
<option>write</option>
<option>comment</option>
</select>
<button type="submit">Authorize</button>
</form>
</body>
</html>
"""
if __name__ == '__main__':
app.run(debug=True)

View File

@ -53,14 +53,31 @@ class MicropubClient:
else:
self.client_id = app.name
def authorize(self, me, redirect_url, next_url=None, scope='read'):
def authenticate(self, me, next_url=None):
"""Authenticate a user via IndieAuth.
Args:
me (string): the authing user's URL. if it does not begin with
https?://, http:// will be prepended.
next_url (string, optional): passed through the whole auth process,
useful if you want to redirect back to a starting page when auth
is complete.
scopes. 'read' by default.
Returns:
a redirect to the user's specified authorization url, or
https://indieauth.com/auth if none is provided.
"""
redirect_url = flask.url_for(
self._authenticated_handler.func_name, _external=True)
return self._start_indieauth(me, redirect_url, next_url, None)
def authorize(self, me, next_url=None, scope='read'):
"""Authorize a user via Micropub.
Args:
me (string): the authing user's URL. if it does not begin with
https?://, http:// will be prepended.
redirect_url (string): the URL that IndieAuth should redirect to
when it's finished; This should be the authorized_handler
next_url (string, optional): passed through the whole auth process,
useful if you want to redirect back to a starting page when auth
is complete.
@ -68,9 +85,32 @@ class MicropubClient:
scopes. 'read' by default.
Returns:
a redirect to the user's specified authorization url, or
a redirect to the user's specified authorization
https://indieauth.com/auth if none is provided.
"""
redirect_url = flask.url_for(
self._authorized_handler.func_name, _external=True)
return self._start_indieauth(me, redirect_url, next_url, scope)
def _start_indieauth(self, me, redirect_url, next_url, scope):
"""Helper for both authentication and authorization. Kicks off
IndieAuth by fetching the authorization endpoint from the user's
homepage and redirecting to it.
Args:
me (string): the authing user's URL. if it does not begin with
https?://, http:// will be prepended.
redirect_url: the callback URL that we pass to the auth endpoint.
next_url (string, optional): passed through the whole auth process,
useful if you want to redirect back to a starting page when auth
is complete.
scope (string): a space-separated string of micropub scopes.
Returns:
a redirect to the user's specified authorization
https://indieauth.com/auth if none is provided.
"""
if not me.startswith('http://') and not me.startswith('https://'):
me = 'http://' + me
auth_url, token_url, micropub_url = self._discover_endpoints(me)
@ -84,31 +124,47 @@ class MicropubClient:
flask.session['_micropub_endpoints'] = (
auth_url, token_url, micropub_url)
auth_url = auth_url + '?' + urlencode({
auth_params = {
'me': me,
'client_id': self.client_id,
'redirect_uri': redirect_url,
'scope': scope,
'state': '{}|{}'.format(csrf_token, next_url or ''),
})
}
if scope:
auth_params['scope'] = scope
auth_url = auth_url + '?' + urlencode(auth_params)
flask.current_app.logger.debug('redirecting to %s', auth_url)
return flask.redirect(auth_url)
def authenticated_handler(self, f):
"""Decorates the authentication callback endpoint. The endpoint should
take one argument, a flask.ext.micropub.AuthResponse.
"""
@functools.wraps(f)
def decorated():
resp = self._handle_authorize_response()
return f(resp)
self._authenticated_handler = decorated
return decorated
def authorized_handler(self, f):
"""Decorates the authorization callback endpoint. The endpoint should
take one argument, a flask.ext.micropub.AuthResponse.
"""
@functools.wraps(f)
def decorated():
resp = self._handle_response()
resp = self._handle_authorize_response()
return f(resp)
self._authorized_handler = decorated
return decorated
def _handle_response(self):
access_token = None
redirect_uri = flask.url_for(flask.request.endpoint, _external=True)
def _handle_authenticate_response(self):
code = flask.request.args.get('code')
state = flask.request.args.get('state')
redirect_uri = flask.url_for(flask.request.endpoint, _external=True)
if state and '|' in state:
csrf_token, next_url = state.split('|', 1)
else:
@ -123,19 +179,15 @@ class MicropubClient:
next_url=next_url, error='mismatched CSRF token')
if '_micropub_endpoints' in flask.session:
auth_url, token_url, micropub_url \
= flask.session['_micropub_endpoints']
del flask.session['_micropub_endpoints']
auth_url = flask.session['_micropub_endpoints'][0]
else:
auth_url, token_url, micropub_url = self._discover_endpoints(
flask.request.args.get('me'))
auth_url = self._discover_endpoints(
flask.request.args.get('me'))[0]
if not auth_url:
return AuthResponse(
next_url=next_url, error='no authorization endpoint')
code = flask.request.args.get('code')
# validate the authorization code
auth_data = {
'code': code,
@ -167,17 +219,34 @@ class MicropubClient:
error='missing "me" in response')
confirmed_me = rdata.get('me')[0]
return AuthResponse(me=confirmed_me, next_url=next_url)
def _handle_authorize_response(self):
authenticate_response = self._handle_authenticate_response()
code = flask.request.args.get('code')
state = flask.request.args.get('state')
redirect_uri = flask.url_for(flask.request.endpoint, _external=True)
if authenticate_response.error:
return authenticate_response
if '_micropub_endpoints' in flask.session:
_, token_url, micropub_url = flask.session['_micropub_endpoints']
else:
_, token_url, micropub_url = self._discover_endpoints(
flask.request.args.get('me'))
if not token_url or not micropub_url:
# successfully auth'ed user, no micropub endpoint
return AuthResponse(
me=confirmed_me, next_url=next_url,
me=authenticate_response.me,
next_url=authenticate_response.next_url,
error='no micropub endpoint found.')
# request an access token
token_data = {
'code': code,
'me': confirmed_me,
'me': authenticate_response.me,
'redirect_uri': redirect_uri,
'client_id': self.client_id,
'state': state,
@ -192,21 +261,26 @@ class MicropubClient:
if token_response.status_code != 200:
return AuthResponse(
me=confirmed_me, next_url=next_url,
me=authenticate_response.me,
next_url=authenticate_response.next_url,
error='bad response from token endpoint: {}'
.format(token_response))
tdata = parse_qs(token_response.text)
if 'access_token' not in tdata:
return AuthResponse(
me=confirmed_me, next_url=next_url,
me=authenticate_response.me,
next_url=authenticate_response.next_url,
error='response from token endpoint missing access_token: {}'
.format(tdata))
# success!
access_token = tdata.get('access_token')[0]
return AuthResponse(me=confirmed_me, micropub_endpoint=micropub_url,
access_token=access_token, next_url=next_url)
return AuthResponse(
me=authenticate_response.me,
micropub_endpoint=micropub_url,
access_token=access_token,
next_url=authenticate_response.next_url)
def _discover_endpoints(self, me):
me_response = requests.get(me)

View File

@ -3,15 +3,15 @@ Flask-Micropub
--------------
This extension adds the ability to login to a Flask-based website
using [IndieAuth](https://indiewebcamp.com/IndieAuth), and to request
an [Micropub](https://indiewebcamp.com/Micropub) access token.
using IndieAuth (https://indiewebcamp.com/IndieAuth), and to request
a Micropub (https://indiewebcamp.com/Micropub) access token.
"""
from setuptools import setup
setup(
name='Flask-Micropub',
version='0.1.4',
version='0.2.0',
url='https://github.com/kylewm/flask-micropub/',
license='BSD',
author='Kyle Mahan',