From 27d7d61dcbabe926af6814b0711390d9b5100f38 Mon Sep 17 00:00:00 2001 From: RTXUX Date: Sat, 14 Oct 2023 20:10:31 +0800 Subject: [PATCH 1/4] auth: add zju solution --- frontend/auth_providers/zju.py | 79 +++++++++++++++++++++++++---- frontend/templates/login_oauth.html | 13 +++++ requirements-lock.txt | 14 +++-- requirements.txt | 1 + 4 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 frontend/templates/login_oauth.html diff --git a/frontend/auth_providers/zju.py b/frontend/auth_providers/zju.py index 0afa2de..99bd7f5 100644 --- a/frontend/auth_providers/zju.py +++ b/frontend/auth_providers/zju.py @@ -1,24 +1,83 @@ -from datetime import timedelta +import random +import string +import json +import hmac +from Crypto.Cipher import AES +from base64 import b64decode +from urllib.parse import urlencode +from django.conf import settings +from django.contrib import messages +from django.shortcuts import redirect +from django.template.response import TemplateResponse from django.urls import path +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt -from .base import DomainEmailValidator -from .external import ExternalLoginView, ExternalGetCodeView +from .base import BaseLoginView +prng = random.SystemRandom() -class LoginView(ExternalLoginView): - template_context = {'provider_name': '浙江大学'} +class LoginView(BaseLoginView): + template_name = 'login_oauth.html' + template_context = {'provider_name': '浙江大学统一身份认证', 'info': '此次登录需要您处在一个能够访问浙江大学内网的环境;如果您不在校内,请尝试使用 RVPN 等工具访问内网;确认能访问内网后,请单击登录按钮。'} provider = 'zju' group = 'zju' + state_key = settings.EXTERNAL_LOGINS[provider]['state_key'].encode() + cipher_key = b64decode(settings.EXTERNAL_LOGINS[provider]['cipher_key']) + provider_url = settings.EXTERNAL_LOGINS[provider]['provider_url'] + @method_decorator(csrf_exempt) # XXX: POST请求来源于外部,不宜外传csrf token,且反CSRF的功能已被state+proof实现 + def dispatch(self, *args, **kwargs): + return super(LoginView, self).dispatch(*args, **kwargs) -class GetCodeView(ExternalGetCodeView): - provider = 'zju' - duration = timedelta(hours=1) - validate_identity = DomainEmailValidator('zju.edu.cn') + + def calc_hmac(self, state): + return hmac.new(self.state_key, state.encode(), 'sha256').hexdigest() + + def get(self, request): + template_context = self.template_context.copy() + state = ''.join(prng.sample(string.ascii_letters + string.digits, 32)) + proof = self.calc_hmac(state) + redirect_uri = request.build_absolute_uri('/accounts/zju/login/?' + urlencode({'proof': proof})) + template_context['url'] = self.provider_url + '?' + urlencode({'redirect_uri': redirect_uri, 'state': state}) + return TemplateResponse(request, self.template_name, template_context) + + def check_cipher(self): + try: + ciphertext = self.request.POST.get('cipher') + proof = self.request.GET.get('proof') + assert isinstance(ciphertext, str) and isinstance(proof, str) + ciphertext = b64decode(ciphertext) + cipher = AES.new(self.cipher_key, AES.MODE_GCM, ciphertext[:16]) + payload = json.loads(cipher.decrypt_and_verify(ciphertext[16:-16], ciphertext[-16:])) + student_id = payload['student_id'] + state = payload['state'] + assert isinstance(student_id, str) and isinstance(state, str) + assert self.calc_hmac(state) == proof + student_id = student_id.strip() + # XXX: 实际上的长度是 8,但作为可信内容放宽限制来避免一些特殊情况 + if not (all(char in string.digits for char in student_id) and 5 <= len(student_id) <= 26): + messages.error(self.request, '学号非法') + return False + self.sno = student_id + self.name = payload['name'] + # XXX: 能够以此学号登录应当与拥有此邮箱等价 + self.identity = student_id + '@zju.edu.cn' + return True + except Exception: + messages.error(self.request, '登录失败') + return False + + def normalize_identity(self): + return self.identity.casefold() + + def post(self, request): + if self.check_cipher(): + self.login(email=self.identity, sno=self.sno, name=self.name) + return redirect('hub') urlpatterns = [ path('zju/login/', LoginView.as_view()), - path('zju/get_code/', GetCodeView.as_view()), ] diff --git a/frontend/templates/login_oauth.html b/frontend/templates/login_oauth.html new file mode 100644 index 0000000..72b4abe --- /dev/null +++ b/frontend/templates/login_oauth.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block content %} + {% block form %} +

