diff --git a/Pipfile.lock b/Pipfile.lock index 5b8697a..784adbf 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -199,27 +199,31 @@ }, "cryptography": { "hashes": [ - "sha256:059e348f9a3c1950937e1b5d7ba1f8e968508ab181e75fc32b879452f08356db", - "sha256:1a5472d40c8f8e91ff7a3d8ac6dfa363d8e3138b961529c996f3e2df0c7a411a", - "sha256:1a8e6c2de6fbbcc5e14fd27fb24414507cb3333198ea9ab1258d916f00bc3039", - "sha256:1fee5aacc7367487b4e22484d3c7e547992ed726d14864ee33c0176ae43b0d7c", - "sha256:5d092fdfedaec4cbbffbf98cddc915ba145313a6fdaab83c6e67f4e6c218e6f3", - "sha256:5f0ff6e18d13a3de56f609dd1fd11470918f770c6bd5d00d632076c727d35485", - "sha256:7bfc55a5eae8b86a287747053140ba221afc65eb06207bedf6e019b8934b477c", - "sha256:7fa01527046ca5facdf973eef2535a27fec4cb651e4daec4d043ef63f6ecd4ca", - "sha256:8dde71c4169ec5ccc1087bb7521d54251c016f126f922ab2dfe6649170a3b8c5", - "sha256:8f4ab7021127a9b4323537300a2acfb450124b2def3756f64dc3a3d2160ee4b5", - "sha256:948224d76c4b6457349d47c0c98657557f429b4e93057cf5a2f71d603e2fc3a3", - "sha256:9a6c7a3c87d595608a39980ebaa04d5a37f94024c9f24eb7d10262b92f739ddb", - "sha256:b46e37db3cc267b4dea1f56da7346c9727e1209aa98487179ee8ebed09d21e43", - "sha256:b4ceb5324b998ce2003bc17d519080b4ec8d5b7b70794cbd2836101406a9be31", - "sha256:cb33ccf15e89f7ed89b235cff9d49e2e62c6c981a6061c9c8bb47ed7951190bc", - "sha256:d198820aba55660b4d74f7b5fd1f17db3aa5eb3e6893b0a41b75e84e4f9e0e4b", - "sha256:d34579085401d3f49762d2f7d6634d6b6c2ae1242202e860f4d26b046e3a1006", - "sha256:eb8163f5e549a22888c18b0d53d6bb62a20510060a22fd5a995ec8a05268df8a", - "sha256:f73bff05db2a3e5974a6fd248af2566134d8981fd7ab012e5dd4ddb1d9a70699" + "sha256:01f1d9e537f9a15b037d5d9ee442b8c22e3ae11ce65ea1f3316a41c78756b711", + "sha256:079347de771f9282fbfe0e0236c716686950c19dee1b76240ab09ce1624d76d7", + "sha256:182be4171f9332b6741ee818ec27daff9fb00349f706629f5cbf417bd50e66fd", + "sha256:192255f539d7a89f2102d07d7375b1e0a81f7478925b3bc2e0549ebf739dae0e", + "sha256:2a034bf7d9ca894720f2ec1d8b7b5832d7e363571828037f9e0c4f18c1b58a58", + "sha256:342f3767e25876751e14f8459ad85e77e660537ca0a066e10e75df9c9e9099f0", + "sha256:439c3cc4c0d42fa999b83ded80a9a1fb54d53c58d6e59234cfe97f241e6c781d", + "sha256:49c3222bb8f8e800aead2e376cbef687bc9e3cb9b58b29a261210456a7783d83", + "sha256:674b669d5daa64206c38e507808aae49904c988fa0a71c935e7006a3e1e83831", + "sha256:7a9a3bced53b7f09da251685224d6a260c3cb291768f54954e28f03ef14e3766", + "sha256:7af244b012711a26196450d34f483357e42aeddb04128885d95a69bd8b14b69b", + "sha256:7d230bf856164de164ecb615ccc14c7fc6de6906ddd5b491f3af90d3514c925c", + "sha256:84609ade00a6ec59a89729e87a503c6e36af98ddcd566d5f3be52e29ba993182", + "sha256:9a6673c1828db6270b76b22cc696f40cde9043eb90373da5c2f8f2158957f42f", + "sha256:9b6d717393dbae53d4e52684ef4f022444fc1cce3c48c38cb74fca29e1f08eaa", + "sha256:9c3fe6534d59d071ee82081ca3d71eed3210f76ebd0361798c74abc2bcf347d4", + "sha256:a719399b99377b218dac6cf547b6ec54e6ef20207b6165126a280b0ce97e0d2a", + "sha256:b332cba64d99a70c1e0836902720887fb4529ea49ea7f5462cf6640e095e11d2", + "sha256:d124682c7a23c9764e54ca9ab5b308b14b18eba02722b8659fb238546de83a76", + "sha256:d73f419a56d74fef257955f51b18d046f3506270a5fd2ac5febbfa259d6c0fa5", + "sha256:f0dc40e6f7aa37af01aba07277d3d64d5a03dc66d682097541ec4da03cc140ee", + "sha256:f14ad275364c8b4e525d018f6716537ae7b6d369c094805cae45300847e0894f", + "sha256:f772610fe364372de33d76edcd313636a25684edb94cee53fd790195f5989d14" ], - "version": "==41.0.1" + "version": "==41.0.2" }, "dnspython": { "hashes": [ diff --git a/migrations/versions/3a1f57242769_create_user_account_user_profile_and_.py b/migrations/versions/3a1f57242769_create_user_account_user_profile_and_.py index 2e6cc1a..5ef4032 100755 --- a/migrations/versions/3a1f57242769_create_user_account_user_profile_and_.py +++ b/migrations/versions/3a1f57242769_create_user_account_user_profile_and_.py @@ -46,7 +46,7 @@ def upgrade() -> None: sa.Column('location', sa.String(), nullable=True), sa.Column('status', sa.String(), nullable=True), sa.Column('sign', sa.String(), nullable=True), - sa.Column('avatar', sa.String(), nullable=True), + sa.Column('avatars', sa.String(), nullable=True), sa.Column('user_id', sa.String(length=26), nullable=False), sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], ondelete='CASCADE'), sa.PrimaryKeyConstraint('id') diff --git a/src/crud/user_crud.py b/src/crud/user_crud.py index 04e2192..0a36460 100755 --- a/src/crud/user_crud.py +++ b/src/crud/user_crud.py @@ -10,16 +10,20 @@ from ..database.db import async_session from ..database.models import UserAccount, UserProfile, Contact -async def select_user_by(condition: Literal['email', 'username'], value: str) -> Tuple[bool, UserAccount]: - async with async_session() as session: - if condition == 'email': +async def select_user_by(condition: Literal['email', 'username', 'id'], value: str) -> Tuple[bool, UserAccount]: + session = async_session() + res: ScalarResult[UserAccount] = ScalarResult[UserAccount] + match condition: + case 'email': res = await session.scalars(select(UserAccount).where(UserAccount.email == value)) - else: + case 'username': res = await session.scalars(select(UserAccount).where(UserAccount.username == value)) + case 'id': + res = await session.scalars(select(UserAccount).where(UserAccount.id == value)) - user = res.first() + user = res.first() - return (True, user) if user else (False, None) + return (True, user) if user else (False, None) async def insert_user(username: str, password: str, email: str): @@ -27,8 +31,10 @@ async def insert_user(username: str, password: str, email: str): id = ulid.new().str user = UserAccount(id=id, username=username, password=password, email=email) profile = UserProfile(nickname=username) - contact = Contact(friends={id: {'friendRemark': None, 'friendGroup': '我的好友'}}, friend_groups=['我的好友'], - group_chats={}) + contact = Contact( + friends={id: {'friendRemark': None, 'friendGroup': '我的好友'}}, friend_groups=['我的好友'], + group_chats={} + ) user.profile = profile user.contact = contact session.add(user) @@ -75,3 +81,28 @@ async def update_profile_avatar(id: str, avatar_name: str): ) await session.commit() await session.close() + + +async def update_account(account: Literal['username', 'email', 'password'], id: str, value: str): + session = async_session() + match account: + case 'username': + await session.execute( + update(UserAccount) + .where(UserAccount.id == id) + .values(username=value) + ) + case 'email': + await session.execute( + update(UserAccount) + .where(UserAccount.id == id) + .values(email=value) + ) + case 'password': + await session.execute( + update(UserAccount) + .where(UserAccount.id == id) + .values(password=value) + ) + await session.commit() + await session.close() diff --git a/src/dependencies.py b/src/dependencies.py index d336a12..18e9ad1 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -1,7 +1,16 @@ from fastapi import Depends +from fastapi.responses import Response, JSONResponse +from jose import ExpiredSignatureError, JWTError from .utils import token_handler as th -def verify_token(token: str = Depends(th.oauth2_scheme)): - th.verify_signin_token(token) \ No newline at end of file +def verify_token(response: Response, token: str = Depends(th.oauth2_scheme)): + try: + token, _ = th.verify_signin_token(token) + if token: + response.headers['Authorization'] = token + except ExpiredSignatureError: + return JSONResponse({'code': 9999, 'msg': 'Token Expire'}) + except JWTError: + return JSONResponse({'code': 9998, 'msg': 'Token Is Not Right'}) diff --git a/src/main.py b/src/main.py index 474ac38..d5c8fdb 100755 --- a/src/main.py +++ b/src/main.py @@ -1,15 +1,18 @@ from fastapi import FastAPI, Depends +from .dependencies import verify_token from .utils.email_code import smtp from .routers.signin import router as signin_router from .routers.signup import router as signup_router from .routers.user_profile import router as user_profile_router +from .routers.user_account import router as user_account_router app = FastAPI() app.include_router(signup_router) app.include_router(signin_router) -app.include_router(user_profile_router) +app.include_router(user_profile_router, dependencies=[Depends(verify_token)]) +app.include_router(user_account_router, dependencies=[Depends(verify_token)]) @app.on_event('shutdown') diff --git a/src/response_models/user_response.py b/src/response_models/user_response.py index 12994bf..dc4bd33 100755 --- a/src/response_models/user_response.py +++ b/src/response_models/user_response.py @@ -26,11 +26,17 @@ class UserAccountResponse(BaseResponseModel): class TokenCreationResponse(BaseResponseModel): - data: str + token: str + + +class TokenSigninResponse(BaseResponseModel): + data: Optional[_UserAccount] = None + token: Optional[str] = None + class UserProfileResponse(BaseResponseModel): data: _UserProfile class UserAvatarResponse(BaseResponseModel): - data: str \ No newline at end of file + data: str diff --git a/src/routers/signin.py b/src/routers/signin.py index 73a67df..fa8f214 100755 --- a/src/routers/signin.py +++ b/src/routers/signin.py @@ -3,17 +3,19 @@ from fastapi import Depends from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel +from jose import ExpiredSignatureError, JWTError + from ..crud.user_crud import select_user_by from ..utils.password import verify_password -from ..utils.token_handler import create_signin_token, oauth2_scheme -from ..response_models.user_response import UserAccountResponse, TokenCreationResponse +from ..utils.token_handler import create_signin_token, oauth2_scheme, verify_signin_token +from ..response_models.user_response import UserAccountResponse, TokenCreationResponse, TokenSigninResponse router = APIRouter(prefix='/signin', tags=['signin']) class TokenPayload(BaseModel): id: str - phone_mac: str + device_id: str @router.post('/username', response_model=UserAccountResponse) @@ -36,9 +38,19 @@ async def signin_by_username(form_data: OAuth2PasswordRequestForm = Depends()): @router.post('/token', response_model=TokenCreationResponse) async def create_token(token_payload: TokenPayload): token = create_signin_token(**token_payload.model_dump()) - return {'code': 10200, 'msg': 'Create Token Successfully', 'data': token} + return {'code': 10200, 'msg': 'Create Token Successfully', 'token': token} -@router.get('/token') +@router.get('/token', response_model=TokenSigninResponse) async def signin_by_token(token: str = Depends(oauth2_scheme)): - pass \ No newline at end of file + try: + new_token, id = verify_signin_token(token) + _, user = await select_user_by('id', id) + if new_token: + return {'code': 10200, 'msg': 'Sign in Successfully', 'data': user.to_dict(), 'token': new_token} + else: + return {'code': 10200, 'msg': 'Sign in Successfully', 'data': user.to_dict(), 'token': token} + except ExpiredSignatureError: + return {'code': 9999, 'msg': 'Token has Expired', 'data': None, 'token': None} + except JWTError: + return {'code': 9998, 'msg': 'Token Is Not Right', 'data': None, 'token': None} \ No newline at end of file diff --git a/src/routers/user_account.py b/src/routers/user_account.py new file mode 100644 index 0000000..ac10c66 --- /dev/null +++ b/src/routers/user_account.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter, BackgroundTasks +from pydantic import BaseModel + +from ..crud import user_crud +from ..utils import password +from ..utils.email_code import send_email, has_code, verify_code + +router = APIRouter(prefix='/user_account', tags=['user_account']) + + +class ChangedAccount(BaseModel): + id: str + username: str | None = None + email: str | None = None + password: str | None = None + code: str | None = None + + +@router.post('/change/username') +async def change_username(changed_account: ChangedAccount): + is_existed, user = await user_crud.select_user_by('username', changed_account.username) + if is_existed: + return {'code': 10401, 'msg': f'This Username ({changed_account.username}) Has Been Used'} + + await user_crud.update_account('username', changed_account.id, changed_account.username) + + return {'code': 10400, 'msg': 'Update Username Successfully'} + + +@router.get('/get/email_code') +async def get_change_email_code(email: str, background_tasks: BackgroundTasks): + is_existed, _ = await user_crud.select_user_by('email', email) + if is_existed: + return {'code': 10401, 'msg': f'This Email ({email}) Has Been Used'} + + if has_code(email): + return {'code': 10402, 'msg': f'Code of Email ({email}) Is Still Available'} + + background_tasks.add_task(send_email, email) + return {'code': 10400, 'msg': 'Send Verification Code Successfully'} + + +@router.post('/change/email') +async def change_email(changed_account: ChangedAccount): + is_correct = verify_code(changed_account.email, changed_account.code) + if not is_correct: + return {'code': 10403, 'msg': f'Email Code ({changed_account.code}) Is Not Correct'} + + await user_crud.update_account('email', changed_account.id, changed_account.email) + + return {'code': 10400, 'msg': 'Update Email Successfully'} + + +@router.get('/get/password_code') +async def get_change_password_code(email: str, background_tasks: BackgroundTasks): + if has_code(email): + return {'code': 10402, 'msg': f'Code of Email ({email}) Is Still Available'} + + background_tasks.add_task(send_email, email) + return {'code': 10400, 'msg': 'Send Verification Code Successfully'} + + +@router.post('/change/password') +async def change_password(changed_account: ChangedAccount): + is_correct = verify_code(changed_account.email, changed_account.code) + if not is_correct: + return {'code': 10403, 'msg': f'Email Code ({changed_account.code}) Is Not Correct'} + + hashed_password = password.get_hashed_password(changed_account.password) + + await user_crud.update_account('password', changed_account.id, hashed_password) + + return {'code': 10400, 'msg': 'Update Email Successfully'} \ No newline at end of file diff --git a/src/routers/user_profile.py b/src/routers/user_profile.py index 9a7df98..47e43d2 100755 --- a/src/routers/user_profile.py +++ b/src/routers/user_profile.py @@ -1,25 +1,25 @@ from typing import Optional -from fastapi import APIRouter, Depends +from fastapi import APIRouter from pydantic import BaseModel -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse +from fastapi.encoders import jsonable_encoder from anyio import open_file from ..crud import user_crud -from ..dependencies import verify_token from ..response_models.user_response import UserProfileResponse, UserAvatarResponse from ..utils import static_file -router = APIRouter(prefix='/user_profile', tags=['user_profile'], dependencies=[Depends(verify_token)]) +router = APIRouter(prefix='/user_profile', tags=['user_profile']) class Uint8List(BaseModel): file: list -class ModifiedProfile(BaseModel): +class ChangedProfile(BaseModel): id: str nickname: Optional[str] = None location: Optional[str] = None @@ -31,7 +31,11 @@ class ModifiedProfile(BaseModel): @router.get('/my', response_model=UserProfileResponse) async def get_profile(id: str): profile = await user_crud.select_profile(id) - return {'code': 10300, 'msg': 'Get My Profile Successfully', 'data': profile.to_dict_all()} + return JSONResponse( + content=jsonable_encoder( + {'code': 10300, 'msg': 'Get My Profile Successfully', 'data': profile.to_dict_all()}, + ) + ) @router.get('/avatar') @@ -40,8 +44,8 @@ async def download_avatar(avatar_filename: str): return FileResponse(avatar_dir_path / avatar_filename) -@router.post('/modify/avatar', response_model=UserAvatarResponse) -async def modify_avatar(id: str, file: Uint8List): +@router.post('/change/avatar', response_model=UserAvatarResponse) +async def change_avatar(id: str, file: Uint8List): avatar_dir_path = static_file.create_avatar_dir() avatar_filename = static_file.create_avatar_filename() @@ -53,22 +57,22 @@ async def modify_avatar(id: str, file: Uint8List): return {'code': 10300, 'msg': 'Update Avatar Successfully', 'data': avatar_filename} -@router.post('/modify/{aspect}') -async def modify_profile(aspect: str, modified_profile: ModifiedProfile): +@router.post('/change/{aspect}') +async def change_profile(aspect: str, changed_profile: ChangedProfile): match aspect: case 'basic': await user_crud.update_profile_basic( - modified_profile.id, - modified_profile.nickname, - modified_profile.location, - modified_profile.birthday, - modified_profile.gender, + changed_profile.id, + changed_profile.nickname, + changed_profile.location, + changed_profile.birthday, + changed_profile.gender, ) case 'sign': await user_crud.update_profile_sign( - modified_profile.id, - modified_profile.sign + changed_profile.id, + changed_profile.sign ) case _: - return {'code': 10301, 'msg': f'No /modify/{aspect} Path'} + return {'code': 10301, 'msg': f'No /change/{aspect} Path'} return {'code': 10300, 'msg': f'Update {aspect} Profile Successfully'} \ No newline at end of file diff --git a/src/utils/email_code.py b/src/utils/email_code.py index d41d47e..8351642 100755 --- a/src/utils/email_code.py +++ b/src/utils/email_code.py @@ -41,7 +41,6 @@ def send_email(to: str): msg.set_content(email_content) smtp.send_message(msg) - smtp.quit() def generate_code(email: str) -> str: @@ -56,3 +55,9 @@ def verify_code(email: str, code: str) -> bool: key = f'code:{email}' value = redis_server.get(key) return code == value + + +def has_code(email: str) -> bool: + key = f'code:{email}' + value = redis_server.get(key) + return True if value else False diff --git a/src/utils/static_file.py b/src/utils/static_file.py index f225af4..8af219d 100755 --- a/src/utils/static_file.py +++ b/src/utils/static_file.py @@ -9,7 +9,7 @@ alphabet = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', def create_avatar_dir() -> Path: - avatar_dir_path = Path(os.getcwd()) / 'static' / 'avatar' + avatar_dir_path = Path(os.getcwd()) / 'static' / 'avatars' if not avatar_dir_path.exists(): avatar_dir_path.mkdir() return avatar_dir_path diff --git a/src/utils/token_handler.py b/src/utils/token_handler.py index 6123ebd..690581a 100644 --- a/src/utils/token_handler.py +++ b/src/utils/token_handler.py @@ -1,9 +1,9 @@ from datetime import datetime, timedelta -from typing import TypedDict +from typing import TypedDict, Tuple from fastapi.security import OAuth2PasswordBearer -from jose import jwt, ExpiredSignatureError +from jose import jwt, ExpiredSignatureError, JWTError # openssl rand -hex 32 SECRET_KEY = '1c3c03b79d084f0c7b41ba11d1d9a4979f72d9fc6eaaaa0a855065e8a5be0468' @@ -17,27 +17,34 @@ class SigninClaim(TypedDict): iss: str iat: float exp: float - phone_mac: str + device_id: str -def create_signin_token(id: str, phone_mac: str) -> str: +def create_signin_token(id: str, device_id: str) -> str: claim: SigninClaim = { 'sub': id, 'iss': 'together', 'iat': datetime.now().timestamp(), 'exp': (datetime.now() + timedelta(days=23)).timestamp(), - 'phone_mac': phone_mac + 'device_id': device_id, } return jwt.encode(claim, SECRET_KEY, algorithm=ALGORITHM) -def verify_signin_token(token): +def verify_signin_token(token) -> Tuple[str | None, str]: try: claim: SigninClaim = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) if claim['exp'] - datetime.now().timestamp() <= 10*24*60*60: - _prolong_token(token) + new_token = _prolong_token(token) + return new_token, claim['sub'] + else: + return None, claim['sub'] except ExpiredSignatureError: - pass + print(f'{token} expire ========================') + raise ExpiredSignatureError + except JWTError: + print(f'This token {token} is not sign by me ==========================') + raise JWTError def _prolong_token(claim: SigninClaim) -> str: