diff --git a/.gitignore b/.gitignore index b6e4761..4c56955 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,5 @@ dmypy.json # Pyre type checker .pyre/ + +*.sqlite3 diff --git a/drf_passwordless_jwt/settings.py b/drf_passwordless_jwt/settings.py index d1b10c8..b9637df 100644 --- a/drf_passwordless_jwt/settings.py +++ b/drf_passwordless_jwt/settings.py @@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/4.1/ref/settings/ """ +from os import getenv from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -20,12 +21,18 @@ # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-yhe(15ptdmm(cbczejzf4x-h)f!=b$vyfw@68+t2y6i_ic!(w@' +SECRET_KEY = getenv( + 'DJANGO_SECRET_KEY', + 'django-insecure-yhe(15ptdmm(cbczejzf4x-h)f!=b$vyfw@68+t2y6i_ic!(w@', +) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = bool(int(getenv('DJANGO_DEBUG', 1))) -ALLOWED_HOSTS = [] +if getenv('DJANGO_ALLOWED_HOSTS'): + ALLOWED_HOSTS = getenv('DJANGO_ALLOWED_HOSTS').split(',') +else: + ALLOWED_HOSTS = ['*'] # Application definition @@ -37,8 +44,49 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'rest_framework', + 'rest_framework.authtoken', + 'drfpasswordless', ] +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + ], +} + +PASSWORDLESS_AUTH = { + 'PASSWORDLESS_AUTH_TYPES': ['EMAIL'], + 'PASSWORDLESS_EMAIL_NOREPLY_ADDRESS': getenv('OTP_EMAIL_ADDRESS', 'xyb@mydomain.com'), + 'PASSWORDLESS_TOKEN_EXPIRE_TIME': int(getenv('OTP_TOKEN_EXPIRE_SECONDS', 5 * 60)), +} +if getenv('OTP_EMAIL_SUBJECT'): + PASSWORDLESS_AUTH['PASSWORDLESS_EMAIL_SUBJECT'] = getenv('OTP_EMAIL_SUBJECT') +if getenv('OTP_EMAIL_PLAINTEXT'): + PASSWORDLESS_AUTH['PASSWORDLESS_EMAIL_PLAINTEXT_MESSAGE'] = getenv('OTP_EMAIL_PLAINTEXT') +if getenv('OTP_EMAIL_HTML'): + PASSWORDLESS_AUTH['PASSWORDLESS_EMAIL_TOKEN_HTML_TEMPLATE_NAME'] = getenv('OTP_EMAIL_HTML') + +if getenv('EMAIL_BACKEND_TEST'): + EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +else: + EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_USE_SSL = bool(int(getenv('EMAIL_USE_SSL', 0))) +EMAIL_USE_TLS = bool(int(getenv('EMAIL_USE_TLS', 0))) +EMAIL_HOST = getenv('EMAIL_HOST', 'smtp.mydomain.com') +EMAIL_HOST_USER = getenv('EMAIL_HOST_USER', 'xyb@mydomain.com') +EMAIL_HOST_PASSWORD = getenv('EMAIL_HOST_PASSWORD', 'password') +EMAIL_PORT = int(getenv('EMAIL_PORT', 465)) +EMAIL_FROM = getenv('EMAIL_FROM', 'xyb@mydomain.com') +EMAIL_TIMEOUT = int(getenv('EMAIL_TIMEOUT', 3)) +EMAIL_WHITE_LIST = getenv('EMAIL_WHITE_LIST', r'.*') +EMAIL_WHITE_LIST_MESSAGE = getenv('EMAIL_WHITE_LIST_MESSAGE', + 'email address not in white list') + +JWT_SECRET = getenv('JWT_SECRET', 'your secret key') +JWT_EXPIRE_SECONDS = int(getenv('JWT_EXPIRE_SECONDS', 60 * 60 * 24 * 30)) + + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -54,7 +102,7 @@ TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [BASE_DIR / 'templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -75,10 +123,21 @@ DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + 'ENGINE': getenv('DB_ENGINE', 'django.db.backends.sqlite3'), + 'NAME': getenv('DB_NAME', BASE_DIR / 'db.sqlite3'), + 'USER': getenv('DB_USER', 'postgres'), + 'PASSWORD': getenv('DB_PASSWORD', ''), + 'HOST': getenv('DB_HOST', ''), + 'PORT': getenv('DB_PORT', ''), } } +if 'mysql' in DATABASES['default']['ENGINE']: + DATABASES['default']['OPTIONS'] = { + # fix mysql error 1452 + "init_command": "SET foreign_key_checks = 0;", + # fix mysql emoji issue + 'charset': 'utf8mb4', + } # Password validation diff --git a/drf_passwordless_jwt/urls.py b/drf_passwordless_jwt/urls.py index c0dcebf..25c09af 100644 --- a/drf_passwordless_jwt/urls.py +++ b/drf_passwordless_jwt/urls.py @@ -14,8 +14,17 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import include, path +from drfpasswordless.settings import api_settings + +from .views import ObtainEmailWhiteListCallbackToken, ObtainJWTFromCallbackToken, VerifyJWT urlpatterns = [ + path(api_settings.PASSWORDLESS_AUTH_PREFIX, + VerifyJWT.as_view(), name='verify_jwt_token'), + path(api_settings.PASSWORDLESS_AUTH_PREFIX + 'jwt/', + ObtainJWTFromCallbackToken.as_view(), name='auth_jwt_token'), + path(api_settings.PASSWORDLESS_AUTH_PREFIX + 'email/', + ObtainEmailWhiteListCallbackToken.as_view(), name='auth_email_token'), path('admin/', admin.site.urls), ] diff --git a/drf_passwordless_jwt/utils.py b/drf_passwordless_jwt/utils.py new file mode 100644 index 0000000..0e0ba81 --- /dev/null +++ b/drf_passwordless_jwt/utils.py @@ -0,0 +1,19 @@ +from datetime import datetime, timedelta, timezone + +import jwt +from django.conf import settings + + +def generate_jwt(email): + payload = {"email": email} + exp = timedelta(seconds=settings.JWT_EXPIRE_SECONDS) + payload['exp'] = datetime.now(tz=timezone.utc) + exp + token = jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256") + return token + + +def decode_jwt(token): + payload = jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"]) + ts = payload['exp'] + payload['exp'] = datetime.fromtimestamp(ts, timezone.utc) + return payload diff --git a/drf_passwordless_jwt/views.py b/drf_passwordless_jwt/views.py new file mode 100644 index 0000000..895976f --- /dev/null +++ b/drf_passwordless_jwt/views.py @@ -0,0 +1,63 @@ +import jwt +from drfpasswordless.views import ObtainAuthTokenFromCallbackToken +from rest_framework import serializers, status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from .utils import decode_jwt, generate_jwt + +from drfpasswordless.views import ObtainEmailCallbackToken +from drfpasswordless.serializers import EmailAuthSerializer +from django.conf import settings +from django.core.validators import RegexValidator + + +class EmailAuthWhiteListSerializer(EmailAuthSerializer): + email_regex = RegexValidator( + regex=settings.EMAIL_WHITE_LIST, + message=settings.EMAIL_WHITE_LIST_MESSAGE, + ) + email = serializers.EmailField(validators=[email_regex]) + + +class ObtainEmailWhiteListCallbackToken(ObtainEmailCallbackToken): + serializer_class = EmailAuthWhiteListSerializer + + +class ObtainJWTFromCallbackToken(ObtainAuthTokenFromCallbackToken): + def post(self, request, *args, **kwargs): + email = request.data['email'] + resp = super(ObtainJWTFromCallbackToken, self).post(request, *args, + **kwargs) + token = generate_jwt(email) + resp.data['email'] = email + resp.data['token'] = token + return resp + + +class JWTSerializer(serializers.Serializer): + token = serializers.CharField() + + def validate_token(self, value): + try: + value = decode_jwt(value) + except jwt.ExpiredSignatureError: + raise serializers.ValidationError('token expired') + return value + + +class VerifyJWT(APIView): + permission_classes = [AllowAny] + serializer_class = JWTSerializer + + def post(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data, + context={'request': request}) + if serializer.is_valid(raise_exception=False): + return Response( + serializer.validated_data['token'], + status=status.HTTP_200_OK, + ) + + return Response(status=status.HTTP_401_UNAUTHORIZED) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c5876df --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Django +djangorestframework +drfpasswordless-gstr169 +PyJWT diff --git a/templates/passwordless_zh_token_email.html b/templates/passwordless_zh_token_email.html new file mode 100644 index 0000000..3c2440b --- /dev/null +++ b/templates/passwordless_zh_token_email.html @@ -0,0 +1,10 @@ + + + + + 你的登录验证码 + + +

你的登录验证码是 {{ callback_token }}。本条验证码有效期5分钟。

+ +