{% block heading %}{{ provider_name }}登录{% endblock %}

+ + {% block info %} +
+

{{ info }}

+
+ {% endblock %} + {% endblock %} +{% endblock %} diff --git a/requirements-lock.txt b/requirements-lock.txt index 5f51a61..6972808 100644 --- a/requirements-lock.txt +++ b/requirements-lock.txt @@ -1,15 +1,17 @@ aliyun-python-sdk-core==2.13.36 asgiref==3.7.2 +backports.zoneinfo==0.2.1 certifi==2023.7.22 -cffi==1.15.1 -charset-normalizer==3.2.0 +cffi==1.16.0 +charset-normalizer==3.3.0 cryptography==41.0.4 defusedxml==0.7.1 Django==4.2.5 django-allauth==0.57.0 gevent==23.9.1 -greenlet==3.0.0rc3 +greenlet==3.0.0 idna==3.4 +importlib-metadata==6.8.0 jmespath==0.10.0 Markdown==3.4.4 oauthlib==3.2.2 @@ -17,6 +19,7 @@ psycopg==3.1.12 psycopg-binary==3.1.12 psycopg-pool==3.1.8 pycparser==2.21 +pycryptodome==3.19.0 PyJWT==2.8.0 pymemcache==4.0.0 pyOpenSSL==23.2.0 @@ -25,8 +28,9 @@ PyYAML==6.0.1 requests==2.31.0 requests-oauthlib==1.3.1 sqlparse==0.4.4 -typing_extensions==4.7.1 +typing-extensions==4.8.0 urllib3==2.0.6 uWSGI==2.0.22 +zipp==3.17.0 zope.event==5.0 -zope.interface==6.0 +zope.interface==6.1 diff --git a/requirements.txt b/requirements.txt index 52da158..c4c2132 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ Markdown==3.4.4 psycopg==3.1.12 psycopg-binary==3.1.12 psycopg-pool==3.1.8 +pycryptodome==3.19.0 pymemcache==4.0.0 pyOpenSSL==23.2.0 PyYAML==6.0.1 From 29f2ae20197b2138314f5ea319de1d009cd16a2c Mon Sep 17 00:00:00 2001 From: taoky Date: Sun, 15 Oct 2023 00:58:03 +0800 Subject: [PATCH 2/4] dep: remove backports.zoneinfo Targeted minimal Python version is 3.9, so no backports.zoneinfo please. --- .github/workflows/smoketest.yml | 2 +- requirements-lock.txt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/smoketest.yml b/.github/workflows/smoketest.yml index ebcbac8..d9e535e 100644 --- a/.github/workflows/smoketest.yml +++ b/.github/workflows/smoketest.yml @@ -28,7 +28,7 @@ jobs: python3 -m venv .venv source .venv/bin/activate pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements-lock.txt cp conf/local_settings.py.example conf/local_settings.py PRIVATE_KEY=$(openssl ecparam -name secp256k1 -genkey -noout) diff --git a/requirements-lock.txt b/requirements-lock.txt index 6972808..36e81f2 100644 --- a/requirements-lock.txt +++ b/requirements-lock.txt @@ -1,6 +1,5 @@ aliyun-python-sdk-core==2.13.36 asgiref==3.7.2 -backports.zoneinfo==0.2.1 certifi==2023.7.22 cffi==1.16.0 charset-normalizer==3.3.0 From 9de5682221c08b38f432d18853f1f027a50a450b Mon Sep 17 00:00:00 2001 From: taoky Date: Sun, 15 Oct 2023 01:36:42 +0800 Subject: [PATCH 3/4] zju: code style and comments, etc. --- frontend/auth_providers/zju.py | 87 ++++++++++++------- .../{login_oauth.html => login_info.html} | 2 + 2 files changed, 60 insertions(+), 29 deletions(-) rename frontend/templates/{login_oauth.html => login_info.html} (86%) diff --git a/frontend/auth_providers/zju.py b/frontend/auth_providers/zju.py index 99bd7f5..5d67cef 100644 --- a/frontend/auth_providers/zju.py +++ b/frontend/auth_providers/zju.py @@ -1,7 +1,8 @@ -import random +import secrets import string import json import hmac +from typing import Any from Crypto.Cipher import AES from base64 import b64decode from urllib.parse import urlencode @@ -16,57 +17,85 @@ from .base import BaseLoginView -prng = random.SystemRandom() class LoginView(BaseLoginView): - template_name = 'login_oauth.html' - template_context = {'provider_name': '浙江大学统一身份认证', 'info': '此次登录需要您处在一个能够访问浙江大学内网的环境;如果您不在校内,请尝试使用 RVPN 等工具访问内网;确认能访问内网后,请单击登录按钮。'} - provider = 'zju' - group = 'zju' - state_key = settings.EXTERNAL_LOGINS[provider]['state_key'].encode() - cipher_key = b64decode(settings.EXTERNAL_LOGINS[provider]['cipher_key']) - provider_url = settings.EXTERNAL_LOGINS[provider]['provider_url'] + template_name = "login_info.html" + template_context = { + "provider_name": "浙江大学统一身份认证", + "info": "此次登录需要您处在一个能够访问浙江大学内网的环境;如果您不在校内,请尝试使用 RVPN 等工具访问内网;确认能访问内网后,请单击登录按钮。", + } + provider = "zju" + group = "zju" - @method_decorator(csrf_exempt) # XXX: POST请求来源于外部,不宜外传csrf token,且反CSRF的功能已被state+proof实现 + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + # 避免 local_settings 未配置时出错 + provider = settings.EXTERNAL_LOGINS.get(self.provider) + if not provider: + raise RuntimeError(f"未配置{self.template_context['provider_name']}登录") + self.state_key = provider["state_key"].encode() + self.cipher_key = b64decode(provider["cipher_key"]) + self.provider_url = provider["provider_url"] + + # XXX: POST 请求到外部,不宜外传 csrf token,且反 CSRF 的功能已被 state+proof 实现 + @method_decorator(csrf_exempt) def dispatch(self, *args, **kwargs): return super(LoginView, self).dispatch(*args, **kwargs) - - def calc_hmac(self, state): - return hmac.new(self.state_key, state.encode(), 'sha256').hexdigest() + def calc_hmac(self, state: str) -> str: + return hmac.new(self.state_key, state.encode(), "sha256").hexdigest() def get(self, request): template_context = self.template_context.copy() - state = ''.join(prng.sample(string.ascii_letters + string.digits, 32)) + state = "".join( + secrets.choice(string.ascii_letters + string.digits) for _ in range(32) + ) proof = self.calc_hmac(state) - redirect_uri = request.build_absolute_uri('/accounts/zju/login/?' + urlencode({'proof': proof})) - template_context['url'] = self.provider_url + '?' + urlencode({'redirect_uri': redirect_uri, 'state': state}) + + # 在登录前显示提示信息(而非直接跳转) + redirect_uri = request.build_absolute_uri( + "/accounts/zju/login/?" + urlencode({"proof": proof}) + ) + template_context["url"] = ( + self.provider_url + + "?" + + urlencode({"redirect_uri": redirect_uri, "state": state}) + ) return TemplateResponse(request, self.template_name, template_context) - def check_cipher(self): + def check_cipher(self) -> bool: try: - ciphertext = self.request.POST.get('cipher') - proof = self.request.GET.get('proof') + ciphertext = self.request.POST.get("cipher") + proof = self.request.GET.get("proof") assert isinstance(ciphertext, str) and isinstance(proof, str) + # ciphertext: + # 16 bytes: nonce + # 16 bytes till end - 16 bytes: payload + # 16 bytes from end: MAC ciphertext = b64decode(ciphertext) cipher = AES.new(self.cipher_key, AES.MODE_GCM, ciphertext[:16]) - payload = json.loads(cipher.decrypt_and_verify(ciphertext[16:-16], ciphertext[-16:])) - student_id = payload['student_id'] - state = payload['state'] + payload = json.loads( + cipher.decrypt_and_verify(ciphertext[16:-16], ciphertext[-16:]) + ) + student_id = payload["student_id"] + state = payload["state"] assert isinstance(student_id, str) and isinstance(state, str) assert self.calc_hmac(state) == proof student_id = student_id.strip() # XXX: 实际上的长度是 8,但作为可信内容放宽限制来避免一些特殊情况 - if not (all(char in string.digits for char in student_id) and 5 <= len(student_id) <= 26): - messages.error(self.request, '学号非法') + if not ( + all(char in string.digits for char in student_id) + and 5 <= len(student_id) <= 26 + ): + messages.error(self.request, "学号非法") return False self.sno = student_id - self.name = payload['name'] + self.name = payload["name"] # XXX: 能够以此学号登录应当与拥有此邮箱等价 - self.identity = student_id + '@zju.edu.cn' + self.identity = student_id + "@zju.edu.cn" return True except Exception: - messages.error(self.request, '登录失败') + messages.error(self.request, "登录失败") return False def normalize_identity(self): @@ -75,9 +104,9 @@ def normalize_identity(self): def post(self, request): if self.check_cipher(): self.login(email=self.identity, sno=self.sno, name=self.name) - return redirect('hub') + return redirect("hub") urlpatterns = [ - path('zju/login/', LoginView.as_view()), + path("zju/login/", LoginView.as_view()), ] diff --git a/frontend/templates/login_oauth.html b/frontend/templates/login_info.html similarity index 86% rename from frontend/templates/login_oauth.html rename to frontend/templates/login_info.html index 72b4abe..4da2c8d 100644 --- a/frontend/templates/login_oauth.html +++ b/frontend/templates/login_info.html @@ -1,5 +1,7 @@ {% extends 'base.html' %} +{% comment %}包含自定义 info 的登录页模板{% endcomment %} + {% block content %} {% block form %}

