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)