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:
parent
c8494277a8
commit
474458c623
|
@ -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
|
40
README.md
40
README.md
|
@ -2,20 +2,48 @@
|
||||||
|
|
||||||
A Flask extension to support IndieAuth and Micropub clients.
|
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
|
```python
|
||||||
from flask import Flask, request, url_for
|
from flask import Flask, request, url_for
|
||||||
from flask.ext.micropub import Micropub
|
from flask.ext.micropub import MicropubClient
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
micropub = Micropub(app)
|
micropub = MicropubClient(app)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/login')
|
@app.route('/login')
|
||||||
def login():
|
def login():
|
||||||
return micropub.authorize(
|
return micropub.authorize(
|
||||||
me, redirect_url=url_for('micropub_callback', _external=True),
|
me, scope=request.args.get('scope'))
|
||||||
scope=request.args.get('scope'))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/micropub-callback')
|
@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
|
||||||
|
|
81
example.py
81
example.py
|
@ -7,6 +7,61 @@ app.config['SECRET_KEY'] = 'my super secret key'
|
||||||
micropub = MicropubClient(app)
|
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')
|
@app.route('/micropub-callback')
|
||||||
@micropub.authorized_handler
|
@micropub.authorized_handler
|
||||||
def micropub_callback(resp):
|
def micropub_callback(resp):
|
||||||
|
@ -14,6 +69,7 @@ def micropub_callback(resp):
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
|
Authorized:
|
||||||
<ul>
|
<ul>
|
||||||
<li>me: {}</li>
|
<li>me: {}</li>
|
||||||
<li>endpoint: {}</li>
|
<li>endpoint: {}</li>
|
||||||
|
@ -27,30 +83,5 @@ def micropub_callback(resp):
|
||||||
resp.next_url, resp.error)
|
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__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=True)
|
app.run(debug=True)
|
||||||
|
|
|
@ -53,14 +53,31 @@ class MicropubClient:
|
||||||
else:
|
else:
|
||||||
self.client_id = app.name
|
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.
|
"""Authorize a user via Micropub.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
me (string): the authing user's URL. if it does not begin with
|
me (string): the authing user's URL. if it does not begin with
|
||||||
https?://, http:// will be prepended.
|
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,
|
next_url (string, optional): passed through the whole auth process,
|
||||||
useful if you want to redirect back to a starting page when auth
|
useful if you want to redirect back to a starting page when auth
|
||||||
is complete.
|
is complete.
|
||||||
|
@ -68,9 +85,32 @@ class MicropubClient:
|
||||||
scopes. 'read' by default.
|
scopes. 'read' by default.
|
||||||
|
|
||||||
Returns:
|
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.
|
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://'):
|
if not me.startswith('http://') and not me.startswith('https://'):
|
||||||
me = 'http://' + me
|
me = 'http://' + me
|
||||||
auth_url, token_url, micropub_url = self._discover_endpoints(me)
|
auth_url, token_url, micropub_url = self._discover_endpoints(me)
|
||||||
|
@ -84,31 +124,47 @@ class MicropubClient:
|
||||||
flask.session['_micropub_endpoints'] = (
|
flask.session['_micropub_endpoints'] = (
|
||||||
auth_url, token_url, micropub_url)
|
auth_url, token_url, micropub_url)
|
||||||
|
|
||||||
auth_url = auth_url + '?' + urlencode({
|
auth_params = {
|
||||||
'me': me,
|
'me': me,
|
||||||
'client_id': self.client_id,
|
'client_id': self.client_id,
|
||||||
'redirect_uri': redirect_url,
|
'redirect_uri': redirect_url,
|
||||||
'scope': scope,
|
|
||||||
'state': '{}|{}'.format(csrf_token, next_url or ''),
|
'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)
|
flask.current_app.logger.debug('redirecting to %s', auth_url)
|
||||||
|
|
||||||
return flask.redirect(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):
|
def authorized_handler(self, f):
|
||||||
"""Decorates the authorization callback endpoint. The endpoint should
|
"""Decorates the authorization callback endpoint. The endpoint should
|
||||||
take one argument, a flask.ext.micropub.AuthResponse.
|
take one argument, a flask.ext.micropub.AuthResponse.
|
||||||
"""
|
"""
|
||||||
@functools.wraps(f)
|
@functools.wraps(f)
|
||||||
def decorated():
|
def decorated():
|
||||||
resp = self._handle_response()
|
resp = self._handle_authorize_response()
|
||||||
return f(resp)
|
return f(resp)
|
||||||
|
self._authorized_handler = decorated
|
||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
def _handle_response(self):
|
def _handle_authenticate_response(self):
|
||||||
access_token = None
|
code = flask.request.args.get('code')
|
||||||
redirect_uri = flask.url_for(flask.request.endpoint, _external=True)
|
|
||||||
state = flask.request.args.get('state')
|
state = flask.request.args.get('state')
|
||||||
|
redirect_uri = flask.url_for(flask.request.endpoint, _external=True)
|
||||||
|
|
||||||
if state and '|' in state:
|
if state and '|' in state:
|
||||||
csrf_token, next_url = state.split('|', 1)
|
csrf_token, next_url = state.split('|', 1)
|
||||||
else:
|
else:
|
||||||
|
@ -123,19 +179,15 @@ class MicropubClient:
|
||||||
next_url=next_url, error='mismatched CSRF token')
|
next_url=next_url, error='mismatched CSRF token')
|
||||||
|
|
||||||
if '_micropub_endpoints' in flask.session:
|
if '_micropub_endpoints' in flask.session:
|
||||||
auth_url, token_url, micropub_url \
|
auth_url = flask.session['_micropub_endpoints'][0]
|
||||||
= flask.session['_micropub_endpoints']
|
|
||||||
del flask.session['_micropub_endpoints']
|
|
||||||
else:
|
else:
|
||||||
auth_url, token_url, micropub_url = self._discover_endpoints(
|
auth_url = self._discover_endpoints(
|
||||||
flask.request.args.get('me'))
|
flask.request.args.get('me'))[0]
|
||||||
|
|
||||||
if not auth_url:
|
if not auth_url:
|
||||||
return AuthResponse(
|
return AuthResponse(
|
||||||
next_url=next_url, error='no authorization endpoint')
|
next_url=next_url, error='no authorization endpoint')
|
||||||
|
|
||||||
code = flask.request.args.get('code')
|
|
||||||
|
|
||||||
# validate the authorization code
|
# validate the authorization code
|
||||||
auth_data = {
|
auth_data = {
|
||||||
'code': code,
|
'code': code,
|
||||||
|
@ -167,17 +219,34 @@ class MicropubClient:
|
||||||
error='missing "me" in response')
|
error='missing "me" in response')
|
||||||
|
|
||||||
confirmed_me = rdata.get('me')[0]
|
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:
|
if not token_url or not micropub_url:
|
||||||
# successfully auth'ed user, no micropub endpoint
|
# successfully auth'ed user, no micropub endpoint
|
||||||
return AuthResponse(
|
return AuthResponse(
|
||||||
me=confirmed_me, next_url=next_url,
|
me=authenticate_response.me,
|
||||||
|
next_url=authenticate_response.next_url,
|
||||||
error='no micropub endpoint found.')
|
error='no micropub endpoint found.')
|
||||||
|
|
||||||
# request an access token
|
# request an access token
|
||||||
token_data = {
|
token_data = {
|
||||||
'code': code,
|
'code': code,
|
||||||
'me': confirmed_me,
|
'me': authenticate_response.me,
|
||||||
'redirect_uri': redirect_uri,
|
'redirect_uri': redirect_uri,
|
||||||
'client_id': self.client_id,
|
'client_id': self.client_id,
|
||||||
'state': state,
|
'state': state,
|
||||||
|
@ -192,21 +261,26 @@ class MicropubClient:
|
||||||
|
|
||||||
if token_response.status_code != 200:
|
if token_response.status_code != 200:
|
||||||
return AuthResponse(
|
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: {}'
|
error='bad response from token endpoint: {}'
|
||||||
.format(token_response))
|
.format(token_response))
|
||||||
|
|
||||||
tdata = parse_qs(token_response.text)
|
tdata = parse_qs(token_response.text)
|
||||||
if 'access_token' not in tdata:
|
if 'access_token' not in tdata:
|
||||||
return AuthResponse(
|
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: {}'
|
error='response from token endpoint missing access_token: {}'
|
||||||
.format(tdata))
|
.format(tdata))
|
||||||
|
|
||||||
# success!
|
# success!
|
||||||
access_token = tdata.get('access_token')[0]
|
access_token = tdata.get('access_token')[0]
|
||||||
return AuthResponse(me=confirmed_me, micropub_endpoint=micropub_url,
|
return AuthResponse(
|
||||||
access_token=access_token, next_url=next_url)
|
me=authenticate_response.me,
|
||||||
|
micropub_endpoint=micropub_url,
|
||||||
|
access_token=access_token,
|
||||||
|
next_url=authenticate_response.next_url)
|
||||||
|
|
||||||
def _discover_endpoints(self, me):
|
def _discover_endpoints(self, me):
|
||||||
me_response = requests.get(me)
|
me_response = requests.get(me)
|
||||||
|
|
6
setup.py
6
setup.py
|
@ -3,15 +3,15 @@ Flask-Micropub
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
This extension adds the ability to login to a Flask-based website
|
This extension adds the ability to login to a Flask-based website
|
||||||
using [IndieAuth](https://indiewebcamp.com/IndieAuth), and to request
|
using IndieAuth (https://indiewebcamp.com/IndieAuth), and to request
|
||||||
an [Micropub](https://indiewebcamp.com/Micropub) access token.
|
a Micropub (https://indiewebcamp.com/Micropub) access token.
|
||||||
"""
|
"""
|
||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='Flask-Micropub',
|
name='Flask-Micropub',
|
||||||
version='0.1.4',
|
version='0.2.0',
|
||||||
url='https://github.com/kylewm/flask-micropub/',
|
url='https://github.com/kylewm/flask-micropub/',
|
||||||
license='BSD',
|
license='BSD',
|
||||||
author='Kyle Mahan',
|
author='Kyle Mahan',
|
||||||
|
|
Loading…
Reference in New Issue