{% block heading %}{{ provider_name }}登录{% endblock %}

From b2b4533c77449af9e8c09506e0bf9b49d3bcbff4 Mon Sep 17 00:00:00 2001 From: RTXUX Date: Sun, 15 Oct 2023 14:02:02 +0800 Subject: [PATCH 4/4] zju: fix by zju AAA --- frontend/auth_providers/zju.py | 74 ++++++++++++++++------------------ 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/frontend/auth_providers/zju.py b/frontend/auth_providers/zju.py index 5d67cef..2c72e98 100644 --- a/frontend/auth_providers/zju.py +++ b/frontend/auth_providers/zju.py @@ -1,7 +1,6 @@ import secrets import string import json -import hmac from typing import Any from Crypto.Cipher import AES from base64 import b64decode @@ -37,74 +36,69 @@ def __init__(self, **kwargs: Any) -> None: self.cipher_key = b64decode(provider["cipher_key"]) self.provider_url = provider["provider_url"] - # XXX: POST 请求到外部,不宜外传 csrf token,且反 CSRF 的功能已被 state+proof 实现 + # XXX: POST 请求到外部,不宜外传 csrf token,且反 CSRF 的功能已被 nonce 实现 @method_decorator(csrf_exempt) def dispatch(self, *args, **kwargs): return super(LoginView, self).dispatch(*args, **kwargs) - def calc_hmac(self, state: str) -> str: - return hmac.new(self.state_key, state.encode(), "sha256").hexdigest() - - def get(self, request): - template_context = self.template_context.copy() - state = "".join( - secrets.choice(string.ascii_letters + string.digits) for _ in range(32) - ) - proof = self.calc_hmac(state) - - # 在登录前显示提示信息(而非直接跳转) - redirect_uri = request.build_absolute_uri( - "/accounts/zju/login/?" + urlencode({"proof": proof}) - ) - template_context["url"] = ( - self.provider_url - + "?" - + urlencode({"redirect_uri": redirect_uri, "state": state}) - ) - return TemplateResponse(request, self.template_name, template_context) + def normalize_identity(self): + return self.identity.casefold() def check_cipher(self) -> bool: try: - ciphertext = self.request.POST.get("cipher") - proof = self.request.GET.get("proof") - assert isinstance(ciphertext, str) and isinstance(proof, str) + ciphertext = self.cipher + nonce = self.request.session.get("auth_nonce_zju") + assert isinstance(ciphertext, str) and isinstance(nonce, str) + self.request.session.pop("auth_nonce_zju") # ciphertext: # 16 bytes: nonce # 16 bytes till end - 16 bytes: payload # 16 bytes from end: MAC ciphertext = b64decode(ciphertext) cipher = AES.new(self.cipher_key, AES.MODE_GCM, ciphertext[:16]) + cipher.update(nonce.encode()) payload = json.loads( cipher.decrypt_and_verify(ciphertext[16:-16], ciphertext[-16:]) ) - student_id = payload["student_id"] - state = payload["state"] - assert isinstance(student_id, str) and isinstance(state, str) - assert self.calc_hmac(state) == proof - student_id = student_id.strip() + sno = payload["sno"] + assert isinstance(sno, str) + sno = sno.strip() # XXX: 实际上的长度是 8,但作为可信内容放宽限制来避免一些特殊情况 if not ( - all(char in string.digits for char in student_id) - and 5 <= len(student_id) <= 26 + all(char in string.digits for char in sno) + and 5 <= len(sno) <= 26 ): messages.error(self.request, "学号非法") return False - self.sno = student_id + self.sno = sno self.name = payload["name"] # XXX: 能够以此学号登录应当与拥有此邮箱等价 - self.identity = student_id + "@zju.edu.cn" + self.identity = sno + "@zju.edu.cn" return True except Exception: messages.error(self.request, "登录失败") return False - def normalize_identity(self): - return self.identity.casefold() + def get(self, request): + self.cipher = request.GET.get("cipher") + if self.cipher: + if self.check_cipher(): + self.login(email=self.identity, sno=self.sno, name=self.name) + return redirect("hub") + template_context = self.template_context.copy() + nonce = "".join( + secrets.choice(string.ascii_letters + string.digits) for _ in range(32) + ) + request.session['auth_nonce_zju'] = nonce - def post(self, request): - if self.check_cipher(): - self.login(email=self.identity, sno=self.sno, name=self.name) - return redirect("hub") + # 在登录前显示提示信息(而非直接跳转) + redirect_uri = request.build_absolute_uri("/accounts/zju/login/") + template_context["url"] = ( + self.provider_url + + "?" + + urlencode({"redirect_uri": redirect_uri, "nonce": nonce}) + ) + return TemplateResponse(request, self.template_name, template_context) urlpatterns = [