Skip to content
This repository was archived by the owner on Sep 30, 2023. It is now read-only.

Commit 09f870c

Browse files
committed
add donation endpoints
* add donation endpoints * donator signs in via Line and organization signs in via Email * refactor endpoint paths
1 parent ccb1521 commit 09f870c

19 files changed

+900
-118
lines changed

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@ dev-env:
99
local-db:
1010
if [ ! -d pg-data ]; then mkdir pg-data; fi
1111
docker run -d --name shared-tw_db --rm -e POSTGRES_DB=sharedtw -e POSTGRES_PASSWORD=password -v $$PWD/pg-data:/var/lib/postgresql/data:Z,shared -p 127.0.0.1:5432:5432 postgres:13
12+
13+
tests:
14+
python manage.py test

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ This repository contains backend codes.
55
## 開發環境
66
* Python 3.7+
77
* Django + django-jinja
8+
* Docker
89

910
```bash
1011
make dev-dev
@@ -17,3 +18,9 @@ make local-db
1718
python manage.py migrate
1819
python manage.py runserver
1920
```
21+
22+
## HTTP 狀態碼
23+
* `400`: POST 資料有錯誤
24+
* `401`: 未登入
25+
* `403`: 未驗證 EMail
26+
* `422`: 無法更新項目狀態

api/api.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from ninja import NinjaAPI
22
from ninja.operation import Operation
33

4-
from authenticator.api import JWTAuthBearer, JWTAuthUserBearer
4+
from authenticator.api import JWTAuthBearer
55
from authenticator.api import router as authenticator_router
66
from oauth2.api import router as oauth_router
7-
from share.api import organization_router, public_router, register_rotuer
7+
from share.api import router as share_router
88

99

1010
class SharedTWApi(NinjaAPI):
@@ -15,8 +15,6 @@ def get_openapi_operation_id(self, operation: Operation) -> str:
1515

1616
api = SharedTWApi(title="shared-tw API", version="0.1.0")
1717

18-
api.add_router("", public_router)
1918
api.add_router("auth", authenticator_router)
2019
api.add_router("oauth", oauth_router)
21-
api.add_router("register", register_rotuer, auth=JWTAuthBearer())
22-
api.add_router("organization", organization_router, auth=JWTAuthUserBearer())
20+
api.add_router("", share_router, auth=JWTAuthBearer())

