Flask API 登錄
零、起因
最近要寫uniapp客戶端,服務器使用的是Python的Flask框架,為了實現用戶登錄,在網上查到了一些Flask的擴展,其中比較簡單的就是flask_httpauth(此時版本__version__ = '4.2.1dev'),其官網給出的基本示例:
from flask import Flask
from flask_httpauth import HTTPBasicAuth
from werkzeug.security import generate_password_hash, check_password_hash
app = Flask(__name__)
auth = HTTPBasicAuth()
users = {
"john": generate_password_hash("hello"),
"susan": generate_password_hash("bye")
}
@auth.verify_password
def verify_password(username, password):
if username in users and \
check_password_hash(users.get(username), password):
return username
@app.route('/')
@auth.login_required
def index():
return "Hello, {}!".format(auth.current_user())
if __name__ == '__main__':
app.run()
瀏覽器訪問127.0.0.1:5000會提示輸入賬號和密碼才能訪問頁面,否則是錯誤提示。實現原理是基於http auth協議完成的,登錄成功后不需要在瀏覽器里設置session,而是設置了一個請求頭,拿上例第一個賬號來說,在請求時添加請求頭Authorization:Basic am9objpoZWxsbw==
就可以完成對用戶的認證。因為機制簡單,這非常適合uniapp這種客戶端的程序編寫,於是決定采用flask_httpauth完成對用戶的認證。但是其中遇到了問題,例如其設置的請求頭,用Base64算法解am9objpoZWxsbw==
,其解出來的值中有包含賬號和密碼,這樣很容易造成密碼泄露,因此我開始想辦法把這里解出的密碼換成用加鹽散列算法generate_password_hash計算出的hash值。
壹、解決
通過對源碼的閱讀,我發現貌似沒有相關函數可以支持把密碼換成hash值,有另外一個類HTTPDigestAuth,但是需要存session,失去了簡單的初衷。
首先API想訪問受保護的函數就必須提供Authorization參數。分析發現默認的Authorization參數格式是Basic(空格)((用戶名(冒號)密碼)的base64編碼)
於是寫一個參數生成函數,API登錄時首先訪問這個函數驗證賬號密碼后獲取Authorization參數值。
@app.route('/api/login', methods=['POST'])
def get_auth():
username = request.args.get('username')
print(username)
password = request.args.get('password')
print(password)
if username and password:
if username in users and check_password_hash(users.get(username), password):
print('登錄成功')
token = username + ':' + users.get(username)
b64_token = base64.urlsafe_b64encode(token.encode("utf-8"))
au = b64_token.decode("utf-8")
return 'Basic {}'.format(au)
else:
return '賬號或密碼錯誤'
else:
return '參數不完整'
需要使用POST方法,在Query參數列表里傳入username=hello&password=hello
,使用ApiPost接口測試軟件發起請求后獲得響應:
Basic am9objpwYmtkZjI6c2hhMjU2OjE1MDAwMCRNc2NEdDNJTSQyMmZlZGNmZTQwNDc3YzAyMzhjNGVkMmIxOTZiZjg5ODIyN2IyOGNlOTcxY2IzOTU2NjE3MWI1NTJhYTgzMzM3
使用Base64解出來是
john:pbkdf2:sha256:150000$MscDt3IM$22fedcfe40477c0238c4ed2b196bf898227b28ce971cb39566171b552aa83337
用戶名和密碼hash值。
接下來是密碼驗證部分,因為存儲的用戶數據里的密碼就是hash值,因此直接判斷相等否就行:
@auth.verify_password
def verify_password(username, password):
if username in users and users.get(username)==password:
return username
再次使用ApiPost加上Header參數Authorization:Basic am9objpwYmtkZjI6c2hhMjU2OjE1MDAwMCRNc2NEdDNJTSQyMmZlZGNmZTQwNDc3YzAyMzhjNGVkMmIxOTZiZjg5ODIyN2IyOGNlOTcxY2IzOTU2NjE3MWI1NTJhYTgzMzM3
訪問127.0.0.1:5000
成功返回Hello, john!
至此,uniapp登錄的基本機制解決了,並且密碼安全性也得到了提高。
但是發現瀏覽器不能正常登錄了,在flask_httpauth源碼部分貌似沒找到生成Authorizationd的函數。項目是用的flask_login用在網頁登錄部分的,因此暫時不受影響,不過在后來的flask_httpauth源碼閱讀中貌似發現了它可以實現把明文密碼替換成密碼hash下發到瀏覽器。稍后再做分析。
貳、重寫
就一直覺得秘鑰生成和認證分在不同的地方總不對,而且沒有對flask_httpauth有很深的了解,生成的秘鑰格式有時候不一定對得上。因此決定仿造flask_httpauth自己寫一個,就暫時叫flask_apiauth吧,因為主要是為API服務的。
源文件只有一個,一個類,仿造flask_httpauth的結構:
flask_httpauth.py
# coding:utf-8
# @Time : 2021/4/24 17:08
# @Author : minuy
# @File : flask_apiauth.py
from functools import wraps
from flask import request, g
import base64
class ApiAuth(object):
def __init__(self, split_character=' '):
# 分割詞,最好唯一且不出現在賬號里
self.split_character = split_character
self.verify_password_callback = None
self.error_content_callback = None
def verify_password(self, f):
""" 驗證密碼回調,此回調返回的非空數據將放在current_user中 """ print('設置密碼驗證函數')
self.verify_password_callback = f
return f
def error_content(self, f):
""" 錯誤數據回調,此回調應返回登錄、驗證失敗回復給客戶端的內容 """ print('設置錯誤內容函數')
self.error_content_callback = f
return f
def get_token(self, username=None, password=None):
""" 根據賬號和密碼(hash)生成token,用於登錄函數 """ print('生成token')
token = username + self.split_character + password
return base64.urlsafe_b64encode(token.encode("utf-8"))
def authentication_failed(self):
""" 認證失敗調用 """ print('驗證密碼失敗')
# 如果有錯誤內容處理,返回錯誤內容
if self.error_content_callback:
print('返回自定義錯誤數據')
return self.error_content_callback()
else:
# 否則返回文字,登錄失敗
return 'Login failed'
@property
def current_user(self):
""" 登錄后通過這個屬性獲取在verify_password函數里返回的內容(用戶信息) """ if hasattr(g, 'flask_api_auth_user'):
return g.flask_api_auth_user
def login_required(self, f=None):
""" 登錄攔截,沒有相應的請求頭或者驗證密碼返回空值會返回錯誤信息 """ def login_required_internal(f):
@wraps(f)
def decorated(*args, **kwargs):
auth_user = None
if 'token' in request.headers:
print('token存在')
try:
# 把賬號和密碼hash都一起打包到base64里
token = base64.urlsafe_b64decode(request.headers['token']).decode('utf-8')
print('token:', token)
# 賬號和密碼hash之間使用空格分割
user, hash_password = token.split(self.split_character, 1)
print('user:', user, 'hash_password', hash_password)
# 如果賬號和密碼都存在
if user and hash_password:
auth_user = {'user': user, 'hash_password': hash_password}
except (ValueError, KeyError):
# 如果解析失敗或者沒有token
print('token解析失敗')
pass
# 沒提交參數
else:
# 在這里可以特別設置未登錄的提醒
return self.authentication_failed()
print('auth', auth_user)
# 如果存在用戶信息,開始驗證密碼
if auth_user:
user = None
# 如果有密碼驗證函數
if self.verify_password_callback:
print('開始驗證密碼')
user = self.verify_password_callback(auth_user.get('user'), auth_user.get('hash_password'))
if user:
print('密碼驗證成功')
# 如果user不為空,加載
g.flask_api_auth_user = user if user is not True \
else auth_user.get('user') if auth_user else None
# 如果user為空
if user in (False, None):
return self.authentication_failed()
else:
# 用戶信息不存在
return self.authentication_failed()
return f(*args, **kwargs)
return decorated
if f:
return login_required_internal(f)
return login_required_internal
(注釋打印可以去掉一下)
完成用戶token生成(登錄)和用戶登錄攔截(認證),眾所周知,退出登錄即把uniapp存儲的token刪除。
然后是示例代碼:
test.py
from flask import Flask, request
from werkzeug.security import generate_password_hash, check_password_hash
from flask_apiauth import ApiAuth
app = Flask(__name__)
auth = ApiAuth()
users = {
"john": generate_password_hash("hello"),
"susan": generate_password_hash("bye")
}
@app.route('/api/login', methods=['POST'])
def get_auth():
username = request.args.get('username')
print(username)
password = request.args.get('password')
print(password)
if username and password:
if username in users and check_password_hash(users.get(username), password):
return auth.get_token(username, users.get(username))
else:
return {
'code': '403',
'data': {},
'message': '賬號或密碼錯誤'
}
else:
return {
'code': '403',
'data': {},
'message': '參數不完整'
}
@auth.error_content
def error_content():
return {
'code': '403',
'data': {},
'message': '請先登錄'
}
@auth.verify_password
def verify_password(username, password):
if username in users and users.get(username) == password:
print('密碼正確')
# 返回的數據是下面auth.current_user拿到的
return {'username': username, 'sex': '男'}
@app.route('/')
@auth.login_required
def index():
return {
'code': '200',
'data': {
'name': auth.current_user.get('username'),
'sex': auth.current_user.get('sex')
},
'message': '成功'
}
if __name__ == '__main__':
app.run()
使用ApiPost測試,登錄、攔截功能正常,目前就這么用着先吧。
開源地址:Flask-APIAuth