Skip to content

34. 应用练习:第14版

Image

版本 14 的文件夹 [http-servers/09] 是通过复制版本 13 的文件夹 [http-servers/08] 获得的。

34.1. 简介

CSRF(跨站请求伪造)是一种会话劫持技术。维基百科(https://fr.wikipedia.org/wiki/Cross-site_request_forgery)对此解释如下:

假设爱丽丝是某论坛的管理员,并通过会话系统登录该论坛。玛洛莉是该论坛的成员,她想删除论坛中的一条帖子。由于她的账户没有相应的权限,她利用CSRF类型的攻击,借用了爱丽丝的账户。
  1. Malorie 设法获取了用于删除该帖子的链接。
  2. Malorie向Alice发送了一条包含待显示伪图片(实际上是一个脚本)的消息。该图片中的URL链接指向了用于删除目标帖子的脚本。
  3. 爱丽丝的浏览器中必须已打开马洛里所针对的网站会话。这是攻击能够悄无声息地成功执行的必要条件,否则会触发身份验证请求,从而引起爱丽丝的警觉。该会话必须具备执行马洛里破坏性请求所需的权限。 浏览器无需打开目标网站的标签页,甚至无需启动浏览器。只需该会话处于活动状态即可。
  4. 爱丽丝正在阅读玛洛丽发来的消息,她的浏览器使用了爱丽丝已打开的会话,因此无需进行交互式身份验证。浏览器试图获取图片的内容。在此过程中,浏览器触发了链接并删除了该消息,随后将一个纯文本网页作为图片内容加载。 由于无法识别关联的图像类型,浏览器未显示图片,爱丽丝因此并不知晓玛洛丽刚刚迫使她删除了这条消息。

即便如此解释,CSRF这一技术仍难以理解。让我们画个示意图:

Image

  • [1-2] 阶段,爱丽丝与论坛(网站 A)进行通信。该论坛为每位用户维护一个会话。爱丽丝的浏览器会本地存储这个会话 Cookie,并在每次向网站 A 发出新请求时将其发送回去;
  • [3] 阶段,Malorie 向 Alice 发送了一条消息。Alice 通过浏览器阅读了该消息。所读消息的格式为 HTML,其中包含指向网站 B 上一张图片的链接。实际上,该链接指向一个 JavaScript 脚本,该脚本会在到达 Alice 的浏览器后执行;
  • 该JavaScript脚本随后向网站A发起请求。爱丽丝的浏览器会自动发送该请求,并附带本地存储的会话cookie。攻击就在此时发生:玛洛丽成功利用爱丽丝的权限(会话)向网站A发起了查询。此后无论发生什么,攻击都已得逞;

为防范此类攻击,网站A可采取以下措施:

  • 在每次与爱丽丝进行 [1-2] 数据交换时,网站 A 会发送一个密钥(下文称为令牌 token)CSRF,爱丽丝必须在下次请求时将其发回。因此,爱丽丝在每次请求中必须发送两项信息:
    • 会话cookie;
    • 在对网站A的上次请求响应中收到的令牌CSRF;

安全机制就在于此:虽然浏览器会自动将会话cookie发回网站A,但不会自动发送令牌CSRF。因此,攻击脚本执行的第6-7步交换将被拒绝,因为第6次请求中未发送令牌CSRF;

网站 A 可以通过多种方式向 Alice 发送令牌 CSRF,用于应用程序 HTML:

  • 它可以在每次请求中发送一个包含所有链接的页面 HTML,其中所有链接都带有令牌 CSRF,例如 [http://siteA/chemin/csrf_token]。 在后续请求中,当爱丽丝点击其中一个链接时,网站 A 只需从请求的 URL 中提取令牌 CSRF,并验证其是否正确。这里将采用这种做法;
  • 对于包含表单的页面,网站A可以将表单连同包含令牌CSRF的隐藏字段[input type=’hidden’]一并发送。当爱丽丝提交页面时,该令牌将随表单自动发送。 网站 A 将从请求主体(body)中获取令牌 CSRF;
  • 其他技术方案也是可行的;

34.2. 配置

Image

我们在应用程序的 [parameters] 配置中引入了两个布尔值:

  • [with_redissession]:设为 True 时,应用程序使用 Redis 会话;设为 False 时,应用程序使用常规 Flask 会话;
  • [with_csrftoken]:设为 True 时,应用程序的 URL 字段将包含一个 CSRF 令牌;

        # 线程暂停时长(秒)
        "sleep_time"0,
        # Redis 服务器
        "with_redissession"True,
        "redis": {
            "host""127.0.0.1",
            "port"6379
        },
        # CSRF令牌
        "with_csrftoken"False,

34.3. CSRF 实现

我们将确保当:


config['parameters']['with_csrftoken']

等于 [True] 时,应用程序向客户端浏览器发送网页,其链接中将包含令牌 CSRF。

34.3.1. 模块 [flask_wtf]

CSRF令牌的实现将通过[flask_wtf]模块完成,该模块将安装在PyCharm终端中:


(venv) C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\packages>pip install flask_wtf
Collecting flask_wtf

34.3.2. 视图模板

我们在模型中引入了一个新类:

Image

[AbstractBaseModelForView] 类定义如下:


from abc import abstractmethod

from flask import Request
from flask_wtf.csrf import generate_csrf
from werkzeug.local import LocalProxy

from InterfaceModelForView import InterfaceModelForView

class AbstractBaseModelForView(InterfaceModelForView):

    @abstractmethod
    def get_model_for_view(self, request: Request, session: LocalProxy, config: dict, résultat: dict) -> dict:
        pass

    def get_csrftoken(self, config: dict):
        # csrf_token
        if config['parameters']['with_csrftoken']:
            return f"/{generate_csrf()}"
        else:
            return ""
  • 第 9 行:类 [AbstractBaseModelForView] 实现了由视图模型类所实现的接口 [InterfaceModelForView]
  • 第 11-13 行:方法 [get_model_for_view] 未被实现;
  • 第15-20行:如果应用程序已配置为使用令牌,则方法[get_csrftoken]会生成令牌CSRF。根据具体情况,该函数会返回一个以符号/开头的令牌,否则返回一个空字符串。 函数 [generate_csrf] 的特点是针对给定的客户端请求始终生成相同的值。处理请求涉及执行多个函数。在这些函数中使用 [generate_csrf] 始终会生成相同的值。 然而,在后续请求中,将生成一个新的令牌 CSRF;

所有视图 V 的 M 模板都将以如下方式包含令牌 CSRF:


class ModelForAuthentificationView(AbstractBaseModelForView):

    def get_model_for_view(self, request: Request, session: LocalProxy, config: dict, résultat: dict) -> dict:
        # 将页面数据封装到模板中
        modèle = {}
        

        # CSRF 令牌
        modèle['csrf_token'] = super().get_csrftoken(config)

        # 渲染模板
        return modèle
  • 每个视图模板类都继承自基类 [AbstractBaseModelForView]
  • 第 8 行:向父类请求令牌 CSRF。结果要么是空字符串,要么是类似 [/Ijk4NjQ2ZDdjZjI0ZDJiYTVjZTZjYmFhZGNjMjE3Y2U5M2I3ODI0NzYi.Xy5Okg.n-kSR_nslkndfT7AFVy2UDtdb8c] 的字符串;

34.3.3. 视图

从刚才的内容可以看出,所有视图 V 的模型 M 中都将包含令牌 CSRF。因此,它们可以在其中的链接中使用该令牌。下面看几个例子:

身份验证片段 [v_authentification.html]


<!-- 表单 HTML - 通过操作提交其值 [authentifier-utilisateur] -->
<form method="post" action="/authentifier-utilisateur{{modèle.csrf_token}}">

    <!-- 标题 -->
    <div class="alert alert-primary" role="alert">
        <h4>Veuillez vous authentifier</h4>
    </div>


</form>
  • 第 2 行:根据刚才的分析,[action] 属性的 URL 将为:

[/authentifier-utilisateur/Ijk4NjQ2ZDdjZjI0ZDJiYTVjZTZjYmFhZGNjMjE3Y2U5M2I3ODI0NzYi.Xy5Okg.n-kSR_nslkndfT7AFVy2UDtdb8c]

[/authentifier-utilisateur]

这取决于应用程序是否配置为使用 CSRF 令牌;

税款计算片段 [v-calcul-impot.html]


<!-- 已发布的 HTML 表单 -->
<form method="post" action="/calculer-impot{{modèle.csrf_token}}">
    <!-- 蓝色背景下的12列消息 -->
    <div class="col-md-12">
        <div class="alert alert-primary" role="alert">
            <h4>Remplissez le formulaire ci-dessous puis validez-le</h4>
        </div>
    </div>
    
</form>

模拟片段 [v-liste-simulations.html]


{% if modèle.simulations is undefined or modèle.simulations|length==0 %}
<!-- 蓝色背景上的消息 -->
<div class="alert alert-primary" role="alert">
    <h4>Votre liste de simulations est vide</h4>
</div>
{% endif %}

{% if modèle.simulations is defined and modèle.simulations|length!=0 %}
<!-- 蓝色背景上的消息 -->
<div class="alert alert-primary" role="alert">
    <h4>Liste de vos simulations</h4>
</div>

<!-- 模拟表格 -->
<table class="table table-sm table-hover table-striped">
    
    <!-- 表格主体(显示的数据) -->
    <tbody>
    <!-- 通过遍历模拟表来显示每项模拟 -->
    {% for simulation in modèle.simulations %}

    <!-- 显示包含 6 列的表格中的一行 - <tr> 标签 -->
    <!-- 第1列:行标题(模拟编号) - 标签 <th scope='row' -->
    <!-- 第2列:参数值 [marié] - 标签 <td> -->
    <!-- 第3列:参数值 [enfants] - 标签 <td> -->
    <!-- 第 4 列:参数值 [salaire] - 标签 <td> -->
    <!-- 第5列:参数值 [impôt](税款) - 标签 <td> -->
    <!-- 第6列:参数值 [surcôte] - 标签 <td> -->
    <!-- 第 7 列:参数值 [décôte] - 标签 <td> -->
    <!-- 第8列:参数值 [réduction] - 标签 <td> -->
    <!-- 第 9 列:参数值 [taux](税款) - 标签 <td> -->
    <!-- 第10列:模拟删除链接 - 标签 <td> -->
    <tr>
        <th scope="row">{{simulation.id}}</th>
        <td>{{simulation.marié}}</td>
        <td>{{simulation.enfants}}</td>
        <td>{{simulation.salaire}}</td>
        <td>{{simulation.impôt}}</td>
        <td>{{simulation.surcôte}}</td>
        <td>{{simulation.décôte}}</td>
        <td>{{simulation.réduction}}</td>
        <td>{{simulation.taux}}</td>
        <td><a href="/supprimer-simulation/{{simulation.id}}{{modèle.csrf_token}}">Supprimer</a></td>
    </tr>
    {% endfor %}
    </tr>
    </tbody>
</table>
{% endif %}

菜单片段 [v-menu.html]


<!-- Bootstrap 菜单 -->
<nav class="nav flex-column">
    <!-- 显示链接列表HTML -->
    {% for optionMenu in modèle.optionsMenu %}
    <a class="nav-link" href="{{optionMenu.url}}{{modèle.csrf_token}}">{{optionMenu.text}}</a>
    {% endfor %}
</nav>

34.3.4. 路线

现在有两种类型的路线,取决于它们是否使用令牌:

Image

  • [routes_without_csrftoken] 指不使用令牌的路线CSRF。这些是上一版本的路线;
  • [routes_with_csrftoken] 表示使用 CSRF 令牌的路由。

[routes_with_csrftoken] 中,路由现在多了一个参数,即令牌 CSRF:


# 前端控制器
def front_controller() -> tuple:
    # 将请求转发至主控制器
    main_controller = config['mvc']['controllers']['main-controller']
    return main_controller.execute(request, session, config)

@app.route('/', methods=['GET'])
def index() -> tuple:
    # 重定向至 /init-session/html
    return redirect(url_for("init_session", type_response="html"csrf_token=generate_csrf()), status.HTTP_302_FOUND)

# init-session
@app.route('/init-session/<string:type_response>/<string:csrf_token>', methods=['GET'])
def init_session(type_response: str, csrf_token: str) -> tuple:
    # 执行与该操作关联的控制器
    return front_controller()

# 用户认证
@app.route('/authentifier-utilisateur/<string:csrf_token>', methods=['POST'])
def authentifier_utilisateur(csrf_token: str) -> tuple:
    # 执行与该操作关联的控制器
    return front_controller()

# 计算税款
@app.route('/calculer-impot/<string:csrf_token>', methods=['POST'])
def calculer_impot(csrf_token: str) -> tuple:
    # 执行与该操作关联的控制器
    return front_controller()

# 批量计算税款
@app.route('/calculer-impots/<string:csrf_token>', methods=['POST'])
def calculer_impots(csrf_token: str):
    # 执行与该操作关联的控制器
    return front_controller()

# 列出模拟
@app.route('/lister-simulations/<string:csrf_token>', methods=['GET'])
def lister_simulations(csrf_token: str) -> tuple:
    # 执行与该操作关联的控制器
    return front_controller()

# 删除模拟
@app.route('/supprimer-simulation/<int:numero>/<string:csrf_token>', methods=['GET'])
def supprimer_simulation(numero: int, csrf_token: str) -> tuple:
    # 执行与该操作关联的控制器
    return front_controller()

# 结束会话
@app.route('/fin-session/<string:csrf_token>', methods=['GET'])
def fin_session(csrf_token: str) -> tuple:
    # 执行与该操作关联的控制器
    return front_controller()

# 显示-计算-税款
@app.route('/afficher-calcul-impot/<string:csrf_token>', methods=['GET'])
def afficher_calcul_impot(csrf_token: str) -> tuple:
    # 正在执行与该操作关联的控制器
    return front_controller()

# 获取管理员数据
@app.route('/get-admindata/<string:csrf_token>', methods=['GET'])
def get_admindata(csrf_token: str) -> tuple:
    # 正在执行与该操作关联的控制器
    return front_controller()

现在所有路由的参数中都包含 CSRF 令牌,甚至包括 [/init-session] 路由。 这意味着用户无法通过直接输入 URL [/init-session/html] 来启动应用程序,因为其中缺少 CSRF 令牌。 现在必须通过第 7-10 行中的 URL [/] 进行调用。

路径选择在主脚本 [main] 中完成:



# 主线程不再需要日志器
logger.close()

# 如果发生错误则停止
if erreur:
    sys.exit(2)

# 导入Web应用程序的路由
if config['parameters']['with_csrftoken']:
    import routes_with_csrftoken as routes
else:
    import routes_without_csrftoken as routes

# 配置路由
routes.config = config

# 启动 Flask 应用程序
routes.execute(__name__)
  • 第9-13行:根据应用程序是否使用令牌来选择路由;

34.3.5. 控制器 [MainController]

每次请求时,服务器都必须检查是否存在令牌 CSRF。我们将通过主控制器 [MainController] 来实现这一功能,该控制器会处理所有请求:


from flask_wtf.csrf import generate_csrf, validate_csrf

       # 处理请求
        try:
            # 日志记录
            logger = Logger(config['parameters']['logsFilename'])

            …

            # 获取路径中的元素
            params = request.path.split('/')

            # 操作是第一个元素
            action = params[1]

            …

            if config['parameters']['with_csrftoken']:
                # csrf_token 是路径的最后一个元素
                csrf_token = params.pop()
                # 验证令牌的有效性
                # 如果 csrf_token 不是预期的令牌,将抛出异常
                validate_csrf(csrf_token)

            …

        except ValidationError as exception:
            # CSRF令牌无效
            résultat = {"action": action, "état"121"réponse"[f"{exception}"]}
            status_code = status.HTTP_400_BAD_REQUEST

        except BaseException as exception:
            # 其他(意外)异常
            résultat = {"action": action, "état"131"réponse"[f"{exception}"]}
            status_code = status.HTTP_400_BAD_REQUEST

        finally:
            pass

        # 将 csrf_token 添加到结果中
        résultat['csrf_token'] = generate_csrf()

        # 将发送给客户端的结果记录到日志中
        log = f"[MainController] {résultat}\n"
        logger.write(log)
  • 第 20 行:从类型为 [http://machine :port/chemin/action/param1/param2/…/csrf_token] 的请求中,获取 URL 中的令牌 CSRF。 会话令牌始终是 URL 的最后一个元素;
  • 第 23 行:验证从 URL 中提取的令牌 CSRF 与会话令牌 CSRF 的有效性。 如果无效,函数 [validate_csrf] 将抛出类型为 [ValidationError] 的异常(第 27 行);
  • 第 41 行:将令牌 CSRF 放入发送给客户端的结果中。 客户端 jSON 和 XML 需要该令牌。因为这些客户端收到的 HTML 页面中,其页面内含的链接中并不包含 CSRF 令牌。 因此,他们将通过服务器发送的 jSON 或 XML 结果获取该令牌;

:第23行的[validate_csrf]函数并不检查严格匹配。令牌CSRF会以密钥[csrf_token]的形式存储在会话中。 测试结果似乎表明,如果令牌 CSRF 是该会话期间生成的,则其有效。因此,如果手动将浏览器中显示的 URL(例如 /lister-simulations/xyz) 将令牌 [xyz] CSRF 替换为之前操作中已接收的另一个令牌 [abc],则操作 [/lister-simulations] 将成功;

34.4. 浏览器测试

操作:

  • 使用参数 [with_csrftoken] 启动服务器,并将该参数设置为 [True]
  • 使用浏览器请求 URL [http://localhost:5000]

Image

  • [1] 中,令牌 CSRF;

进行操作直至获得模拟列表:

Image

现在,我们手动输入 URL [http://localhost:5000/supprimer-simulation/1/x] 以删除 id=1 的模拟。我们故意输入一个错误的令牌 CSRF 来观察会发生什么。服务器的响应如下:

Image

注1:目前尚不确定此处采用的方法是否始终足以抵御CSRF攻击。让我们回顾一下攻击流程:

Image

如果通过 [5] 下载的 JavaScript 脚本能够读取爱丽丝所用浏览器的历史记录, 它就能获取浏览器执行过的 URL,以及类似 [/cible/csrf_token] 的 URL。 随后,它将能够获取会话令牌 [csrf_token],并发起攻击 [6-7]。然而,浏览器仅允许利用执行脚本的浏览器窗口中的历史记录。 因此,如果爱丽丝没有使用同一个窗口来访问网站 A([1-2])并阅读玛洛丽的消息([3]),那么攻击(CSRF)将无法实现。

34.5. 控制台客户端

测试该应用程序第 14 版另一种方法是沿用第 12 版的测试方案,并将其适配到新服务器上。

Image

文件 [impots/http-clients/09] 最初是通过复制文件 [impots/http-clients/07] 获得的。随后对其进行了修改。

让我们回到初始化会话的路由:


# 应用程序根目录
@app.route('/', methods=['GET'])
def index() -> tuple:
    # 重定向至 /init-session/html
    return redirect(url_for("init_session", type_response="html", csrf_token=generate_csrf()), status.HTTP_302_FOUND)

# init-session-with-csrf-token
@app.route('/init-session/<string:type_response>/<string:csrf_token>', methods=['GET'])
def init_session(type_response: str, csrf_token: str) -> tuple:
    # 执行与该操作关联的控制器
    return front_controller()

这些路由均不适用于初始化 jSON 或 XML 会话:

  • 第 2-5 行:路由 [/] 初始化会话 HTML;
  • 第8-11行:路由[/init-session]需要一个我们不知道的令牌CSRF;

我们决定在服务器上添加一条新路由:


# 不使用CSRF令牌初始化会话
@app.route('/init-session-without-csrftoken/<string:type_response>', methods=['GET'])
def init_session_without_csrftoken(type_response: str) -> tuple:
    # 重定向至 /init-session/type_response
    return redirect(url_for("init_session", type_response=type_response, csrf_token=generate_csrf()), status.HTTP_302_FOUND)
  • 第2行:新路由。它不要求CSRF令牌。因此我们回到了上一版本的[/init-session]路由;
  • 第4-5行:将客户端(jSON、XML、 HTML)重定向至路由 [/init-session],该路由的参数中包含令牌 CSRF;

我们可以使用浏览器测试这条新路由:

Image

服务器(配置为 [with_csrftoken=True])的响应如下:

Image

  • [1] 路径下,服务器被重定向至 [/init-session] 路径,且 CSRF 令牌位于 URL 中;
  • [2] 中,令牌 CSRF 位于服务器发送的字典 jSON 中,该字典与密钥 [csrf_token] 相关联;

让我们回到客户端代码:

Image

我们将 [config] 的配置修改如下:


   config.update({
        # 纳税人文件
        "taxpayersFilename"f"{script_dir}/../data/input/taxpayersdata.txt",
        # 结果文件
        "resultsFilename"f"{script_dir}/../data/output/résultats.json",
        # 错误文件
        "errorsFilename"f"{script_dir}/../data/output/errors.txt",
        # 日志文件
        "logsFilename"f"{script_dir}/../data/logs/logs.txt",
        # 税款计算服务器
        "server": {
            "urlServer""http://127.0.0.1:5000",
            "user": {
                "login""admin",
                "password""admin"
            },
            "url_services": {
                "calculate-tax""/calculer-impot",
                "get-admindata""/get-admindata",
                "calculate-tax-in-bulk-mode""/calculer-impots",
                "init-session""/init-session-without-csrftoken",
                "end-session""/fin-session",
                "authenticate-user""/authentifier-utilisateur",
                "get-simulations""/lister-simulations",
                "delete-simulation""/supprimer-simulation",
            }
        },
        # 调试模式
        "debug"True,
        # csrf_token
        "with_csrftoken"True,
    }
    )

    # 初始化会话路由
    url_services = config['server']['url_services']
    if config['with_csrftoken']:
        url_services['init-session'] = '/init-session-without-csrftoken'
    else:
        url_services['init-session'] = '/init-session'
  • 第 31 行:一个布尔值将告知客户端,其所连接的服务器是否使用 CSRF 令牌;
  • 第 37-40 行:为操作 [init-session] 设置服务 URL
    • 如果服务器使用 CSRF 令牌,则服务 URL 为 [/init-session-without-csrftoken]
    • 否则,服务令牌 URL 即为 [/init-session]

已提出 [/init-session-without-csrftoken] 路径。它允许 jSON / XML 客户端在未持有 CSRF 令牌的情况下与服务器建立会话。 该客户端将在服务器的响应中获取该令牌。

接下来,我们修改实现客户端 [dao] 层的 [ImpôtsDaoWithHttpSession] 类:

Image


# 导入
import json

import requests
import xmltodict
from flask_api import status

from AbstractImpôtsDao import AbstractImpôtsDao
from AdminData import AdminData
from ImpôtsError import ImpôtsError
from InterfaceImpôtsDaoWithHttpSession import InterfaceImpôtsDaoWithHttpSession
from TaxPayer import TaxPayer

class ImpôtsDaoWithHttpSession(InterfaceImpôtsDaoWithHttpSession):

    # 构造函数
    def __init__(self, config: dict):
        # 父级初始化
        AbstractImpôtsDao.__init__(self, config)
        # 配置项存储
        # 通用配置
        self.__config = config
        # 服务器
        self.__config_server = config["server"]
        # 服务
        self.__config_services = config["server"]['url_services']
        # 调试模式
        self.__debug = config["debug"]
        # 日志记录器
        self.__logger = None
        # Cookie
        self.__cookies = None
        # 会话类型(json、xml)
        self.__session_type = None
        # 令牌CSRF
        self.__csrf_token = None

    # 请求/响应阶段
    def get_response(self, method: str, url_service: str, data_value: dict = None, json_value=None):
        # [method]:方法 HTTP GET 或 POST
        # [url_service]:服务 URL
        # [data]:POST的x-www-form-urlencoded参数
        # [json]:POST的参数(JSON格式)
        # [cookies]:请求中需包含的cookie

        # 必须拥有 XML 或 JSON 会话,否则无法处理响应
        if self.__session_type not in ['json''xml']:
            raise ImpôtsError(73"il n'y a pas de session valide en cours")

        # 将令牌 CSRF 添加到服务端 URL 中
        if self.__csrf_token:
            url_service = f"{url_service}/{self.__csrf_token}"

        # 执行请求
        response = requests.request(method,
                                    url_service,
                                    data=data_value,
                                    json=json_value,
                                    cookies=self.__cookies,
                                    allow_redirects=True)

        # 调试模式?
        if self.__debug:
            # 日志记录器
            if not self.__logger:
                self.__logger = self.__config['logger']
            # 正在记录
            self.__logger.write(f"{response.text}\n")

        # 结果
        if self.__session_type == "json":
            résultat = json.loads(response.text)
        else:  # XML
            résultat = xmltodict.parse(response.text[39:])['root']

        # 如果响应中包含Cookie,则获取Cookie
        if response.cookies:
            self.__cookies = response.cookies

        # 获取令牌 CSRF
        if self.__config['with_csrftoken']:
            self.__csrf_token = résultat.get('csrf_token'None)

        # 状态码
        status_code = response.status_code

        # 若状态码不为 200,则返回 OK
        if status_code != status.HTTP_200_OK:
            raise ImpôtsError(35, résultat['réponse'])

        # 返回结果
        return résultat['réponse']

    
    def init_session(self, session_type: str):
        # 记录会话类型
        self.__session_type = session_type

        # 删除先前调用的令牌 CSRF
        self.__csrf_token = None

        # 请求 init-session 操作的 URL
        url_service = f"{self.__config_server['urlServer']}{self.__config_services['init-session']}/{session_type}"

        # 执行请求
        self.get_response("GET", url_service)

  • 第 38-92 行:令牌 CSRF 的处理主要在方法 [get_response] 中进行;
  • 第 60 行:关键点在于参数 [allow_redirects=True]。这是其默认值,但我们特意将其突出显示;

当处于 [with_csrftoken=True] 模式时:

  • 客户端通过调用路由 [/init-session_without_csftoken/type_response] 开始与服务器的交互;
  • 服务器通过重定向至路由 [/init-session/type_response/csrf_token] 来响应此请求;
  • 由于参数 [allow_redirects=True],客户端 [requests] 将执行此重定向;
  • 在第72行和第74行的检索结果中,将找到与密钥[csrf_token]关联的令牌CSRF;

当处于 [with_csrftoken=False] 模式时:

  • (续)
    • 客户端通过调用路由 [/init-session /type_response] 开始与服务器的对话;
    • 服务器对此请求的响应是重定向至路由 [/init-session/type_response]
    • 由于参数 [allow_redirects=True] 的存在,客户端将随后访问 [requests] 路由;
    • 第 81-82 行中没有可获取的 CSRF 令牌。因此,[self.__csrf_token] 属性始终保持为 None(第 36 行);
  • 第51-52行:对于后续所有请求,若存在令牌CSRF,则将其添加到初始路由中;
  • 第81-82行:服务器在处理客户端每次新请求时生成的令牌会被本地存储,以便在第52行将其发回给后续请求;

此外,[init_session]方法稍作调整:


    def init_session(self, session_type: str):
        # 记录会话类型
        self.__session_type = session_type

        # 删除先前调用生成的令牌 CSRF
        self.__csrf_token = None

        # 请求 init-session 操作的 URL
        url_service = f"{self.__config_server['urlServer']}{self.__config_services['init-session']}/{session_type}"

        # 执行请求
        self.get_response("GET", url_service)

这里需要记住,我们创建了路由 [/init-session-without-csrftoken/<type-response>],用于在不使用令牌 CSRF 的情况下初始化客户端/服务器对话。 然而我们已看到,代码第 12 行调用的 [get_response] 方法会系统地将存储在 [self.__csrf_token] 中的令牌 CSRF 添加到服务端 URL 的末尾。 因此,代码第6行会删除该令牌CSRF(如果存在的话)。

就这样。为了测试,我们将执行:

  • 控制台客户端 [main, main2, main3]
  • 测试类 [Test1HttpClientDaoWithSession] [Test2HttpClientDaoWithSession]

并将配置参数 [with_csrftoken] 依次设置为 True 然后 False。

Image

以下是客户端 [main json][with_csrftoken=True] 配合运行时生成的日志示例:


2020-08-08 16:33:23.317903, MainThread : début du calcul de l'impôt des contribuables
2020-08-08 16:33:23.317903, Thread-1 : début du calcul de l'impôt des 4 contribuables
2020-08-08 16:33:23.317903, Thread-2 : début du calcul de l'impôt des 2 contribuables
2020-08-08 16:33:23.317903, Thread-3 : début du calcul de l'impôt des 4 contribuables
2020-08-08 16:33:23.317903, Thread-4 : début du calcul de l'impôt des 1 contribuables
2020-08-08 16:33:23.379221, Thread-2 : {"action": "init-session", "état": 700, "réponse": ["session démarrée avec le type de réponse json"], "csrf_token": "ImFiZmZkYjZmMzFkZDc2YWRjNWYwOGM0NTBmMGM4ODJjYzViOWI4NGEi.Xy63sw.H5L0--yWsvfaWvggrGw78z5VnN0"}
2020-08-08 16:33:23.381073, Thread-4 : {"action": "init-session", "état": 700, "réponse": ["session démarrée avec le type de réponse json"], "csrf_token": "ImY5YzQyMjlkYzcyYmM4YmZiMGI0NWY5MjE4MzIzNDExZjc0MGQ3MWQi.Xy63sw.q6olg7IP_g2ro_RBFRCX1BX90g8"}
2020-08-08 16:33:23.386982, Thread-3 : {"action": "init-session", "état": 700, "réponse": ["session démarrée avec le type de réponse json"], "csrf_token": "IjkxZGNlN2YyMmUxMjQ0M2Y0MTdjNDQ4ZmQ1MDMxZjkwNjBhNzAzZjMi.Xy63sw.-6buL11No3UJBlElpW4tX4B-lp0"}
2020-08-08 16:33:23.390269, Thread-1 : {"action": "init-session", "état": 700, "réponse": ["session démarrée avec le type de réponse json"], "csrf_token": "IjIxNmU4MDQyZDFmZmIyZDlmZjE4MzNlNDUzYzFjMGYxMWYxYzEwNGYi.Xy63sw.fgs6Cm2owsJf4NjTm7gKrVESabI"}
2020-08-08 16:33:23.413206, Thread-2 : {"action": "authentifier-utilisateur", "état": 200, "réponse": "Authentification réussie", "csrf_token": "ImFiZmZkYjZmMzFkZDc2YWRjNWYwOGM0NTBmMGM4ODJjYzViOWI4NGEi.Xy63sw.H5L0--yWsvfaWvggrGw78z5VnN0"}
2020-08-08 16:33:23.422877, Thread-2 : {"action": "calculer-impots", "état": 1500, "réponse": [{"marié": "non", "enfants": 3, "salaire": 100000, "impôt": 16782, "surcôte": 7176, "taux": 0.41, "décôte": 0, "réduction": 0, "id": 1}, {"marié": "oui", "enfants": 3, "salaire": 100000, "impôt": 9200, "surcôte": 2180, "taux": 0.3, "décôte": 0, "réduction": 0, "id": 2}], "csrf_token": "ImFiZmZkYjZmMzFkZDc2YWRjNWYwOGM0NTBmMGM4ODJjYzViOWI4NGEi.Xy63sw.H5L0--yWsvfaWvggrGw78z5VnN0"}
2020-08-08 16:33:23.428622, Thread-4 : {"action": "authentifier-utilisateur", "état": 200, "réponse": "Authentification réussie", "csrf_token": "ImY5YzQyMjlkYzcyYmM4YmZiMGI0NWY5MjE4MzIzNDExZjc0MGQ3MWQi.Xy63sw.q6olg7IP_g2ro_RBFRCX1BX90g8"}
2020-08-08 16:33:23.429127, Thread-3 : {"action": "authentifier-utilisateur", "état": 200, "réponse": "Authentification réussie", "csrf_token": "IjkxZGNlN2YyMmUxMjQ0M2Y0MTdjNDQ4ZmQ1MDMxZjkwNjBhNzAzZjMi.Xy63sw.-6buL11No3UJBlElpW4tX4B-lp0"}
2020-08-08 16:33:23.429127, Thread-1 : {"action": "authentifier-utilisateur", "état": 200, "réponse": "Authentification réussie", "csrf_token": "IjIxNmU4MDQyZDFmZmIyZDlmZjE4MzNlNDUzYzFjMGYxMWYxYzEwNGYi.Xy63sw.fgs6Cm2owsJf4NjTm7gKrVESabI"}
2020-08-08 16:33:23.429127, Thread-2 : {"action": "fin-session", "état": 400, "réponse": "session réinitialisée", "csrf_token": "IjU1YjlmZDA0OWRhNTJlODFmYjgyYjlhM2ExYWNhZmUzNTk2NjA5NGIi.Xy63sw.nyNSvkcG6iG0oIMBjtYPo8ySgdw"}
2020-08-08 16:33:23.438519, Thread-2 : fin du calcul de l'impôt des 2 contribuables
2020-08-08 16:33:23.443033, Thread-4 : {"action": "calculer-impots", "état": 1500, "réponse": [{"marié": "oui", "enfants": 3, "salaire": 200000, "impôt": 42842, "surcôte": 17283, "taux": 0.41, "décôte": 0, "réduction": 0, "id": 1}], "csrf_token": "ImY5YzQyMjlkYzcyYmM4YmZiMGI0NWY5MjE4MzIzNDExZjc0MGQ3MWQi.Xy63sw.q6olg7IP_g2ro_RBFRCX1BX90g8"}
2020-08-08 16:33:23.446510, Thread-3 : {"action": "calculer-impots", "état": 1500, "réponse": [{"marié": "oui", "enfants": 5, "salaire": 100000, "impôt": 4230, "surcôte": 0, "taux": 0.14, "décôte": 0, "réduction": 0, "id": 1}, {"marié": "non", "enfants": 0, "salaire": 100000, "impôt": 22986, "surcôte": 0, "taux": 0.41, "décôte": 0, "réduction": 0, "id": 2}, {"marié": "oui", "enfants": 2, "salaire": 30000, "impôt": 0, "surcôte": 0, "taux": 0.0, "décôte": 0, "réduction": 0, "id": 3}, {"marié": "non", "enfants": 0, "salaire": 200000, "impôt": 64210, "surcôte": 7498, "taux": 0.45, "décôte": 0, "réduction": 0, "id": 4}], "csrf_token": "IjkxZGNlN2YyMmUxMjQ0M2Y0MTdjNDQ4ZmQ1MDMxZjkwNjBhNzAzZjMi.Xy63sw.-6buL11No3UJBlElpW4tX4B-lp0"}
2020-08-08 16:33:23.453477, Thread-1 : {"action": "calculer-impots", "état": 1500, "réponse": [{"marié": "oui", "enfants": 2, "salaire": 55555, "impôt": 2814, "surcôte": 0, "taux": 0.14, "décôte": 0, "réduction": 0, "id": 1}, {"marié": "oui", "enfants": 2, "salaire": 50000, "impôt": 1384, "surcôte": 0, "taux": 0.14, "décôte": 384, "réduction": 347, "id": 2}, {"marié": "oui", "enfants": 3, "salaire": 50000, "impôt": 0, "surcôte": 0, "taux": 0.14, "décôte": 720, "réduction": 0, "id": 3}, {"marié": "non", "enfants": 2, "salaire": 100000, "impôt": 19884, "surcôte": 4480, "taux": 0.41, "décôte": 0, "réduction": 0, "id": 4}], "csrf_token": "IjIxNmU4MDQyZDFmZmIyZDlmZjE4MzNlNDUzYzFjMGYxMWYxYzEwNGYi.Xy63sw.fgs6Cm2owsJf4NjTm7gKrVESabI"}
2020-08-08 16:33:23.457912, Thread-4 : {"action": "fin-session", "état": 400, "réponse": "session réinitialisée", "csrf_token": "IjQ0ZDQxODgzN2M5NjRiYWI0NjA2MTk5YWFkNGFhMzY1M2IxNWMyNDIi.Xy63sw.mOa5MKXvJ-EXf_qEok-OqC5j_mg"}
2020-08-08 16:33:23.458442, Thread-4 : fin du calcul de l'impôt des 1 contribuables
2020-08-08 16:33:23.459045, Thread-3 : {"action": "fin-session", "état": 400, "réponse": "session réinitialisée", "csrf_token": "ImQ0NDZlYmViYjY1ZDUxYzJhMTNmM2JiZTRkMjBjZGJkYzE0OGVkYzMi.Xy63sw.fviTJz4zFDqVLlVlkrosT_JRPww"}
2020-08-08 16:33:23.459700, Thread-3 : fin du calcul de l'impôt des 4 contribuables
2020-08-08 16:33:23.460492, Thread-1 : {"action": "fin-session", "état": 400, "réponse": "session réinitialisée", "csrf_token": "Ijg3MjQ1NGUyYTUyOGEyNTdmZmNmYWZkMmU2OTgyMzUwNjI1YTlhZjIi.Xy63sw.I0xBl9Q8DzsuXPSgOdeARc_VKBA"}
2020-08-08 16:33:23.460492, Thread-1 : fin du calcul de l'impôt des 4 contribuables
2020-08-08 16:33:23.460492, MainThread : fin du calcul de l'impôt des contribuables

如果观察依次接收到的 CSRF 令牌,可以看到它们各不相同。