api/settings.py

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@
99
For the full list of settings and their values, see
1010
https://docs.djangoproject.com/en/3.2/ref/settings/
1111
"""
12+
import logging.config
1213
import os
1314
from pathlib import Path
1415

16+
from django.utils.log import DEFAULT_LOGGING
17+
1518
# Build paths inside the project like this: BASE_DIR / 'subdir'.
1619
BASE_DIR = Path(__file__).resolve().parent.parent
1720

@@ -39,7 +42,9 @@
3942
"django.contrib.sessions",
4043
"django.contrib.messages",
4144
"django.contrib.staticfiles",
45+
# 3rd party
4246
"corsheaders",
47+
# shared tw
4348
"share",
4449
"oauth2",
4550
]
@@ -53,14 +58,15 @@
5358
"django.contrib.auth.middleware.AuthenticationMiddleware",
5459
"django.contrib.messages.middleware.MessageMiddleware",
5560
"django.middleware.clickjacking.XFrameOptionsMiddleware",
61+
"authenticator.middleware.Status403Middleware",
5662
]
5763

5864
ROOT_URLCONF = "api.urls"
5965

6066
TEMPLATES = [
6167
{
6268
"BACKEND": "django.template.backends.django.DjangoTemplates",
63-
"DIRS": [],
69+
"DIRS": [os.path.join(BASE_DIR, "templates")],
6470
"APP_DIRS": True,
6571
"OPTIONS": {
6672
"context_processors": [
@@ -134,6 +140,52 @@
134140

135141
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
136142

143+
# Disable Django's logging setup
144+
LOGGING_CONFIG = None
145+
146+
LOGLEVEL = os.environ.get("LOGLEVEL", "info").upper()
147+
148+
logging.config.dictConfig(
149+
{
150+
"version": 1,
151+
"disable_existing_loggers": False,
152+
"formatters": {
153+
"default": {
154+
"format": "%(asctime)s %(name)-12s %(levelname)-8s %(message)s",
155+
},
156+
"django.server": DEFAULT_LOGGING["formatters"]["django.server"],
157+
},
158+
"handlers": {
159+
"console": {
160+
"class": "logging.StreamHandler",
161+
"formatter": "default",
162+
},
163+
"django.server": DEFAULT_LOGGING["handlers"]["django.server"],
164+
},
165+
"loggers": {
166+
"": {
167+
"level": "WARNING",
168+
"handlers": ["console"],
169+
},
170+
"share": {
171+
"level": LOGLEVEL,
172+
"handlers": ["console"],
173+
"propagate": False,
174+
},
175+
# Default runserver request logging
176+
"django.server": DEFAULT_LOGGING["loggers"]["django.server"],
177+
},
178+
}
179+
)
180+
181+
# Email settings
182+
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
183+
EMAIL_HOST = "smtp.gmail.com"
184+
EMAIL_HOST_USER = os.environ.get("EMAIL_USER", "__not_set__")
185+
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_PASSWORD", "__not_set__")
186+
EMAIL_PORT = 587
187+
EMAIL_USE_TLS = True
188+
137189

138190
# django-cors-headers settings
139191
# FIXME: use the domain!
@@ -142,10 +194,20 @@
142194

143195
# shared-tw setting
144196
SHARED_TW_SETTINGS = {
145-
"LINE_CLIENT_ID": os.environ.get("LINE_CLIENT_ID"),
146-
"LINE_CLIENT_SECRET": os.environ.get("LINE_CLIENT_SECRET"),
147-
"DOMAIN": os.getenv("DOMAIN", "shared-tw.icu"),
197+
"line_client_id": os.environ.get("LINE_CLIENT_ID"),
198+
"line_client_secret": os.environ.get("LINE_CLIENT_SECRET"),
199+
# "domain": os.getenv("DOMAIN", "shared-tw.icu"),
148200
}
149201

202+
203+
AUTHENTICATOR = {
204+
# Do not use SECRET_KEY to prevent the key gets leak
205+
"hash_id_secret": os.getenv("HASH_ID_SECRET", "__not_set__"),
206+
"min_length": int(os.getenv("MIN_LENGTH", "7")),
207+
"verification_email_url": "/auth/verify-email?uid={uid}&token={token}",
208+
}
209+
210+
211+
# oauth2 settings
150212
# This allows us to use a plain HTTP callback
151213
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"

api/settings_prod.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
from .settings import * # noqa: F403
66

77
DEBUG = False
8+
SECRET_KEY = os.environ["SECRET_KEY"]
89
ALLOWED_HOSTS = ["shared-tw.herokuapp.com", "api.shared-tw.icu"]
910
DATABASES["default"] = dj_database_url.config( # noqa: F405
1011
conn_max_age=600, ssl_require=True
1112
)
13+
AUTHENTICATOR["hash_id_secret"] = os.environ["HASH_ID_SECRET"] # noqa: F405
14+
1215
if "OAUTHLIB_INSECURE_TRANSPORT" in os.environ:
1316
del os.environ["OAUTHLIB_INSECURE_TRANSPORT"]

authenticator/api.py

Lines changed: 94 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@
66

77
from django.conf import settings
88
from django.contrib.auth import get_user_model
9-
from django.http import HttpRequest, HttpResponse
10-
from django.http.response import JsonResponse
9+
from django.contrib.auth.tokens import default_token_generator
10+
from django.contrib.sites.shortcuts import get_current_site
11+
from django.http import HttpRequest
12+
from django.http.response import HttpResponseBase, JsonResponse
1113
from django.utils import timezone
1214
from fastapi_jwt_auth import AuthJWT
1315
from fastapi_jwt_auth.exceptions import JWTDecodeError
1416
from ninja import Router, Schema, errors
1517
from ninja.security import HttpBearer
1618
from ninja.security.apikey import APIKeyBase
1719

18-
from . import schemas
20+
from . import schemas, utils
1921

2022
logger = logging.getLogger(__name__)
2123

@@ -36,94 +38,129 @@ def get_config():
3638
class RefreshTokenCookieAuth(APIKeyBase, ABC):
3739
"""Check is refresh token exists in the Cookies"""
3840

39-
# We don't inherit APIKeyCookie
41+
# We don't inherit APIKeyCookie to skip CSRF check for now
4042
openapi_in: str = "cookie"
4143
param_name = "refresh-token"
4244

4345
def _get_key(self, request: HttpRequest) -> Optional[str]:
4446
return request.COOKIES.get(self.param_name)
4547

4648
def authenticate(self, request: HttpRequest, key: Optional[str]) -> Optional[Any]:
47-
user, decoded_token = JWTLogin().authenticate(key)
48-
if user:
49-
request.user = user
50-
else:
49+
user, decoded_token = Authenticator().authenticate_by_token(key)
50+
if not user:
5151
return None
52+
53+
request.user = user
5254
return decoded_token
5355

5456

5557
class JWTAuthBearer(HttpBearer):
5658
"""Verify JWT token"""
5759

58-
def authenticate(self, request, token):
59-
user, decoded_token = JWTLogin().authenticate(token, is_active=None)
60-
if user:
61-
request.user = user
62-
return decoded_token
63-
64-
65-
class JWTAuthUserBearer(HttpBearer):
66-
"""Verify JWT token and make sure the user is active"""
60+
def __init__(self, inactive_user_raise_403: bool = True) -> None:
61+
super().__init__()
62+
self.inactive_user_raise_403 = inactive_user_raise_403
6763

6864
def authenticate(self, request, token):
69-
user, decoded_token = JWTLogin().authenticate(token)
70-
if user:
71-
request.user = user
65+
user, decoded_token = Authenticator().authenticate_by_token(token)
66+
if user and not user.is_active and self.inactive_user_raise_403:
67+
raise errors.HttpError(403, "Please verify your email.")
68+
elif not user:
69+
return None
70+
71+
request.user = user
7272
return decoded_token
7373

7474

7575
@router.post("/token", response=schemas.JWTToken)
7676
def create_jwt_token(request, payload: schemas.JWTTokenCreation):
77-
try:
78-
user = User.objects.get(username=payload.username, is_active=True)
79-
if not user.check_password(payload.password):
80-
raise User.DoesNotExist()
77+
authenticator = Authenticator()
8178

82-
access_token, refresh_token = JWTLogin().login(user)
83-
resp = JsonResponse(schemas.JWTToken(access=access_token).dict())
84-
return JWTLogin.set_refresh_cookie(resp, refresh_token)
79+
try:
80+
user = authenticator.authenticate(payload.username, payload.password)
81+
if not user.is_active:
82+
raise errors.HttpError(403, "Please verify your email.")
83+
access_token, refresh_token = authenticator.login(user)
84+
kwargs = schemas.JWTToken(access=access_token).dict()
85+
return authenticator.generate_http_response(
86+
request, JsonResponse, refresh_token, resp_kwargs=kwargs
87+
)
8588
except User.DoesNotExist:
8689
raise errors.HttpError(400, "Invalid username or password")
8790

8891

8992
@router.post("/token/refresh", auth=RefreshTokenCookieAuth(), response=schemas.JWTToken)
9093
def refresh_jwt_token(request):
91-
access_token, refresh_token = JWTLogin().login(request.user)
94+
authenticator = Authenticator()
95+
access_token, refresh_token = authenticator.login(request.user)
9296

9397
# workaround to set Cookie in the response, see: https://github.com/vitalik/django-ninja/issues/117
94-
resp = JsonResponse(schemas.JWTToken(access=access_token).dict())
95-
return JWTLogin.set_refresh_cookie(resp, refresh_token)
98+
return authenticator.generate_http_response(
99+
request,
100+
JsonResponse,
101+
refresh_token,
102+
resp_kwargs=schemas.JWTToken(access=access_token).dict(),
103+
)
104+
105+
106+
@router.get("/verify-email", response=schemas.JWTToken)
107+
def verify_email(request, uid: str, token: str):
108+
authenticator = Authenticator()
109+
try:
110+
access_token, refresh_token = authenticator.verify_email(uid, token)
111+
kwargs = schemas.JWTToken(access=access_token).dict()
112+
return authenticator.generate_http_response(
113+
request, JsonResponse, refresh_token, resp_kwargs=kwargs
114+
)
115+
except Exception as e:
116+
logger.warning("Invalid user or token: %s=%s, err: %s", uid, token, e)
117+
raise errors.HttpError(401, "Invalid user or token")
96118

97119

98-
class JWTLogin:
120+
class Authenticator:
99121
def __init__(self):
100122
self.auth = AuthJWT()
101123

102124
def login(
103125
self,
104126
user,
105-
user_claims: typing.Optional[typing.Dict] = None,
106127
) -> typing.Tuple[str, str]:
107128
user.last_login = timezone.now()
108129
user.save(update_fields=["last_login"])
109130

110-
if user_claims is None:
111-
user_claims = {}
112-
131+
user_id = utils.encode_id(user.id)
113132
return self.auth.create_access_token(
114-
subject=user.username, user_claims=user_claims
115-
), self.auth.create_refresh_token(subject=user.username)
133+
subject=user_id
134+
), self.auth.create_refresh_token(subject=user_id)
135+
136+
def verify_email(self, encoded_id: str, token: str) -> typing.Tuple[str, str]:
137+
user = User.objects.get(id=utils.decode_id(encoded_id))
138+
if not default_token_generator.check_token(user, token):
139+
raise ValueError("Invalid or expired token")
140+
user.is_active = True
141+
user.save()
142+
return self.login(user)
143+
144+
def authenticate(self, username: str, password: str) -> User:
145+
user = User.objects.get(username=username)
146+
if not user.check_password(password):
147+
raise User.DoesNotExist()
148+
return user
116149

117-
def authenticate(self, token, is_active: typing.Optional[bool] = None):
150+
def authenticate_by_token(
151+
self, token: str, is_active: typing.Optional[bool] = None
152+
) -> typing.Tuple[typing.Optional[User], typing.Optional[str]]:
118153
user = None
119154
decoded_token = None
155+
120156
kwargs = {}
121-
if is_active is not None:
157+
if isinstance(is_active, bool):
122158
kwargs["is_active"] = is_active
123159

124160
try:
125161
decoded_token = self.auth.get_raw_jwt(token)
126-
user = User.objects.get(username=decoded_token["sub"], **kwargs)
162+
user_id = utils.decode_id(decoded_token["sub"])
163+
user = User.objects.get(id=user_id, **kwargs)
127164
except User.DoesNotExist:
128165
logger.warning("User doesn't exist: %s", decoded_token)
129166
except JWTDecodeError as e:
@@ -133,13 +170,27 @@ def authenticate(self, token, is_active: typing.Optional[bool] = None):
133170

134171
return user, decoded_token
135172

136-
@classmethod
137-
def set_refresh_cookie(cls, resp: HttpResponse, refresh_token: str) -> HttpResponse:
173+
@staticmethod
174+
def generate_http_response(
175+
request,
176+
resp_cls: typing.Type[HttpResponseBase],
177+
refresh_token: str,
178+
resp_kwargs: typing.Dict = None,
179+
unpack_resp_kwargs: bool = False,
180+
) -> HttpResponseBase:
181+
site = get_current_site(request)
182+
if resp_kwargs is None:
183+
resp_kwargs = {}
184+
185+
if unpack_resp_kwargs:
186+
resp = resp_cls(**resp_kwargs)
187+
else:
188+
resp = resp_cls(resp_kwargs)
138189
resp.set_cookie(
139190
RefreshTokenCookieAuth.param_name,
140191
refresh_token,
141192
httponly=True,
142-
domain=settings.SHARED_TW_SETTINGS["DOMAIN"],
193+
domain=site.domain.split(":", 1)[0], # remove port
143194
samesite="Strict",
144195
max_age=timedelta(days=7).total_seconds(),
145196
)

0 commit comments

Comments
 (0)