diff --git a/Dockerfile-app b/Dockerfile-app index 33013de..04bd77e 100644 --- a/Dockerfile-app +++ b/Dockerfile-app @@ -12,12 +12,14 @@ ADD ./celery_task/__init__.py \ ./celery_task/ ADD ./models/__init__.py \ + ./models/api_token.py \ ./models/base.py \ ./models/index.py \ ./models/subscriberdb.py \ ./models/ ADD ./module/__init__.py \ + ./module/api_token.py \ ./module/awsses.py \ ./module/ga.py \ ./module/sender.py \ @@ -37,6 +39,7 @@ ADD ./templates/admin_subscriber_add.html \ ./templates/base_subscribe.html \ ./templates/base.html \ ./templates/index.html \ + ./templates/settings_token.html \ ./templates/subscribe_coscup.html \ ./templates/subscriber_error.html \ ./templates/subscriber_intro.html \ @@ -49,5 +52,7 @@ ADD ./view/__init__.py \ ./view/reader.py \ ./view/subscribe.py \ ./view/subscriber.py \ + ./view/token.py \ ./view/trello.py \ + ./view/volunteer.py \ ./view/ diff --git a/main.py b/main.py index d00fb2b..5e07885 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,7 @@ import google_auth_oauthlib.flow from apiclient import discovery from flask import (Flask, g, got_request_exception, redirect, render_template, - request, session, url_for) + request, session, url_for, make_response) from flask.wrappers import Response from werkzeug.wrappers import Response as ResponseBase @@ -21,6 +21,9 @@ from view.subscribe import VIEW_SUBSCRIBE from view.subscriber import VIEW_SUBSCRIBER from view.trello import VIEW_TRELLO +from view.volunteer import VIEW_VOLUNTEER +from view.token import VIEW_TOKEN +from module.api_token import APIToken logging.basicConfig( filename='./log/log.log', @@ -37,7 +40,9 @@ app.register_blueprint(VIEW_READER) app.register_blueprint(VIEW_SUBSCRIBE) app.register_blueprint(VIEW_SUBSCRIBER) +app.register_blueprint(VIEW_TOKEN) app.register_blueprint(VIEW_TRELLO) +app.register_blueprint(VIEW_VOLUNTEER) if app.debug: app.config['TEMPLATES_AUTO_RELOAD'] = True @@ -60,6 +65,16 @@ def need_login() -> ResponseBase | None: request.headers.get('USER-AGENT'), session, ) + if request.path.startswith('/volunteer'): + token = request.headers.get('Authorization') + if token is None: + return make_response('Unauthorized', 401) + + if APIToken.verify(token) is True: + return None + + return make_response('Unauthorized', 401) + if request.path not in NO_NEED_LOGIN_PATH \ and not request.path.startswith('/subscriber') \ and not request.path.startswith('/subscribe') \ diff --git a/models/api_token.py b/models/api_token.py new file mode 100644 index 0000000..14e0bf9 --- /dev/null +++ b/models/api_token.py @@ -0,0 +1,19 @@ +''' APIToken DB ''' +from models.base import DBBase + +class APITokenDB(DBBase): + ''' Token Collection + + Schema: + { + serial_no: string, + token: string, + label: string + } + ''' + def __init__(self) -> None: + super().__init__('api_token') + + def index(self) -> None: + ''' index ''' + self.create_index([('serial_no', 1)]) diff --git a/models/index.py b/models/index.py index 2c58b25..23a239f 100644 --- a/models/index.py +++ b/models/index.py @@ -1,8 +1,10 @@ ''' index ''' +from models.api_token import APITokenDB from models.subscriberdb import (SubscriberDB, SubscriberLoginTokenDB, SubscriberReadDB) if __name__ == '__main__': + APITokenDB().index() SubscriberDB().index() SubscriberLoginTokenDB().index() SubscriberReadDB().index() diff --git a/module/api_token.py b/module/api_token.py new file mode 100644 index 0000000..ab49e44 --- /dev/null +++ b/module/api_token.py @@ -0,0 +1,62 @@ +''' API Token Module ''' +from typing import Any +from uuid import uuid4 +from dataclasses import dataclass, asdict, field + +from passlib.context import CryptContext + +from models.api_token import APITokenDB + +@dataclass +class APITokenSchema: + ''' Schema of api_token collection ''' + token: str = field(default_factory=lambda: uuid4().hex) + serial_no: str = field(default_factory=lambda: f'{uuid4().node:08x}') + label: str = '' + +class APIToken: + ''' Class for managing API tokens ''' + @staticmethod + def create(label: str) -> APITokenSchema: + ''' Create token ''' + new_token = APITokenSchema(label=label) + + hash_context = CryptContext(schemes=['bcrypt'], deprecated='auto') + hashed_token = asdict(new_token) + hashed_token['token'] = hash_context.hash(hashed_token['token']) + + APITokenDB().insert_one(hashed_token) + + new_token.token = f'{new_token.serial_no}|{new_token.token}' + + return new_token + + @staticmethod + def get_list() -> list[dict[str, Any]]: + ''' Get list of token ''' + return list(APITokenDB().find({}, { 'label': 1, 'serial_no': 1, '_id': 0 })) + + @staticmethod + def delete(tokens: list[str]) -> None: + ''' Delete the given token serial_no ''' + APITokenDB().delete_many({ + 'serial_no': { '$in': tokens } + }) + + @staticmethod + def verify(token: str) -> bool: + ''' Check if the token exists and valid ''' + schema, token = token.split(' ') + if schema.lower() != 'bearer': + return False + + serial_no, token = token.split('|') + hash_context = CryptContext(schemes=['bcrypt'], deprecated='auto') + hashed_token = APITokenDB().find_one({ + 'serial_no': serial_no + }) + + if hashed_token is None: + return False + + return hash_context.verify(token, hashed_token['token']) diff --git a/poetry.lock b/poetry.lock index 029482a..5382fc9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "amqp" @@ -816,6 +816,23 @@ rsa = ["cryptography (>=3.0.0)"] signals = ["blinker (>=1.4.0)"] signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] +[[package]] +name = "passlib" +version = "1.7.4" +description = "comprehensive password hashing framework supporting over 30 schemes" +optional = false +python-versions = "*" +files = [ + {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"}, + {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"}, +] + +[package.extras] +argon2 = ["argon2-cffi (>=18.2.0)"] +bcrypt = ["bcrypt (>=3.1.0)"] +build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"] +totp = ["cryptography"] + [[package]] name = "phonenumbers" version = "8.13.30" @@ -1194,6 +1211,17 @@ files = [ {file = "tomlkit-0.12.3.tar.gz", hash = "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4"}, ] +[[package]] +name = "types-passlib" +version = "1.7.7.20240327" +description = "Typing stubs for passlib" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-passlib-1.7.7.20240327.tar.gz", hash = "sha256:4cce6a1a3a6afee9fc4728b4d9784300764ac2be747f5bcc01646d904b85f4bb"}, + {file = "types_passlib-1.7.7.20240327-py3-none-any.whl", hash = "sha256:3a3b7f4258b71034d2e2f4f307d6810f9904f906cdf375514c8bdbdb28a4ad23"}, +] + [[package]] name = "types-python-dateutil" version = "2.8.19.20240106" @@ -1321,4 +1349,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "2b9b81f0b5f3ab613fa87a8a14c0bce8df54cb00f378aabe619253d58930dd57" +content-hash = "fe1b79770234978826b61e5c41a01f3f989f78c93e500fda23e09749bdf546b8" diff --git a/pyproject.toml b/pyproject.toml index 49c7221..bf438a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,8 @@ pymongo = "^4.3.3" requests = "^2.31.0" uWSGI = "^2.0.21" certifi = "*" +passlib = "^1.7.4" +types-passlib = "^1.7.7.20240327" [tool.poetry.group.dev.dependencies] diff --git a/templates/base.html b/templates/base.html index b9ac90b..2b13d7a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -38,6 +38,9 @@ {{session.u.name}}
+| 勾選 | +標籤 | +
|---|---|
| + | [[ token.label ]] | +
新增 Token
+ +新增 Token 成功
+此 Token 只會顯示一次,關掉此畫面後便無法複製此 Token
+移除 Token
+ +是否確認要刪除以下 Token
+