diff --git a/account/serializers.py b/account/serializers.py index fe128f6..112d59d 100644 --- a/account/serializers.py +++ b/account/serializers.py @@ -44,3 +44,23 @@ class EditUserSerializer(serializers.Serializer): open_api = serializers.BooleanField() two_factor_auth = serializers.BooleanField() is_disabled = serializers.BooleanField() + + +class ApplyResetPasswordSerializer(serializers.Serializer): + email = serializers.EmailField() + captcha = serializers.CharField(max_length=4, min_length=4) + + +class ResetPasswordSerializer(serializers.Serializer): + token = serializers.CharField(min_length=1, max_length=40) + password = serializers.CharField(min_length=6, max_length=30) + captcha = serializers.CharField(max_length=4, min_length=4) + + +class SSOSerializer(serializers.Serializer): + appkey = serializers.CharField(max_length=35) + token = serializers.CharField(max_length=40) + + +class TwoFactorAuthCodeSerializer(serializers.Serializer): + code = serializers.IntegerField() diff --git a/account/tasks.py b/account/tasks.py new file mode 100644 index 0000000..6470b70 --- /dev/null +++ b/account/tasks.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from celery import shared_task +from utils.mail import send_email + + +@shared_task +def _send_email(from_name, to_email, to_name, subject, content): + send_email(from_name, to_email, to_name, subject, content) diff --git a/account/templates/reset_password_email.html b/account/templates/reset_password_email.html new file mode 100644 index 0000000..5a0b591 --- /dev/null +++ b/account/templates/reset_password_email.html @@ -0,0 +1,78 @@ + + + + + + +
+ + + + + + +
+ {{ website_name }} 登录信息找回 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Hello, {{ username }}: +
+ 您刚刚在 {{ website_name }} 申请了找回登录信息服务。 +
+ 请在30分钟内点击下面链接设置您的新密码: +
+ 重置密码 +
+ 如果上面的链接点击无效,请复制以下链接至浏览器的地址栏直接打开。 +
+ + {{ link }} + +
+ 如果您没有提出过该申请,请忽略此邮件。有可能是其他用户误填了您的邮件地址,我们不会对你的帐户进行任何修改。 + 请不要向他人透露本邮件的内容,否则可能会导致您的账号被盗。 +
+
\ No newline at end of file diff --git a/account/urls/admin.py b/account/urls/admin.py index 372ba24..b10741e 100644 --- a/account/urls/admin.py +++ b/account/urls/admin.py @@ -3,5 +3,5 @@ from django.conf.urls import url from ..views.admin import UserAdminAPI urlpatterns = [ - url(r"^user$", UserAdminAPI.as_view(), name="user_admin_api"), + url(r"^user/?$", UserAdminAPI.as_view(), name="user_admin_api"), ] diff --git a/account/urls/oj.py b/account/urls/oj.py index 6a1ef6c..e73311f 100644 --- a/account/urls/oj.py +++ b/account/urls/oj.py @@ -1,9 +1,15 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + from django.conf.urls import url -from ..views.oj import UserChangePasswordAPI, UserLoginAPI, UserRegisterAPI +from ..views.oj import (UserChangePasswordAPI, UserLoginAPI, UserRegisterAPI, + ApplyResetPasswordAPI, ResetPasswordAPI) urlpatterns = [ - url(r"^login$", UserLoginAPI.as_view(), name="user_login_api"), - url(r"^register$", UserRegisterAPI.as_view(), name="user_register_api"), - url(r"^change_password$", UserChangePasswordAPI.as_view(), name="user_change_password_api") + url(r"^login/?$", UserLoginAPI.as_view(), name="user_login_api"), + url(r"^register/?$", UserRegisterAPI.as_view(), name="user_register_api"), + url(r"^change_password/?$", UserChangePasswordAPI.as_view(), name="user_change_password_api"), + url(r"^apply_reset_password/?$", ApplyResetPasswordAPI.as_view(), name="apply_reset_password_api"), + url(r"^reset_password/?$", ResetPasswordAPI.as_view(), name="apply_reset_password_api") ] diff --git a/account/urls/user.py b/account/urls/user.py new file mode 100644 index 0000000..3ec765a --- /dev/null +++ b/account/urls/user.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from django.conf.urls import url + +from ..views.user import (UserInfoAPI, UserProfileAPI, AvatarUploadAPI, + SSOAPI, TwoFactorAuthAPI) + +urlpatterns = [ + url(r"^user/?$", UserInfoAPI.as_view(), name="user_info_api"), + url(r"^profile/?$", UserProfileAPI.as_view(), name="user_profile_api"), + url(r"^avatar/upload/?$", AvatarUploadAPI.as_view(), name="avatar_upload_api"), + url(r"^sso/?$", SSOAPI.as_view(), name="sso_api"), + url(r"^two_factor_auth/?$", TwoFactorAuthAPI.as_view(), name="two_factor_auth_api") +] diff --git a/account/views/oj.py b/account/views/oj.py index 3e48a12..125fc69 100644 --- a/account/views/oj.py +++ b/account/views/oj.py @@ -1,15 +1,26 @@ -from django.contrib import auth -from django.core.exceptions import MultipleObjectsReturned -from django.utils.translation import ugettext as _ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from datetime import timedelta from otpauth import OtpAuth +from django.contrib import auth +from django.conf import settings +from django.core.exceptions import MultipleObjectsReturned +from django.utils.translation import ugettext as _ +from django.utils.timezone import now + +from conf.models import WebsiteConfig from utils.api import APIView, validate_serializer from utils.captcha import Captcha +from utils.shortcuts import rand_str from ..decorators import login_required from ..models import User, UserProfile from ..serializers import (UserChangePasswordSerializer, UserLoginSerializer, - UserRegisterSerializer) + UserRegisterSerializer, + ApplyResetPasswordSerializer, ResetPasswordSerializer) +from ..tasks import _send_email class UserLoginAPI(APIView): @@ -92,3 +103,55 @@ class UserChangePasswordAPI(APIView): return self.success(_("Succeeded")) else: return self.error(_("Invalid old password")) + + +class ApplyResetPasswordAPI(APIView): + @validate_serializer(ApplyResetPasswordSerializer) + def post(self, request): + data = request.data + captcha = Captcha(request) + config = WebsiteConfig.objects.first() + if not captcha.check(data["captcha"]): + return self.error(_("Invalid captcha")) + try: + user = User.objects.get(email=data["email"]) + except User.DoesNotExist: + return self.error(_("User does not exist")) + if user.reset_password_token_expire_time and 0 < ( + user.reset_password_token_expire_time - now()).total_seconds() < 20 * 60: + return self.error(_("You can only reset password once per 20 minutes")) + user.reset_password_token = rand_str() + + user.reset_password_token_expire_time = now() + timedelta(minutes=20) + user.save() + email_template = open("reset_password_email.html", "w", + encoding="utf-8").read() + email_template = email_template.replace("{{ username }}", user.username). \ + replace("{{ website_name }}", settings.WEBSITE_INFO["website_name"]). \ + replace("{{ link }}", settings.WEBSITE_INFO["url"] + "/reset_password/t/" + + user.reset_password_token) + _send_email.delay(config.name, + user.email, + user.username, + config.name + " 登录信息找回邮件", + email_template) + return self.success(_("Succeeded")) + + +class ResetPasswordAPI(APIView): + @validate_serializer(ResetPasswordSerializer) + def post(self, request): + data = request.data + captcha = Captcha(request) + if not captcha.check(data["captcha"]): + return self.error(_("Invalid captcha")) + try: + user = User.objects.get(reset_password_token=data["token"]) + except User.DoesNotExist: + return self.error(_("Token dose not exist")) + if 0 < (user.reset_password_token_expire_time - now()).total_seconds() < 30 * 60: + return self.error(_("Token expired")) + user.reset_password_token = None + user.set_password(data["password"]) + user.save() + return self.success(_("Succeeded")) diff --git a/account/views/user.py b/account/views/user.py new file mode 100644 index 0000000..d3bcacf --- /dev/null +++ b/account/views/user.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import qrcode + +from io import StringIO +from otpauth import OtpAuth + +from django.conf import settings +from django.http import HttpResponse +from django.utils.translation import ugettext as _ + +from conf.models import WebsiteConfig +from utils.api import APIView, validate_serializer +from utils.shortcuts import rand_str + +from ..decorators import login_required +from ..models import User +from ..serializers import (EditUserSerializer, UserSerializer, + SSOSerializer, TwoFactorAuthCodeSerializer) + + +class UserInfoAPI(APIView): + @login_required + def get(self, request): + """ + Return user info api + """ + return self.success(UserSerializer(request.user).data) + + +class UserProfileAPI(APIView): + @login_required + def get(self, request): + """ + Return user info api + """ + return self.success(UserSerializer(request.user).data) + + @validate_serializer(EditUserSerializer) + @login_required + def put(self, request): + data = request.data + user_profile = request.user.userprofile + if data["avatar"]: + user_profile.avatar = data["avatar"] + else: + user_profile.mood = data["mood"] + user_profile.blog = data["blog"] + user_profile.school = data["school"] + user_profile.student_id = data["student_id"] + user_profile.phone_number = data["phone_number"] + user_profile.major = data["major"] + # Timezone & language 暂时不加 + user_profile.save() + return self.success(_("Succeeded")) + + +class AvatarUploadAPI(APIView): + def post(self, request): + if "file" not in request.FILES: + return self.error(_("Upload failed")) + + f = request.FILES["file"] + if f.size > 1024 * 1024: + return self.error(_("Picture too large")) + if os.path.splitext(f.name)[-1].lower() not in [".gif", ".jpg", ".jpeg", ".bmp", ".png"]: + return self.error(_("Unsupported file format")) + + name = "avatar_" + rand_str(5) + os.path.splitext(f.name)[-1] + with open(os.path.join(settings.IMAGE_UPLOAD_DIR, name), "wb") as img: + for chunk in request.FILES["file"]: + img.write(chunk) + return self.success({"path": "/static/upload/" + name}) + + +class SSOAPI(APIView): + @login_required + def get(self, request): + callback = request.GET.get("callback", None) + if not callback: + return self.error(_("Parameter Error")) + token = rand_str() + request.user.auth_token = token + request.user.save() + return self.success({"redirect_url": callback + "?token=" + token, + "callback": callback}) + + @validate_serializer(SSOSerializer) + def post(self, request): + data = request.data + try: + User.objects.get(open_api_appkey=data["appkey"]) + except User.DoesNotExist: + return self.error(_("Invalid appkey")) + try: + user = User.objects.get(auth_token=data["token"]) + user.auth_token = None + user.save() + return self.success({"username": user.username, + "id": user.id, + "admin_type": user.admin_type, + "avatar": user.userprofile.avatar}) + except User.DoesNotExist: + return self.error("User does not exist") + + +class TwoFactorAuthAPI(APIView): + @login_required + def get(self, request): + """ + Get QR code + """ + user = request.user + if user.two_factor_auth: + return self.error("Already open 2FA") + token = rand_str() + user.tfa_token = token + user.save() + + config = WebsiteConfig.objects.first() + image = qrcode.make(OtpAuth(token).to_uri("totp", config.base_url, config.name)) + buf = StringIO() + image.save(buf, "gif") + + return HttpResponse(buf.getvalue(), "image/gif") + + @login_required + @validate_serializer(TwoFactorAuthCodeSerializer) + def post(self, request): + """ + Open 2FA + """ + code = request.data["code"] + user = request.user + if OtpAuth(user.tfa_token).valid_totp(code): + user.two_factor_auth = True + user.save() + return self.success(_("Succeeded")) + else: + return self.error(_("Invalid captcha")) + + @login_required + @validate_serializer(TwoFactorAuthCodeSerializer) + def put(self, request): + code = request.data["code"] + user = request.user + if OtpAuth(user.tfa_token).valid_totp(code): + user.two_factor_auth = False + user.save() + else: + return self.error(_("Invalid captcha")) diff --git a/announcement/urls/admin.py b/announcement/urls/admin.py index fefc160..6b9ce0f 100644 --- a/announcement/urls/admin.py +++ b/announcement/urls/admin.py @@ -3,5 +3,5 @@ from django.conf.urls import url from ..views import AnnouncementAdminAPI urlpatterns = [ - url(r"^announcement$", AnnouncementAdminAPI.as_view(), name="announcement_admin_api"), + url(r"^announcement/?$", AnnouncementAdminAPI.as_view(), name="announcement_admin_api"), ] diff --git a/conf/urls/admin.py b/conf/urls/admin.py index dcb5c6f..be24272 100644 --- a/conf/urls/admin.py +++ b/conf/urls/admin.py @@ -3,7 +3,7 @@ from django.conf.urls import url from ..views import SMTPAPI, JudgeServerAPI, WebsiteConfigAPI urlpatterns = [ - url(r"^smtp$", SMTPAPI.as_view(), name="smtp_admin_api"), - url(r"^website$", WebsiteConfigAPI.as_view(), name="website_config_api"), - url(r"^judge_server", JudgeServerAPI.as_view(), name="judge_server_api") + url(r"^smtp/?$", SMTPAPI.as_view(), name="smtp_admin_api"), + url(r"^website/?$", WebsiteConfigAPI.as_view(), name="website_config_api"), + url(r"^judge_server/?$", JudgeServerAPI.as_view(), name="judge_server_api") ] diff --git a/conf/urls/oj.py b/conf/urls/oj.py index b5a06e5..3e6d757 100644 --- a/conf/urls/oj.py +++ b/conf/urls/oj.py @@ -3,7 +3,7 @@ from django.conf.urls import url from ..views import JudgeServerHeartbeatAPI, LanguagesAPI, WebsiteConfigAPI urlpatterns = [ - url(r"^website$", WebsiteConfigAPI.as_view(), name="website_info_api"), - url(r"^judge_server_heartbeat$", JudgeServerHeartbeatAPI.as_view(), name="judge_server_heartbeat_api"), - url(r"^languages$", LanguagesAPI.as_view(), name="language_list_api") + url(r"^website/?$", WebsiteConfigAPI.as_view(), name="website_info_api"), + url(r"^judge_server_heartbeat/?$", JudgeServerHeartbeatAPI.as_view(), name="judge_server_heartbeat_api"), + url(r"^languages/?$", LanguagesAPI.as_view(), name="language_list_api") ] diff --git a/contest/urls/admin.py b/contest/urls/admin.py index d9ea9c9..2a7705a 100644 --- a/contest/urls/admin.py +++ b/contest/urls/admin.py @@ -3,6 +3,6 @@ from django.conf.urls import url from ..views.admin import ContestAnnouncementAPI, ContestAPI urlpatterns = [ - url(r"^contest$", ContestAPI.as_view(), name="contest_api"), - url(r"^contest/announcement$", ContestAnnouncementAPI.as_view(), name="contest_announcement_admin_api") + url(r"^contest/?$", ContestAPI.as_view(), name="contest_api"), + url(r"^contest/announcement/?$", ContestAnnouncementAPI.as_view(), name="contest_announcement_admin_api") ] diff --git a/contest/urls/oj.py b/contest/urls/oj.py index e2e48ca..bfc80b8 100644 --- a/contest/urls/oj.py +++ b/contest/urls/oj.py @@ -3,5 +3,5 @@ from django.conf.urls import url from ..views.oj import ContestAnnouncementListAPI urlpatterns = [ - url(r"^contest$", ContestAnnouncementListAPI.as_view(), name="contest_list_api"), + url(r"^contest/?$", ContestAnnouncementListAPI.as_view(), name="contest_list_api"), ] diff --git a/deploy/requirements.txt b/deploy/requirements.txt index ae2a33c..308698b 100644 --- a/deploy/requirements.txt +++ b/deploy/requirements.txt @@ -1,5 +1,5 @@ -Django<1.10 -djangorestframework==3.3.3 +django==1.9.6 +djangorestframework==3.4.0 pillow jsonfield otpauth @@ -7,3 +7,6 @@ flake8-quotes pytz coverage python-dateutil +celery +Envelopes +qrcode \ No newline at end of file diff --git a/oj/urls.py b/oj/urls.py index b8cc62d..79e6b03 100644 --- a/oj/urls.py +++ b/oj/urls.py @@ -3,6 +3,7 @@ from django.conf.urls import include, url urlpatterns = [ url(r"^api/", include("account.urls.oj")), url(r"^api/admin/", include("account.urls.admin")), + url(r"^api/account/", include("account.urls.user")), url(r"^api/admin/", include("announcement.urls.admin")), url(r"^api/", include("conf.urls.oj")), url(r"^api/admin/", include("conf.urls.admin")), diff --git a/problem/urls/admin.py b/problem/urls/admin.py index bfd66c2..7ea423e 100644 --- a/problem/urls/admin.py +++ b/problem/urls/admin.py @@ -3,7 +3,7 @@ from django.conf.urls import url from ..views.admin import ProblemAPI, TestCaseUploadAPI, ContestProblemAPI urlpatterns = [ - url(r"^test_case/upload$", TestCaseUploadAPI.as_view(), name="test_case_upload_api"), - url(r"^problem$", ProblemAPI.as_view(), name="problem_api"), - url(r"^contest/problem$", ContestProblemAPI.as_view(), name="contest_problem_api") + url(r"^test_case/upload/?$", TestCaseUploadAPI.as_view(), name="test_case_upload_api"), + url(r"^problem/?$", ProblemAPI.as_view(), name="problem_api"), + url(r"^contest/problem/?$", ContestProblemAPI.as_view(), name="contest_problem_api") ] diff --git a/problem/urls/oj.py b/problem/urls/oj.py index d99155a..a7613f1 100644 --- a/problem/urls/oj.py +++ b/problem/urls/oj.py @@ -3,5 +3,5 @@ from django.conf.urls import url from ..views.oj import ProblemTagAPI urlpatterns = [ - url(r"^problem/tags$", ProblemTagAPI.as_view(), name="problem_tag_list_api") + url(r"^problem/tags/?$", ProblemTagAPI.as_view(), name="problem_tag_list_api") ] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9b0d805 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +django==1.9.6 +djangorestframework==3.4.0 +otpauth +pillow +python-dateutil +celery +Envelopes +pytz +jsonfield +qrcode \ No newline at end of file diff --git a/utils/mail.py b/utils/mail.py new file mode 100644 index 0000000..6ff7e7d --- /dev/null +++ b/utils/mail.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from envelopes import Envelope + +from conf.models import SMTPConfig + + +def send_email(from_name, to_email, to_name, subject, content): + smtp = SMTPConfig.objects.first() + if not smtp: + return + envlope = Envelope(from_addr=(smtp.email, from_name), + to_addr=(to_email, to_name), + subject=subject, + html_body=content) + envlope.send(smtp.server, + login=smtp.email, + password=smtp.password, + port=smtp.port, + tls=smtp.tls)