使用Flask设计带认证token的RESTful API接口[翻译]
上一篇文章, 使用python的Flask实现一个RESTful API服务器端 简单地演示了Flask实的现的api服务器,里面提到了因为无状态的原则,没有session cookies,如果访问需要验证的接口,客户端请求必需每次都发送用户名和密码。通常在实际app应用中,并不会每次都将用户名和密码发送。
这篇里面就谈到了产生token的方法。
完整的例子的代码
可以在github:REST-auth 上找到。作者欢迎大家上去跟他讨论。
创建用户数据库
这个例子比较接近真实的项目,将会使用Flask-SQLAlchemy (ORM)的模块去管理用户数据库。
user model 非常简单。每个用户只有 username 和 password_hash 两个属性。
class User(db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key = True) username = db.Column(db.String(32), index = True) password_hash = db.Column(db.String(128))
因为安全的原因,明文密码不可以直接存储,必需经过hash后方可存入数据库。如果数据库被脱了,也是比较难破解的。
密码永远不要明文存在数据库中。
Password Hashing
这里使用PassLib库对密码进行hash。
PassLib提供几种hash算法。custom_app_context模块是基于sha256_crypt加密算法,使用十分简单。
对User model增加密码hash和验证有两办法:
from passlib.apps import custom_app_context as pwd_context class User(db.Model): # ... def hash_password(self, password): self.password_hash = pwd_context.encrypt(password) def verify_password(self, password): return pwd_context.verify(password, self.password_hash)
当一个新的用户注册,或者更改密码时,就会调用hash_password()函数,将原始密码作为参数传入hash_password()函数。
当验证用户密码时就会调用verify_password()函数,如果密码正确,就返回True,如果不正确就返回False。
hash算法是单向的,意味着它只能hash密码,但是无法还原密码。但是这些算法是绝对可靠的,输入相同的内容,那么hash后的内容也会是一样的。通常注册或者验证时,对比的是hash后的结果。
用户注册
在这个例子里,客户端通过发送 POST 请求到 /api/users 上,并且请求的body部份必需是JSON格式,并且包含 username 和 password 字段。
Flask 实现的代码:
@app.route('/api/users', methods = ['POST']) def new_user(): username = request.json.get('username') password = request.json.get('password') if username is None or password is None: abort(400) # missing arguments if User.query.filter_by(username = username).first() is not None: abort(400) # existing user user = User(username = username) user.hash_password(password) db.session.add(user) db.session.commit() return jsonify({ 'username': user.username }), 201, {'Location': url_for('get_user', id = user.id, _external = True)}
这个函数真是简单极了。只是用请求的JSON里面拿到 username 和 password 两个参数。
如果参数验证通过,一个User实例被创建,密码hash后,用户资料就存到数据库里面了。
请求响应返回的是一个JSON格式的对象,状态码为201,并且在http header里面定义了Location指向刚刚创建的用户的URI。
注意:get_user函数没有在这里实现,具体查以查看github。
试试使用curl发送一个注册请求:
$ curl -i -X POST -H "Content-Type: application/json" -d '{"username":"ok","password":"python"}' http://127.0.0.1:5000/api/users HTTP/1.0 201 CREATED Content-Type: application/json Content-Length: 27 Location: http://127.0.0.1:5000/api/users/1 Server: Werkzeug/0.9.4 Python/2.7.3 Date: Thu, 28 Nov 2013 19:56:39 GMT { "username": "ok" }
通常在正式的服务器里面,最好还是使用https通讯。这样的登录方式,明文通讯是很容易被截取的。
基于简单密码的认证
现在我们假设有一个API只向已经注册好的用户开放。接入点是/api/resource。
这里使用HTTP BASIC Authentication的方法来进行验证,我计划使用Flask-HTTPAuth这个扩展来实现这个功能。
导入Flask-HTTPAuth扩展模块后,为对应的函数添加login_required装饰器:
from flask.ext.httpauth import HTTPBasicAuth auth = HTTPBasicAuth() @app.route('/api/resource') @auth.login_required def get_resource(): return jsonify({ 'data': 'Hello, %s!' % g.user.username })
那么Flask-HTTPAuth(login_required装饰器)需要知道如何验证用户信息,这就需要具体去实现安全验证的方法了。
有一种办法是十分灵活的,通过实现verify_password回调函数去验证用户名和密码,验证通过返回True,否则返回False。然后Flask-HTTPAuth再调用这个回调函数,这样就可以轻松自定义验证方法了。(注:Python修饰器的函数式编程)
具体实现代码如下:
@auth.verify_password def verify_password(username, password): user = User.query.filter_by(username = username).first() if not user or not user.verify_password(password): return False g.user = user return True
如果用户名与密码验证通过,user对像会被存储到Flask的g对像中。(注:对象 g 存储在应用上下文中而不再是请求上下文中,这意味着即使在应用上下文中它也是可访问的而不是只能在请求上下文中。)方便其它函数使用。
让我们使用已经注册的用户来请求看看:
$ curl -u ok:python -i -X GET http://127.0.0.1:5000/api/resource HTTP/1.0 200 OK Content-Type: application/json Content-Length: 30 Server: Werkzeug/0.9.4 Python/2.7.3 Date: Thu, 28 Nov 2013 20:02:25 GMT { "data": "Hello, ok!" }
如果登录错误,会返回以下内容:
$ curl -u miguel:ruby -i -X GET http://127.0.0.1:5000/api/resource HTTP/1.0 401 UNAUTHORIZED Content-Type: text/html; charset=utf-8 Content-Length: 19 WWW-Authenticate: Basic realm="Authentication Required" Server: Werkzeug/0.9.4 Python/2.7.3 Date: Thu, 28 Nov 2013 20:03:18 GMT Unauthorized Access
再次重申,真实的API服务器最好在HTTPS下通讯。
基于Token的认证
因为需要每次请求都要发送用户名和密码,客户端需要把验证信息存储起来进行发送,这样十分不方便,就算在HTTPS下的传输,也是有风险存在的。
比前面的密码验证方法更好的是使用Token认证请求。
原理是第一次客户端与服务器交换过认证信息后得到一个认证token,后面的请求就使用这个token进行请求。
Token通常会给一个过期的时间,当超过这个时间后,就会变成无效,需要产生一个新的token。这样就算token泄漏了,危害也只是在有效的时间内。
好多种办法去实现token。一种简单的做法就是产生一个固定长度的随机序列字符与用户名和密码一同存储在数据库当中,有可能带上一个过期时间。这样token就变成了一串普通的字符,可以十分容易地和其它字符串验证对比,并且可以检查时间是否过期。
更复杂的实现办法是不需要服务器端进行存储token,而是使用数字签名信息作为token。这样做的好处是经过用户数字签名生成的token是可以防篡改的。
Flask使用与数字签名有些相似的办法去实现加密的cookies的,这里我们使用itsdangerous的库去实现。
生成token和验证token的方法可以附加到User model上实现:
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer class User(db.Model): # ... def generate_auth_token(self, expiration = 600): s = Serializer(app.config['SECRET_KEY'], expires_in = expiration) return s.dumps({ 'id': self.id }) @staticmethod def verify_auth_token(token): s = Serializer(app.config['SECRET_KEY']) try: data = s.loads(token) except SignatureExpired: return None # valid token, but expired except BadSignature: return None # invalid token user = User.query.get(data['id']) return user
在generate_auth_token()函数中,token其实就是一个加密过的字典,里面包含了用户的id和默认为10分钟(600秒)的过期时间。
verify_auth_token()的实现是一个静态方法,因为token只是一次解码检索里面的用户id。获取用户id后就可以在数据库中取得用户资料了。
试试使用一个新的接入点,让客户端请求一个token:
@app.route('/api/token') @auth.login_required def get_auth_token(): token = g.user.generate_auth_token() return jsonify({ 'token': token.decode('ascii') })
注意,这个接入点是被Flask-HTTPAuth扩展的auth.login_required装饰器保护的,请求需要提供用户名和密码。
上面返回的是一个token字符串,下面的请求将会包含这个token。
HTTP Basic Authentication协议没有具体要求必需使用用户名和密码进行验证,HTTP头可以使用两个字段去传输认证信息,对于token认证,只需要把token当成用户名发送即可,密码字段可以乎略。
综上所说,一些认证还是要使用用户名和密码认证,另外一部份直接使用获取的token认证。verify_password回调函数则需要包括两种验证的方式:
@auth.verify_password def verify_password(username_or_token, password): # first try to authenticate by token user = User.verify_auth_token(username_or_token) if not user: # try to authenticate with username/password user = User.query.filter_by(username = username_or_token).first() if not user or not user.verify_password(password): return False g.user = user return True
修改原来的verify_password回调函数,添加两种验证。开始用用户名字段当作token,如果不是token来的,就采用用户名和密码验证。
使用curl测试请求获取一个认证token:
$ curl -u ok:python -i -X GET http://127.0.0.1:5000/api/token HTTP/1.0 200 OK Content-Type: application/json Content-Length: 139 Server: Werkzeug/0.9.4 Python/2.7.3 Date: Thu, 28 Nov 2013 20:04:15 GMT { "token": "eyJhbGciOiJIUzI1NiIsImV4cCI6MTM4NTY2OTY1NSwiaWF0IjoxMzg1NjY5MDU1fQ.eyJpZCI6MX0.XbOEFJkhjHJ5uRINh2JA1BPzXjSohKYDRT472wGOvjc" }
再试试使用token一访问受保护的API:
$ curl -u eyJhbGciOiJIUzI1NiIsImV4cCI6MTM4NTY2OTY1NSwiaWF0IjoxMzg1NjY5MDU1fQ.eyJpZCI6MX0.XbOEFJkhjHJ5uRINh2JA1BPzXjSohKYDRT472wGOvjc:unused -i -X GET http://127.0.0.1:5000/api/resource HTTP/1.0 200 OK Content-Type: application/json Content-Length: 30 Server: Werkzeug/0.9.4 Python/2.7.3 Date: Thu, 28 Nov 2013 20:05:08 GMT { "data": "Hello, ok!" }
注意,请求里面带了unused字段。只是为了标识而已,替代密码的占位符。
OAuth 认证
谈到RESTful认证,通常会提到OAuth协议。
So what is OAuth?
通常是允许一个应用接入到另外一个应用的数据或者服务的验证方法。
举个例子,如果一个网站或者应用问你权限接入你的facebook账号,并且提交一些东西到你的时间轴上面。这个例子,你就是资源拥有者(你拥有你的facebook时间轴),第三方应用是消费者,facebook是提供者。如果你授权接入允许消费者写东西到你的时间轴上面,是不需要提供你的facebook登录信息的。
OAuth并不合适用在client/server的RESTful API上面,一般是用在你的RESTful API允许第三方应用(消费者)去接入。
上面的例子是,客户端/服务器端之间直接通讯并不需要去隐藏认证信息,客户端是直接发送认证请求信息到服务器端的。
原文来自:http://blog.miguelgrinberg.com/post/restful-authentication-with-flask