用户接口相关邮箱验证功能接口
This commit is contained in:
parent
fc053c239d
commit
340b2a7b6b
|
|
@ -4,6 +4,7 @@
|
|||
<file url="file://$PROJECT_DIR$/migrations/models/11_20250803092810_update.py" dialect="MySQL" />
|
||||
<file url="file://$PROJECT_DIR$/migrations/models/16_20250806104900_update.py" dialect="GenericSQL" />
|
||||
<file url="file://$PROJECT_DIR$/migrations/models/21_20250827100315_update.py" dialect="MySQL" />
|
||||
<file url="file://$PROJECT_DIR$/migrations/models/33_20250929111212_update.py" dialect="MySQL" />
|
||||
<file url="file://$PROJECT_DIR$/scripts/update_fr.py" dialect="MySQL" />
|
||||
</component>
|
||||
</project>
|
||||
103
README.md
103
README.md
|
|
@ -24,6 +24,7 @@ Authorization: Bearer <your_jwt_token>
|
|||
### 1. 用户认证模块 (`/users`)
|
||||
|
||||
#### 1.1 用户注册
|
||||
##### 1.1.1 注册主接口
|
||||
|
||||
- **接口**: `POST /users/register`
|
||||
- **描述**: 新用户注册
|
||||
|
|
@ -33,8 +34,11 @@ Authorization: Bearer <your_jwt_token>
|
|||
{
|
||||
"username": "string",
|
||||
"password": "string",
|
||||
"email": "EmailFields[string]",
|
||||
"phone": "PhoneFields",
|
||||
"lang_pref": "jp" | "fr" | "private",
|
||||
"portrait": "string"
|
||||
"portrait": "string",
|
||||
"code": "string"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -51,6 +55,30 @@ Authorization: Bearer <your_jwt_token>
|
|||
- `200`: 注册成功
|
||||
- `400`: 参数验证失败
|
||||
|
||||
##### 1.1.2 邮箱验证
|
||||
|
||||
- **接口**: `POST /users/register/email_verify`
|
||||
- **描述**: 新用户注册时的邮箱验证
|
||||
- **请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"user_email" : "string"
|
||||
}
|
||||
```
|
||||
|
||||
- **响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "验证码已发送"
|
||||
}
|
||||
```
|
||||
|
||||
- **状态码**:
|
||||
- `200`: 邮件发送成功
|
||||
- `400`: 邮箱已被使用
|
||||
|
||||
#### 1.2 用户登录
|
||||
|
||||
- **接口**: `POST /users/login`
|
||||
|
|
@ -120,6 +148,79 @@ Authorization: Bearer <your_jwt_token>
|
|||
- `200`: 更新成功
|
||||
- `400`: 原密码错误或用户名为保留词
|
||||
|
||||
#### 1.5 邮箱找回密码(发送验证码)
|
||||
|
||||
- **接口**: `POST /users/auth/forget-password/email`
|
||||
- **描述**: 用户请求通过邮箱找回密码时,向注册邮箱发送验证码
|
||||
- **请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "string"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
- **响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "验证码已发送"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
- **状态码**
|
||||
- `200`: 更新成功
|
||||
- `404`: 用户不存在
|
||||
|
||||
#### 1.6 邮箱验证码验证
|
||||
|
||||
- **接口**: `POST /users/auth/varify_code/email`
|
||||
- **描述**: 用户输入邮箱验证码后,验证验证码是否有效,返回重置令牌
|
||||
- **请求体**:
|
||||
```json
|
||||
{
|
||||
"email": "string",
|
||||
"code": "string"
|
||||
}
|
||||
```
|
||||
|
||||
- **响应**
|
||||
```json
|
||||
{
|
||||
"reset_token": "string"
|
||||
}
|
||||
```
|
||||
|
||||
- **状态码**
|
||||
- `200`: 验证码验证成功
|
||||
- `400`: 验证码错误或已过期
|
||||
|
||||
#### 1.7 重置密码
|
||||
|
||||
- **接口**: `POST /users/auth/reset-password`
|
||||
- **描述**: 用户通过邮箱验证码获得的重置令牌来设置新密码
|
||||
- **请求头**:
|
||||
- `x-reset-token`: 重置令牌
|
||||
- **请求体**:
|
||||
```json
|
||||
{
|
||||
"password": "string"
|
||||
}
|
||||
```
|
||||
|
||||
- **响应**
|
||||
```json
|
||||
{
|
||||
"massage": "密码重置成功"
|
||||
}
|
||||
```
|
||||
|
||||
- **状态码**
|
||||
- `200`: 密码重置成功
|
||||
- `400`: 密码不合法或令牌无效
|
||||
|
||||
---
|
||||
|
||||
### 2. 词典搜索模块
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import redis
|
||||
from fastapi import APIRouter, Depends
|
||||
from app.core.redis import redis_client, get_redis
|
||||
|
||||
from app.core.redis import get_redis
|
||||
|
||||
redis_test_router = APIRouter()
|
||||
|
||||
@redis_test_router.get("/ping-redis")
|
||||
async def ping_redis(r: redis.Redis = Depends(get_redis)):
|
||||
return {"pong": await r.ping()}
|
||||
return {
|
||||
"pong": await r.ping(),
|
||||
"redis": r.connection_pool.connection_kwargs,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,41 +6,70 @@ from fastapi import APIRouter, HTTPException, Depends, Request
|
|||
from jose import jwt
|
||||
|
||||
from app.api.user.user_schemas import UserIn, UpdateUserRequest, UserLoginRequest, UserResetPhoneRequest, \
|
||||
UserDoesNotExistsError, VerifyCodeRequest, UserResetEmailRequest, UserResetPasswordRequest, VerifyEmailRequest
|
||||
VerifyPhoneCodeRequest, UserResetEmailRequest, UserResetPasswordRequest, VerifyEmailRequest
|
||||
from app.core.redis import get_redis
|
||||
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):
|
||||
async def register(req: Request, user_in: UserIn):
|
||||
await service.validate_username(user_in.username)
|
||||
await service.validate_password(user_in.password)
|
||||
# await service.validate_email_exists(user_in.email)
|
||||
result = await service.verify_email_code(
|
||||
redis=req.app.state.redis,
|
||||
email=user_in.email,
|
||||
input_code=user_in.code
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=400, detail="验证码错误或已过期")
|
||||
|
||||
hashed_pwd = service.hash_password(user_in.password)
|
||||
|
||||
lang_pref = await Language.get(code=user_in.lang_pref)
|
||||
|
||||
new_user, created = await User.get_or_create(
|
||||
encrypted_phone = (
|
||||
req.app.state.phone_encrypto.encrypt(user_in.phone)) \
|
||||
if user_in.phone else None
|
||||
|
||||
new_user = await User.create(
|
||||
name=user_in.username,
|
||||
defaults={
|
||||
"pwd_hashed": hashed_pwd,
|
||||
"language": lang_pref,
|
||||
"portrait": user_in.portrait, },
|
||||
email=user_in.email,
|
||||
pwd_hashed=hashed_pwd,
|
||||
language=lang_pref,
|
||||
encrypted_phone=encrypted_phone,
|
||||
)
|
||||
if not created:
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
return {
|
||||
"id": new_user.id,
|
||||
"message": "register success",
|
||||
}
|
||||
|
||||
|
||||
@users_router.post("/register/email_verify")
|
||||
async def register_email_verify(req: Request, user_email: UserResetEmailRequest):
|
||||
await service.validate_email_exists(user_email.email)
|
||||
|
||||
code = service.generate_code()
|
||||
redis = req.app.state.redis
|
||||
|
||||
await service.save_email_code(redis, email=user_email.email, code=code)
|
||||
await service.send_email_code(
|
||||
redis=redis,
|
||||
email=user_email.email,
|
||||
code=code,
|
||||
ops_type="reg"
|
||||
)
|
||||
|
||||
print(f"[DEBUG] 给 {user_email.email} 发送验证码:{code}")
|
||||
|
||||
return {"message": "验证码已发送"}
|
||||
|
||||
|
||||
@users_router.put("/update", deprecated=False)
|
||||
async def user_modification(updated_user: UpdateUserRequest, current_user: User = Depends(get_current_user)):
|
||||
"""
|
||||
|
|
@ -62,7 +91,7 @@ async def user_modification(updated_user: UpdateUserRequest, current_user: User
|
|||
|
||||
# 修改密码(如果提供)
|
||||
if updated_user.new_password:
|
||||
current_user.password_hash = hash_password(updated_user.new_password)
|
||||
current_user.password_hash = service.hash_password(updated_user.new_password)
|
||||
|
||||
|
||||
@users_router.post("/login")
|
||||
|
|
@ -110,28 +139,19 @@ async def user_logout(request: Request,
|
|||
now = datetime.now(timezone.utc).timestamp()
|
||||
ttl = max(int(exp - now), 1) if exp else 7200
|
||||
|
||||
# try:
|
||||
# payload = jwt.decode(raw_token, SECRET_KEY, algorithms=["HS256"])
|
||||
# exp = payload.get("exp")
|
||||
# now = datetime.now(timezone.utc).timestamp()
|
||||
# ttl = int(exp - now) if exp else 7200 # Time To Live: 黑名单生效时长
|
||||
# except ExpiredSignatureError:
|
||||
# raise HTTPException(status_code=401, detail="登录信息已过期")
|
||||
# except JWTError:
|
||||
# raise HTTPException(status_code=401, detail="无效 token")
|
||||
|
||||
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()
|
||||
raise HTTPException(status_code=404, detail="User does not exists")
|
||||
|
||||
redis = request.app.state.Redis
|
||||
code = service.generate_code()
|
||||
|
|
@ -147,7 +167,7 @@ async def forget_password(request: Request, user_request: UserResetPhoneRequest)
|
|||
# TODO 后续升级为防止爆破测试手机号的
|
||||
|
||||
@users_router.post("/auth/varify_code", deprecated=True)
|
||||
async def varify_code(data: VerifyCodeRequest, request: Request):
|
||||
async def varify_code(data: VerifyPhoneCodeRequest, 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="验证码错误或已过期")
|
||||
|
|
@ -165,14 +185,14 @@ async def email_forget_password(request: Request, user_request: UserResetEmailRe
|
|||
user_email = user_request.email
|
||||
user = await User.get_or_none(email=user_email)
|
||||
if not user:
|
||||
raise UserDoesNotExistsError("User does not exists")
|
||||
raise HTTPException(status_code=404, detail="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 service.save_email_code(redis, email=user_request.email, code=code)
|
||||
|
||||
# 邮箱服务
|
||||
await send_email_code(redis, user_request.email, code)
|
||||
await service.send_email_code(redis, user_request.email, code, ops_type="reset")
|
||||
|
||||
print(f"[DEBUG] 给 {user_request.email} 发送验证码:{code}")
|
||||
|
||||
|
|
@ -187,9 +207,10 @@ async def email_varify_code(request: Request, data: VerifyEmailRequest):
|
|||
raise HTTPException(status_code=400, detail="验证码错误或已过期")
|
||||
|
||||
return {
|
||||
"reset_token": reset_token,
|
||||
"reset_token": reset_token,
|
||||
}
|
||||
|
||||
|
||||
@users_router.post("/auth/reset-password", deprecated=False)
|
||||
async def reset_password(request: Request, reset_request: UserResetPasswordRequest):
|
||||
# 校验密码是否合法
|
||||
|
|
@ -199,9 +220,8 @@ async def reset_password(request: Request, reset_request: UserResetPasswordReque
|
|||
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)
|
||||
new_password = service.hash_password(raw_password=reset_request.password)
|
||||
|
||||
await User.filter(id=user_id).update(pwd_hashed=new_password)
|
||||
|
||||
return {"massage": "密码重置成功"}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import random
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
import bcrypt
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -38,6 +39,11 @@ async def validate_password(password: str):
|
|||
detail=f"密码只能包含字母、数字和常见特殊字符 {allowed_specials}"
|
||||
)
|
||||
|
||||
async def validate_email_exists(email: str):
|
||||
user = await User.get_or_none(email=email)
|
||||
if user:
|
||||
raise HTTPException(status_code=400, detail="邮箱已经被使用,请更换其他邮箱后重试")
|
||||
|
||||
|
||||
# 登陆校验
|
||||
async def verify_password(raw_password: str, hashed_password: str) -> bool:
|
||||
|
|
@ -79,7 +85,7 @@ 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):
|
||||
async def varify_phone_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
|
||||
|
||||
|
|
@ -89,16 +95,20 @@ 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):
|
||||
async def send_email_code(redis: Redis, email: str, code: str, ops_type: Literal["reg", "reset"]):
|
||||
await save_email_code(redis, email, code)
|
||||
|
||||
ops_dict = {
|
||||
"reg" : "用户注册",
|
||||
"reset" : "密码重置",
|
||||
}
|
||||
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>您正在进行 <b>{ops_dict[ops_type]}</b> 操作。</p>
|
||||
<p>
|
||||
您的验证码是:
|
||||
<span style="font-size: 24px; font-weight: bold; color: #d9534f;">{code}</span>
|
||||
|
|
@ -115,7 +125,7 @@ async def send_email_code(redis: Redis, email: str, code: str):
|
|||
send_email(email, subject, content)
|
||||
|
||||
|
||||
async def __verify_email_code(redis: Redis, email: str, input_code: str) -> bool:
|
||||
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
|
||||
|
|
@ -135,7 +145,7 @@ async def __get_reset_token(redis: Redis, email: str):
|
|||
|
||||
|
||||
async def verify_and_get_reset_token(redis: Redis, email: str, input_code: str):
|
||||
ok = await __verify_email_code(redis, email, input_code)
|
||||
ok = await verify_email_code(redis, email, input_code)
|
||||
if not ok:
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import re
|
||||
from typing import Literal, Optional, Annotated
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
default_portrait_url = '#'
|
||||
|
||||
|
|
@ -12,11 +13,26 @@ EmailField = Annotated[str, Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")]
|
|||
class UserIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
email: Optional[EmailField] = None
|
||||
phone: ChinaPhone
|
||||
email: str
|
||||
phone: Optional[str] = None
|
||||
lang_pref: Literal['jp', 'fr', 'private'] = "private"
|
||||
portrait: str = default_portrait_url
|
||||
|
||||
code: str
|
||||
|
||||
@field_validator('email')
|
||||
@classmethod
|
||||
def validate_email(cls, v):
|
||||
if not re.match(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$", string=v):
|
||||
raise HTTPException(status_code=400, detail="邮箱格式错误")
|
||||
return v
|
||||
|
||||
@field_validator('phone')
|
||||
@classmethod
|
||||
def validate_phone(cls, v):
|
||||
if not re.match(pattern=r"^1[3-9]\d{9}$", string=v):
|
||||
raise HTTPException(status_code=400, detail="手机号格式错误")
|
||||
return v
|
||||
# @field_validator('username')
|
||||
# @classmethod
|
||||
# def validate_username(cls, v):
|
||||
|
|
@ -75,12 +91,7 @@ class UserResetPhoneRequest(BaseModel):
|
|||
phone_number: ChinaPhone
|
||||
|
||||
|
||||
class UserDoesNotExistsError(HTTPException):
|
||||
def __init__(self, message: str):
|
||||
super().__init__(status_code=404, detail=message)
|
||||
|
||||
|
||||
class VerifyCodeRequest(BaseModel):
|
||||
class VerifyPhoneCodeRequest(BaseModel):
|
||||
code: str
|
||||
phone: ChinaPhone
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ redis_client: Optional[redis.Redis] = None
|
|||
async def init_redis():
|
||||
global redis_client
|
||||
if redis_client is None:
|
||||
redis_client = await redis.Redis(
|
||||
redis_client = redis.Redis(
|
||||
host="127.0.0.1",
|
||||
port=6379,
|
||||
decode_responses=True, # 返回 str 而不是 Bytes
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ class User(Model):
|
|||
name = fields.CharField(max_length=20, unique=True, description="用户名")
|
||||
pwd_hashed = fields.CharField(max_length=60, 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="用户手机号")
|
||||
email = fields.CharField(max_length=120, description="e-mail")
|
||||
encrypted_phone = fields.CharField(max_length=11, description="用户手机号", null=True)
|
||||
language = fields.ForeignKeyField("models.Language", related_name="users", on_delete=fields.CASCADE)
|
||||
is_admin = fields.BooleanField(default=False, description="管理员权限")
|
||||
|
||||
|
|
|
|||
4
main.py
4
main.py
|
|
@ -13,7 +13,7 @@ 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
|
||||
from settings import TORTOISE_ORM
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -41,7 +41,7 @@ app.add_middleware(
|
|||
|
||||
register_tortoise(
|
||||
app=app,
|
||||
config=ONLINE_SETTINGS,
|
||||
config=TORTOISE_ORM,
|
||||
)
|
||||
|
||||
app.include_router(users_router, tags=["User API"], prefix="/users")
|
||||
|
|
|
|||
|
|
@ -9,10 +9,13 @@ dependencies = [
|
|||
"aiosqlite==0.21.0",
|
||||
"annotated-types==0.7.0",
|
||||
"anyio==4.10.0",
|
||||
"async-timeout==5.0.1",
|
||||
"asyncclick==8.2.2.2",
|
||||
"asyncmy==0.2.10",
|
||||
"bcrypt==4.3.0",
|
||||
"certifi==2025.8.3",
|
||||
"cffi==1.17.1",
|
||||
"charset-normalizer==3.4.3",
|
||||
"click==8.2.1",
|
||||
"colorama==0.4.6",
|
||||
"cryptography==45.0.6",
|
||||
|
|
@ -23,22 +26,28 @@ dependencies = [
|
|||
"fastapi==0.116.1",
|
||||
"fugashi==1.5.1",
|
||||
"h11==0.16.0",
|
||||
"httpcore==1.0.9",
|
||||
"httpx==0.28.1",
|
||||
"idna==3.10",
|
||||
"iso8601==2.1.0",
|
||||
"jaconv==0.4.0",
|
||||
"numpy==2.3.2",
|
||||
"openpyxl==3.1.5",
|
||||
"pandas==2.3.1",
|
||||
"pandas-stubs==2.3.2.250827",
|
||||
"pyasn1==0.6.1",
|
||||
"pycparser==2.22",
|
||||
"pycryptodome==3.23.0",
|
||||
"pydantic==2.11.7",
|
||||
"pydantic-core==2.33.2",
|
||||
"pykakasi==2.3.0",
|
||||
"pypika-tortoise==0.6.1",
|
||||
"python-dateutil==2.9.0.post0",
|
||||
"python-dotenv==1.1.1",
|
||||
"python-jose==3.5.0",
|
||||
"pytz==2025.2",
|
||||
"redis==6.4.0",
|
||||
"requests==2.32.5",
|
||||
"rsa==4.9.1",
|
||||
"six==1.17.0",
|
||||
"sniffio==1.3.1",
|
||||
|
|
@ -47,7 +56,8 @@ dependencies = [
|
|||
"typing-extensions==4.14.1",
|
||||
"typing-inspection==0.4.1",
|
||||
"tzdata==2025.2",
|
||||
"unidic-lite>=1.0.8",
|
||||
"unidic-lite==1.0.8",
|
||||
"urllib3==2.5.0",
|
||||
"uvicorn==0.35.0",
|
||||
"wrapt==1.17.3",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ ONLINE_SETTINGS = {
|
|||
'app.models.base',
|
||||
'app.models.fr',
|
||||
'app.models.jp',
|
||||
'app.models.comments'
|
||||
'app.models.comments',
|
||||
'aerich.models' # aerich自带模型类(必须填入)
|
||||
],
|
||||
'default_connection': 'default',
|
||||
|
|
|
|||
Loading…
Reference in New Issue