忘记密码接口
This commit is contained in:
parent
3ff6d4a3da
commit
fc053c239d
|
|
@ -4,7 +4,7 @@
|
|||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.12 (dict_server) (2)" jdkType="Python SDK" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.12 (dict_server)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="PROJECT_PROFILE" value="Default" />
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@
|
|||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.12 (dict_server)" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (dict_server) (2)" project-jdk-type="Python SDK" />
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (dict_server)" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
|
|
@ -1,21 +1,59 @@
|
|||
from typing import Literal, List
|
||||
|
||||
import jaconv
|
||||
import pykakasi
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.models import DefinitionJp
|
||||
from app.models import DefinitionJp, CommentFr, CommentJp
|
||||
from app.models.fr import DefinitionFr
|
||||
from app.schemas.comment_schemas import CommentSet
|
||||
from app.schemas.search_schemas import SearchRequest, SearchResponse, SearchItemFr, SearchItemJp
|
||||
from app.utils.all_kana import all_in_kana
|
||||
from app.utils.autocomplete import suggest_autocomplete
|
||||
from app.utils.security import get_current_user
|
||||
from app.utils.textnorm import normalize_text
|
||||
from scripts.update_jp import normalize_jp_text
|
||||
|
||||
dict_search = APIRouter()
|
||||
|
||||
|
||||
async def __get_comments(
|
||||
__query_word: str,
|
||||
language: Literal["jp", "fr"]
|
||||
) -> CommentSet:
|
||||
if language == "fr":
|
||||
comments = await (
|
||||
CommentFr
|
||||
.filter(comment_word__word=__query_word)
|
||||
.select_related("user")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
commentlist = CommentSet(
|
||||
comments=[
|
||||
(
|
||||
comment.user.id,
|
||||
comment.user.name,
|
||||
comment.comment_text
|
||||
) for comment in comments
|
||||
]
|
||||
)
|
||||
return commentlist
|
||||
else:
|
||||
comments = await (
|
||||
CommentJp
|
||||
.filter(comment_word__word=__query_word)
|
||||
.select_related("user")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
commentlist = CommentSet(
|
||||
comments=[
|
||||
(
|
||||
comment.user.id,
|
||||
comment.user.name,
|
||||
comment.comment_text,
|
||||
) for comment in comments
|
||||
]
|
||||
)
|
||||
return commentlist
|
||||
|
||||
|
||||
@dict_search.post("/search", response_model=SearchResponse)
|
||||
async def search(request: Request, body: SearchRequest, user=Depends(get_current_user)):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,25 +1,28 @@
|
|||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from typing import Tuple, Dict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from jose import jwt
|
||||
from typing import Tuple, Dict
|
||||
|
||||
import redis.asyncio as redis
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from jose import jwt
|
||||
|
||||
from app.models.base import ReservedWords, User, Language
|
||||
from app.utils.security import verify_password, hash_password, validate_password, validate_username, get_current_user
|
||||
from settings import settings
|
||||
from app.api.user.user_schemas import UserIn, UpdateUserRequest, UserLoginRequest, UserResetPhoneRequest, \
|
||||
UserDoesNotExistsError, VerifyCodeRequest, UserResetEmailRequest, UserResetPasswordRequest, VerifyEmailRequest
|
||||
from app.core.redis import get_redis
|
||||
|
||||
from app.schemas.user_schemas import UserIn, UserOut, UpdateUserRequest, UserLoginRequest
|
||||
from app.models.base import ReservedWords, User, Language
|
||||
from app.utils.security import get_current_user
|
||||
from settings import settings
|
||||
from . import service
|
||||
from .service import hash_password, send_email_code
|
||||
|
||||
users_router = APIRouter()
|
||||
|
||||
|
||||
@users_router.post("/register")
|
||||
async def register(user_in: UserIn):
|
||||
await validate_username(user_in.username)
|
||||
await validate_password(user_in.password)
|
||||
await service.validate_username(user_in.username)
|
||||
await service.validate_password(user_in.password)
|
||||
|
||||
hashed_pwd = hash_password(user_in.password)
|
||||
hashed_pwd = service.hash_password(user_in.password)
|
||||
|
||||
lang_pref = await Language.get(code=user_in.lang_pref)
|
||||
|
||||
|
|
@ -48,7 +51,7 @@ async def user_modification(updated_user: UpdateUserRequest, current_user: User
|
|||
"""
|
||||
reserved_words = await ReservedWords.filter(category="username").values_list("reserved", flat=True)
|
||||
# 验证当前密码
|
||||
if not await verify_password(updated_user.current_password, current_user.password_hash):
|
||||
if not await service.verify_password(updated_user.current_password, current_user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="原密码错误")
|
||||
|
||||
# 修改用户名(如果提供)
|
||||
|
|
@ -68,7 +71,7 @@ async def user_login(user_in: UserLoginRequest):
|
|||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
if not await verify_password(user_in.password, user.pwd_hashed):
|
||||
if not await service.verify_password(user_in.password, user.pwd_hashed):
|
||||
raise HTTPException(status_code=400, detail="用户名或密码错误")
|
||||
|
||||
# token 中放置的信息
|
||||
|
|
@ -120,3 +123,85 @@ async def user_logout(request: Request,
|
|||
await redis_client.setex(f"blacklist:{raw_token}", ttl, "true")
|
||||
|
||||
return {"message": "logout ok"}
|
||||
|
||||
#后续通过参数合并
|
||||
@users_router.post("/auth/forget-password/phone", deprecated=True)
|
||||
async def forget_password(request: Request, user_request: UserResetPhoneRequest):
|
||||
encrypted_phone = request.app.state.phone_encrypto.encrypt(phone=user_request.phone)
|
||||
user = await User.get_or_none(encrypted_phone=encrypted_phone)
|
||||
|
||||
if not user:
|
||||
raise UserDoesNotExistsError()
|
||||
|
||||
redis = request.app.state.Redis
|
||||
code = service.generate_code()
|
||||
await service.save_code_redis(redis, phone=user_request.phone_number, code=code)
|
||||
|
||||
# TODO 短信服务
|
||||
|
||||
print(f"[DEBUG] 给 {user_request.phone_number} 发送验证码:{code}")
|
||||
|
||||
return {"message": "验证码已发送"}
|
||||
|
||||
|
||||
# TODO 后续升级为防止爆破测试手机号的
|
||||
|
||||
@users_router.post("/auth/varify_code", deprecated=True)
|
||||
async def varify_code(data: VerifyCodeRequest, request: Request):
|
||||
redis = request.app.state.redis
|
||||
if not await service.varify_code(redis=redis, phone=data.phone, input_code=data.code):
|
||||
raise HTTPException(status_code=400, detail="验证码错误或已过期")
|
||||
return {"message": "验证成功,可以重置密码"}
|
||||
|
||||
|
||||
@users_router.post("/auth/forget-password/email", deprecated=False, description="邮箱遗忘接口")
|
||||
async def email_forget_password(request: Request, user_request: UserResetEmailRequest):
|
||||
"""
|
||||
用户点击验证邮箱时启用
|
||||
:param request:
|
||||
:param user_request:
|
||||
:return:
|
||||
"""
|
||||
user_email = user_request.email
|
||||
user = await User.get_or_none(email=user_email)
|
||||
if not user:
|
||||
raise UserDoesNotExistsError("User does not exists")
|
||||
|
||||
redis = request.app.state.redis
|
||||
code = service.generate_code()
|
||||
await service.save_email_code(redis, email=user_request.email, code=code)
|
||||
|
||||
# 邮箱服务
|
||||
await send_email_code(redis, user_request.email, code)
|
||||
|
||||
print(f"[DEBUG] 给 {user_request.email} 发送验证码:{code}")
|
||||
|
||||
return {"message": "验证码已发送"}
|
||||
|
||||
|
||||
@users_router.post("/auth/varify_code/email")
|
||||
async def email_varify_code(request: Request, data: VerifyEmailRequest):
|
||||
redis = request.app.state.redis
|
||||
reset_token = await service.verify_and_get_reset_token(redis=redis, email=data.email, input_code=data.code)
|
||||
if not reset_token:
|
||||
raise HTTPException(status_code=400, detail="验证码错误或已过期")
|
||||
|
||||
return {
|
||||
"reset_token": reset_token,
|
||||
}
|
||||
|
||||
@users_router.post("/auth/reset-password", deprecated=False)
|
||||
async def reset_password(request: Request, reset_request: UserResetPasswordRequest):
|
||||
# 校验密码是否合法
|
||||
await service.validate_password(password=reset_request.password)
|
||||
|
||||
redis = request.app.state.redis
|
||||
reset_token = request.headers.get("x-reset-token")
|
||||
user_id = await service.is_reset_password(redis=redis, token=reset_token)
|
||||
|
||||
new_password = hash_password(raw_password=reset_request.password)
|
||||
|
||||
await User.filter(id=user_id).update(pwd_hashed=new_password)
|
||||
|
||||
return {"massage": "密码重置成功"}
|
||||
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import random
|
||||
import re
|
||||
|
||||
import bcrypt
|
||||
from fastapi import HTTPException
|
||||
from jose import ExpiredSignatureError, JWTError
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.core.email_utils import send_email
|
||||
from app.core.reset_utils import create_reset_token, save_reset_jti, verify_and_consume_reset_token, ResetTokenError
|
||||
from app.models.base import ReservedWords, User
|
||||
|
||||
|
||||
# 登陆校验
|
||||
async def validate_username(username: str):
|
||||
# 长度限制
|
||||
if not (3 <= len(username) <= 20):
|
||||
raise ValueError('用户名长度必须在3到20个字符之间')
|
||||
# 只能包含字母、数字和下划线
|
||||
if not re.fullmatch(r'[a-zA-Z_][a-zA-Z0-9_]*', username):
|
||||
raise ValueError('用户名只能包含字母、数字和下划线,且不能以数字开头')
|
||||
# 保留词
|
||||
reserved_words = await ReservedWords.filter(category="username").values_list("reserved", flat=True)
|
||||
if username.lower() in {w.lower() for w in reserved_words}:
|
||||
raise HTTPException(status_code=400, detail="用户名为保留关键词,请更换")
|
||||
|
||||
|
||||
async def validate_password(password: str):
|
||||
if not (6 <= len(password) <= 20):
|
||||
raise HTTPException(status_code=400, detail="密码长度必须在6到20之间")
|
||||
if not re.search(r'\d', password):
|
||||
raise HTTPException(status_code=400, detail="密码必须包含至少一个数字")
|
||||
# 检查是否包含允许的特殊字符(白名单方式)
|
||||
allowed_specials = r"!@#$%^&*()_\-+=\[\]{};:'\",.<>?/\\|`~"
|
||||
if re.search(fr"[^\da-zA-Z{re.escape(allowed_specials)}]", password):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"密码只能包含字母、数字和常见特殊字符 {allowed_specials}"
|
||||
)
|
||||
|
||||
|
||||
# 登陆校验
|
||||
async def verify_password(raw_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
校验用户登录时输入的密码是否与数据库中保存的加密密码匹配。
|
||||
|
||||
参数:
|
||||
raw_password: 用户登录时输入的明文密码
|
||||
hashed_password: 数据库存储的加密密码字符串(password_hash)
|
||||
|
||||
返回:
|
||||
如果密码匹配,返回 True;否则返回 False
|
||||
"""
|
||||
return bcrypt.checkpw(raw_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||
|
||||
|
||||
# 注册或修改密码时加密
|
||||
def hash_password(raw_password: str) -> str:
|
||||
"""
|
||||
将用户输入的明文密码进行加密(哈希)后返回字符串,用于保存到数据库中。
|
||||
|
||||
参数:
|
||||
raw_password: 用户输入的明文密码
|
||||
|
||||
返回:
|
||||
加密后的密码字符串(含盐),可直接保存到数据库字段如 password_hash 中
|
||||
"""
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(raw_password.encode("utf-8"), salt).decode("utf-8")
|
||||
|
||||
|
||||
# 生成随机验证码
|
||||
def generate_code(length=6):
|
||||
return "".join([str(random.randint(0, 9)) for _ in range(length)])
|
||||
|
||||
|
||||
# PHONE MESSAGE
|
||||
async def save_code_redis(redis: Redis, phone: str, code: str, expire: int = 300):
|
||||
await redis.setex(f"sms:{phone}", expire, code)
|
||||
|
||||
|
||||
async def varify_code(redis: Redis, phone: str, input_code: str):
|
||||
stored = await redis.get(f"sms:{phone}")
|
||||
return stored is not None and stored.decode() == input_code
|
||||
|
||||
|
||||
# EMAIL
|
||||
async def save_email_code(redis: Redis, email: str, code: str, expire: int = 300):
|
||||
await redis.setex(f"email:{email}", expire, code)
|
||||
|
||||
|
||||
async def send_email_code(redis: Redis, email: str, code: str):
|
||||
await save_email_code(redis, email, code)
|
||||
|
||||
subject = "Lexiverse 用户邮箱验证码"
|
||||
content = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height:1.6;">
|
||||
<h2 style="color:#4CAF50;">Lexiverse 验证码</h2>
|
||||
<p>您好,</p>
|
||||
<p>您正在进行 <b>密码重置</b> 操作。</p>
|
||||
<p>
|
||||
您的验证码是:
|
||||
<span style="font-size: 24px; font-weight: bold; color: #d9534f;">{code}</span>
|
||||
</p>
|
||||
<p>有效期 5 分钟,请勿泄露给他人。</p>
|
||||
<hr>
|
||||
<p style="font-size: 12px; color: #999;">
|
||||
如果这不是您本人的操作,请忽略此邮件。
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
send_email(email, subject, content)
|
||||
|
||||
|
||||
async def __verify_email_code(redis: Redis, email: str, input_code: str) -> bool:
|
||||
stored = await redis.getdel(f"email:{email}")
|
||||
if stored is None or stored != input_code:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def __get_reset_token(redis: Redis, email: str):
|
||||
user = await User.get_or_none(email=email)
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
reset_token, jti = create_reset_token(user_id=user.id, expire_seconds=300)
|
||||
|
||||
await save_reset_jti(redis, user.id, jti=jti, expire_seconds=300)
|
||||
|
||||
return reset_token
|
||||
|
||||
|
||||
async def verify_and_get_reset_token(redis: Redis, email: str, input_code: str):
|
||||
ok = await __verify_email_code(redis, email, input_code)
|
||||
if not ok:
|
||||
return None
|
||||
|
||||
return await __get_reset_token(redis, email)
|
||||
|
||||
|
||||
async def is_reset_password(redis: Redis, token: str):
|
||||
try:
|
||||
user_id = await verify_and_consume_reset_token(redis=redis, token=token)
|
||||
return user_id
|
||||
except ResetTokenError as e:
|
||||
print(e)
|
||||
raise ResetTokenError("Token 非法或已过期")
|
||||
except ExpiredSignatureError as e:
|
||||
print(e)
|
||||
except JWTError as e:
|
||||
print(e)
|
||||
|
|
@ -1,12 +1,19 @@
|
|||
from pydantic import BaseModel
|
||||
from typing import Literal, Optional
|
||||
from typing import Literal, Optional, Annotated
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
default_portrait_url = '#'
|
||||
|
||||
ChinaPhone = Annotated[str, Field(pattern=r"^1[3-9]\d{9}$")]
|
||||
EmailField = Annotated[str, Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")]
|
||||
|
||||
|
||||
class UserIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
email: Optional[EmailField] = None
|
||||
phone: ChinaPhone
|
||||
lang_pref: Literal['jp', 'fr', 'private'] = "private"
|
||||
portrait: str = default_portrait_url
|
||||
|
||||
|
|
@ -57,3 +64,35 @@ class UpdateUserRequest(BaseModel):
|
|||
class UserLoginRequest(BaseModel):
|
||||
name: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserSchema(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class UserResetPhoneRequest(BaseModel):
|
||||
phone_number: ChinaPhone
|
||||
|
||||
|
||||
class UserDoesNotExistsError(HTTPException):
|
||||
def __init__(self, message: str):
|
||||
super().__init__(status_code=404, detail=message)
|
||||
|
||||
|
||||
class VerifyCodeRequest(BaseModel):
|
||||
code: str
|
||||
phone: ChinaPhone
|
||||
|
||||
|
||||
class UserResetEmailRequest(BaseModel):
|
||||
email: EmailField
|
||||
|
||||
|
||||
class VerifyEmailRequest(BaseModel):
|
||||
email: EmailField
|
||||
code: str
|
||||
|
||||
|
||||
class UserResetPasswordRequest(BaseModel):
|
||||
password: str
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import os
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formataddr
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
SMTP_HOST = os.getenv("SMTP_HOST")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
|
||||
SMTP_USER = os.getenv("SMTP_USER")
|
||||
SMTP_PASS = os.getenv("SMTP_PASS")
|
||||
|
||||
def send_email(to_email: str, subject: str, content: str):
|
||||
msg = MIMEText(content, "html", "utf-8")
|
||||
msg["From"] = formataddr(("noreply-Lexiverse", SMTP_USER))
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = subject
|
||||
|
||||
try:
|
||||
server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT)
|
||||
code, response = server.login(SMTP_USER, SMTP_PASS)
|
||||
print(f"[DEBUG] 登录响应: {code}, {response}")
|
||||
result = server.sendmail(SMTP_USER, [to_email], msg.as_string())
|
||||
print(f"[DEBUG] sendmail 返回: {result}")
|
||||
print(f"[DEBUG] 邮件已发送到 {to_email}")
|
||||
try:
|
||||
server.quit() # 主动关闭连接
|
||||
except smtplib.SMTPResponseException as e:
|
||||
if e.smtp_code == -1:
|
||||
# QQ 邮箱常见问题:断开时返回非标准响应
|
||||
print(f"[WARN] 邮件发送成功,但服务器关闭连接时异常: {e.smtp_error}")
|
||||
else:
|
||||
raise
|
||||
except smtplib.SMTPAuthenticationError:
|
||||
print("[ERROR] 邮件认证失败,请检查账号和授权码是否正确")
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 邮件发送失败: {e}")
|
||||
raise
|
||||
|
||||
def main(receiver: str, code: int = 123456):
|
||||
content_model = content = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height:1.6;">
|
||||
<h2 style="color:#4CAF50;">Lexiverse 验证码</h2>
|
||||
<p>您好,</p>
|
||||
<p>您正在进行 <b>密码重置</b> 操作。</p>
|
||||
<p>
|
||||
您的验证码是:
|
||||
<span style="font-size: 24px; font-weight: bold; color: #d9534f;">{code}</span>
|
||||
</p>
|
||||
<p>有效期 5 分钟,请勿泄露给他人。</p>
|
||||
<hr>
|
||||
<p style="font-size: 12px; color: #999;">
|
||||
如果这不是您本人的操作,请忽略此邮件。
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
send_email(to_email=receiver, subject='Test Email', content=content_model)
|
||||
# send_email(to_email="GodricTan@gmail.com", subject="Test Email", content=content_model)
|
||||
|
||||
if __name__ == '__main__':
|
||||
xza = "3480039769@qq.com"
|
||||
bb = "1530799205@qq.com"
|
||||
me = "GodricTan@gmail.com"
|
||||
main(xza, code=123833)
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import redis.asyncio as redis
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
# 全局 Redis 客户端
|
||||
redis_client: Optional[redis.Redis] = None
|
||||
|
||||
|
|
@ -15,6 +16,8 @@ async def init_redis():
|
|||
)
|
||||
await redis_client.ping()
|
||||
|
||||
return redis_client
|
||||
|
||||
async def close_redis():
|
||||
global redis_client
|
||||
if redis_client:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Tuple
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import HTTPException
|
||||
from jose import jwt, ExpiredSignatureError, JWTError
|
||||
from redis.asyncio import Redis
|
||||
|
||||
load_dotenv()
|
||||
|
||||
RESET_SECRET_KEY = os.getenv("RESET_SECRET_KEY")
|
||||
ALGORITHM = 'HS256'
|
||||
|
||||
|
||||
class ResetTokenError(HTTPException):
|
||||
def __init__(self, message: str):
|
||||
super().__init__(status_code=400, detail=message)
|
||||
|
||||
|
||||
def create_reset_token(user_id: int, expire_seconds: int = 300) -> Tuple[str, str]:
|
||||
"""生成 reset_token (JWT) 和 jti"""
|
||||
jti = uuid.uuid4().hex
|
||||
payload = {
|
||||
'sub': str(user_id),
|
||||
'purpose': 'reset_pw',
|
||||
'exp': datetime.now(timezone.utc) + timedelta(hours=2),
|
||||
'jti': jti,
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, RESET_SECRET_KEY, algorithm=ALGORITHM)
|
||||
return token, jti
|
||||
|
||||
|
||||
async def save_reset_jti(redis: Redis, user_id: int, jti: str, expire_seconds: int = 300):
|
||||
"""把 jti 存到 Redis,设置过期时间"""
|
||||
await redis.setex(f"reset:{user_id}", expire_seconds, jti)
|
||||
|
||||
|
||||
async def verify_and_consume_reset_token(redis: Redis, token: str) -> int | None:
|
||||
"""
|
||||
验证 reset_token:
|
||||
- 校验签名、过期时间、用途
|
||||
- 校验 Redis 里 jti 是否匹配
|
||||
- 如果通过,删除 Redis 记录,确保一次性
|
||||
- 返回 user_id,否则 None
|
||||
"""
|
||||
try:
|
||||
# 1. 解码并验证签名
|
||||
payload = jwt.decode(token, RESET_SECRET_KEY, algorithms=[ALGORITHM], options={"verify_exp": False})
|
||||
|
||||
# 2. 校验用途
|
||||
if payload.get("purpose") != "reset_pw":
|
||||
return None
|
||||
|
||||
user_id = int(payload.get("sub"))
|
||||
jti = payload.get("jti")
|
||||
|
||||
stored = await redis.getdel(f"reset:{user_id}")
|
||||
if stored is None or stored != jti:
|
||||
raise ResetTokenError("Token 非法或已过期")
|
||||
|
||||
return user_id
|
||||
|
||||
except ExpiredSignatureError as e:
|
||||
raise ExpiredSignatureError(e)
|
||||
except JWTError as e:
|
||||
raise JWTError(e)
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
from . import signals
|
||||
from .base import User
|
||||
from .comments import CommentFr, CommentJp
|
||||
from .fr import WordlistFr, DefinitionFr, AttachmentFr
|
||||
from .jp import WordlistJp, DefinitionJp, AttachmentJp, PosType
|
||||
from .base import User
|
||||
|
|
@ -17,15 +17,17 @@
|
|||
|
||||
"""
|
||||
|
||||
from tortoise.models import Model
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
|
||||
class User(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
name = fields.CharField(max_length=20, unique=True, description="用户名")
|
||||
pwd_hashed = fields.CharField(max_length=60, description="密码")
|
||||
portrait = fields.CharField(max_length=120, description="用户头像")
|
||||
portrait = fields.CharField(max_length=120, default='#', description="用户头像")
|
||||
email = fields.CharField(max_length=120, null=True, description="e-mail")
|
||||
encrypted_phone = fields.CharField(max_length=11, description="用户手机号")
|
||||
language = fields.ForeignKeyField("models.Language", related_name="users", on_delete=fields.CASCADE)
|
||||
is_admin = fields.BooleanField(default=False, description="管理员权限")
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
"""
|
||||
本脚本对于同一个加密密钥只允许生成一次
|
||||
"""
|
||||
import secrets
|
||||
|
||||
key = secrets.token_hex(32) # 生成 32字节 (256 bit) 十六进制字符串
|
||||
print(key)
|
||||
print(len(key)) # 64个字符 (32字节 -> hex编码 = 64字符)
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import base64
|
||||
import os
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class PhoneEncrypt:
|
||||
def __init__(self, key: bytes):
|
||||
self.key = key
|
||||
if len(self.key) not in (16, 24, 32):
|
||||
raise ValueError("AES 密钥必须是 16/24/32 字节")
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
hex_key = os.getenv("AES_SECRET_KEY")
|
||||
return cls(bytes.fromhex(hex_key))
|
||||
|
||||
def encrypt(self, phone: str) -> str:
|
||||
"""
|
||||
加密手机号 -> 返回 Base64 字符串
|
||||
:return iv为解密初始数
|
||||
ct为加密密文
|
||||
"""
|
||||
cipher = AES.new(self.key, AES.MODE_CBC) # 随机 IV
|
||||
ct_bytes = cipher.encrypt(pad(phone.encode(), AES.block_size))
|
||||
iv = base64.b64encode(cipher.iv).decode()
|
||||
ct = base64.b64encode(ct_bytes).decode()
|
||||
return f"{iv}:{ct}"
|
||||
|
||||
def decrypt(self, data: str) -> str:
|
||||
"""解密 Base64 字符串 -> 返回手机号"""
|
||||
iv_b64, ct_b64 = data.split(":")
|
||||
iv = base64.b64decode(iv_b64)
|
||||
ct = base64.b64decode(ct_b64)
|
||||
cipher = AES.new(self.key, AES.MODE_CBC, iv)
|
||||
pt = unpad(cipher.decrypt(ct), AES.block_size)
|
||||
return pt.decode()
|
||||
|
||||
|
||||
def main():
|
||||
phone_encrypt = PhoneEncrypt()
|
||||
encrypted = phone_encrypt.encrypt("13568847988")
|
||||
print(encrypted)
|
||||
|
||||
decrypted = phone_encrypt.decrypt(encrypted)
|
||||
print(decrypted)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -1,84 +1,17 @@
|
|||
import re
|
||||
import bcrypt
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Tuple, Dict, Annotated
|
||||
|
||||
import redis.asyncio as redis
|
||||
from fastapi import HTTPException, Request, Depends
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import jwt, JWTError, ExpiredSignatureError
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.models.base import ReservedWords, User
|
||||
|
||||
from app.models.base import User
|
||||
from settings import settings
|
||||
|
||||
redis_client = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
async def validate_username(username: str):
|
||||
# 长度限制
|
||||
if not (3 <= len(username) <= 20):
|
||||
raise ValueError('用户名长度必须在3到20个字符之间')
|
||||
# 只能包含字母、数字和下划线
|
||||
if not re.fullmatch(r'[a-zA-Z_][a-zA-Z0-9_]*', username):
|
||||
raise ValueError('用户名只能包含字母、数字和下划线,且不能以数字开头')
|
||||
# 保留词
|
||||
reserved_words = await ReservedWords.filter(category="username").values_list("reserved", flat=True)
|
||||
if username.lower() in {w.lower() for w in reserved_words}:
|
||||
raise HTTPException(status_code=400, detail="用户名为保留关键词,请更换")
|
||||
|
||||
|
||||
async def validate_password(password: str):
|
||||
if not (6 <= len(password) <= 20):
|
||||
raise HTTPException(status_code=400, detail="密码长度必须在6到20之间")
|
||||
if not re.search(r'\d', password):
|
||||
raise HTTPException(status_code=400, detail="密码必须包含至少一个数字")
|
||||
# 检查是否包含允许的特殊字符(白名单方式)
|
||||
allowed_specials = r"!@#$%^&*()_\-+=\[\]{};:'\",.<>?/\\|`~"
|
||||
if re.search(fr"[^\da-zA-Z{re.escape(allowed_specials)}]", password):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"密码只能包含字母、数字和常见特殊字符 {allowed_specials}"
|
||||
)
|
||||
|
||||
|
||||
# 登陆校验
|
||||
async def verify_password(raw_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
校验用户登录时输入的密码是否与数据库中保存的加密密码匹配。
|
||||
|
||||
参数:
|
||||
raw_password: 用户登录时输入的明文密码
|
||||
hashed_password: 数据库存储的加密密码字符串(password_hash)
|
||||
|
||||
返回:
|
||||
如果密码匹配,返回 True;否则返回 False
|
||||
"""
|
||||
return bcrypt.checkpw(raw_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||
|
||||
|
||||
# 注册或修改密码时加密
|
||||
def hash_password(raw_password: str) -> str:
|
||||
"""
|
||||
将用户输入的明文密码进行加密(哈希)后返回字符串,用于保存到数据库中。
|
||||
|
||||
参数:
|
||||
raw_password: 用户输入的明文密码
|
||||
|
||||
返回:
|
||||
加密后的密码字符串(含盐),可直接保存到数据库字段如 password_hash 中
|
||||
"""
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(raw_password.encode("utf-8"), salt).decode("utf-8")
|
||||
|
||||
|
||||
# @asynccontextmanager
|
||||
# async def redis_pool():
|
||||
# client = redis.Redis(host="localhost", port=6379, decode_responses=True)
|
||||
# yield client
|
||||
|
||||
|
||||
async def _extract_bearer_token(request: Request) -> str:
|
||||
"""
|
||||
小工具:提取 Bearer Token(兼容大小写/多空格)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
# FastAPI
|
||||
版本:0.1.0
|
||||
|
||||
**认证方式**
|
||||
部分接口需要 `OAuth2PasswordBearer`,在 Header 中添加:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
------
|
||||
|
||||
## User API
|
||||
|
||||
### Register
|
||||
**Method**: `POST`
|
||||
**Path**: `/users/register`
|
||||
|
||||
#### 请求体
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|----------|--------|------|------|
|
||||
| username | string | 是 | Username |
|
||||
| password | string | 是 | Password |
|
||||
| lang_pref | string(enum: jp, fr, private,默认: private) | 否 | Lang Pref |
|
||||
| portrait | string(默认: #) | 否 | Portrait |
|
||||
|
||||
#### 响应
|
||||
**200 成功**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|--------|--------|------|------|
|
||||
| name | string | 是 | Name |
|
||||
| potrait| string(默认: #) | 否 | Potrait |
|
||||
|
||||
**422 验证错误**
|
||||
返回 `HTTPValidationError`
|
||||
|
||||
---
|
||||
|
||||
### User Modification
|
||||
**Method**: `PUT`
|
||||
**Path**: `/users/update`
|
||||
需要认证
|
||||
|
||||
描述:根据 JSON 内容修改对应字段。
|
||||
|
||||
#### 请求体
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------------------|--------|------|------|
|
||||
| current_password | string / null | 否 | Current Password |
|
||||
| new_username | string / null | 否 | New Username |
|
||||
| new_password | string / null | 否 | New Password |
|
||||
| new_language | string(enum: jp, fr, private,默认: private) | 否 | New Language |
|
||||
|
||||
#### 响应
|
||||
- **200 成功**(空 schema)
|
||||
- **422 验证错误**
|
||||
|
||||
------
|
||||
|
||||
### User Logout
|
||||
|
||||
**Method**: `POST`
|
||||
**Path**: `/users/logout`
|
||||
需要认证
|
||||
|
||||
#### 响应
|
||||
|
||||
- **200 成功**(空 schema)
|
||||
|
||||
------
|
||||
|
||||
## Dictionary Search API
|
||||
|
||||
### Search
|
||||
|
||||
**Method**: `POST`
|
||||
**Path**: `/search`
|
||||
需要认证
|
||||
|
||||
#### 请求体
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| -------- | ----------------------------------------- | ---- | -------- |
|
||||
| query | string | 是 | Query |
|
||||
| language | string(enum: fr, jp) | 是 | Language |
|
||||
| sort | string(enum: relevance, date,默认: date) | 否 | Sort |
|
||||
| order | string(enum: asc, des,默认: des) | 否 | Order |
|
||||
|
||||
#### 响应
|
||||
|
||||
**200 成功**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| -------- | --------------------------------------- | ---- | -------- |
|
||||
| query | string | 是 | Query |
|
||||
| pos | array | 是 | Pos |
|
||||
| contents | array(SearchItemFr[] 或 SearchItemJp[]) | 是 | Contents |
|
||||
|
||||
**422 验证错误**
|
||||
|
||||
### Search List
|
||||
|
||||
**Method**: `POST`
|
||||
**Path**: `search/list`
|
||||
需要认证
|
||||
|
||||
描述:检索提示接口,根据用户输入返回候选列表。
|
||||
|
||||
#### 请求体
|
||||
|
||||
同 `/search`
|
||||
|
||||
#### 响应
|
||||
|
||||
- **200 成功**(空 schema)
|
||||
- **422 验证错误**
|
||||
|
||||
------
|
||||
|
||||
## Redis Test-Only API
|
||||
|
||||
### Ping Redis
|
||||
|
||||
**Method**: `GET`
|
||||
**Path**: `/ping-redis`
|
||||
|
||||
#### 响应
|
||||
|
||||
- **200 成功**
|
||||
|
||||
## 错误模型
|
||||
|
||||
### ValidationError
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ---- | ----------------------- | ---- | ---------- |
|
||||
| loc | array[string / integer] | 是 | Location |
|
||||
| msg | string | 是 | Message |
|
||||
| type | string | 是 | Error Type |
|
||||
|
||||
### HTTPValidationError
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ------ | ---------------------- | ---- | ------ |
|
||||
| detail | array[ValidationError] | 否 | Detail |
|
||||
|
||||
24
main.py
24
main.py
|
|
@ -1,25 +1,27 @@
|
|||
from contextlib import asynccontextmanager
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import uvicorn
|
||||
from tortoise.contrib.fastapi import register_tortoise
|
||||
|
||||
from app.api.redis_test import redis_test_router
|
||||
from app.api.translator import translator_router
|
||||
from app.utils import redis_client
|
||||
from settings import TORTOISE_ORM, ONLINE_SETTINGS
|
||||
from app.api.users import users_router
|
||||
from app.api.admin.router import admin_router
|
||||
from app.api.search import dict_search
|
||||
from app.core.redis import init_redis, close_redis
|
||||
import app.models.signals
|
||||
from app.api.admin.router import admin_router
|
||||
from app.api.redis_test import redis_test_router
|
||||
from app.api.search import dict_search
|
||||
from app.api.translator import translator_router
|
||||
from app.api.user.routes import users_router
|
||||
from app.core.redis import init_redis, close_redis
|
||||
from app.utils.phone_encrypt import PhoneEncrypt
|
||||
from settings import ONLINE_SETTINGS
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# ---- startup ----
|
||||
await init_redis()
|
||||
app.state.redis = await init_redis()
|
||||
# phone_encrypt
|
||||
app.state.phone_encrypto = PhoneEncrypt.from_env() # 接口中通过 Request 访问
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
|
|
@ -28,8 +30,6 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
import debug.httpdebugger
|
||||
|
||||
# 添加CORS中间件
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ TORTOISE_ORM = {
|
|||
'app.models.base',
|
||||
'app.models.fr',
|
||||
'app.models.jp',
|
||||
'app.models.comments',
|
||||
'aerich.models' # aerich自带模型类(必须填入)
|
||||
],
|
||||
'default_connection': 'default',
|
||||
|
|
@ -31,6 +32,7 @@ ONLINE_SETTINGS = {
|
|||
'app.models.base',
|
||||
'app.models.fr',
|
||||
'app.models.jp',
|
||||
'app.models.comments'
|
||||
'aerich.models' # aerich自带模型类(必须填入)
|
||||
],
|
||||
'default_connection': 'default',
|
||||
|
|
|
|||
Loading…
Reference in New Issue