Skip to content

Commit b408a2c

Browse files
committed
feat: v14.0-E 安全增强(登录锁定持久化+JWT双Token+邮箱验证码)
1 parent af24e7a commit b408a2c

9 files changed

Lines changed: 456 additions & 38 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""v14e_security_tables
2+
3+
Revision ID: 3f8a9b2c1d47
4+
Revises: f03fbc681fc5
5+
Create Date: 2026-03-24 20:00:00.000000
6+
7+
新增三张安全增强表:
8+
- login_attempts: 登录失败持久化(替代内存字典)
9+
- refresh_tokens: JWT Refresh Token(7天有效)
10+
- email_verifications: 邮箱验证码
11+
"""
12+
from typing import Sequence, Union
13+
14+
from alembic import op
15+
import sqlalchemy as sa
16+
17+
18+
revision: str = '3f8a9b2c1d47'
19+
down_revision: Union[str, Sequence[str], None] = 'f03fbc681fc5'
20+
branch_labels: Union[str, Sequence[str], None] = None
21+
depends_on: Union[str, Sequence[str], None] = None
22+
23+
24+
def upgrade() -> None:
25+
op.create_table('login_attempts',
26+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
27+
sa.Column('identifier', sa.String(length=255), nullable=False),
28+
sa.Column('attempted_at', sa.DateTime(timezone=True), nullable=False),
29+
sa.Column('is_success', sa.Boolean(), nullable=False),
30+
sa.PrimaryKeyConstraint('id'),
31+
)
32+
with op.batch_alter_table('login_attempts', schema=None) as batch_op:
33+
batch_op.create_index(batch_op.f('ix_login_attempts_identifier'), ['identifier'], unique=False)
34+
batch_op.create_index('ix_login_attempts_id_time', ['identifier', 'attempted_at'], unique=False)
35+
36+
op.create_table('refresh_tokens',
37+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
38+
sa.Column('user_id', sa.Integer(), nullable=False),
39+
sa.Column('token_hash', sa.String(length=128), nullable=False),
40+
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
41+
sa.Column('is_revoked', sa.Boolean(), nullable=False),
42+
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
43+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
44+
sa.PrimaryKeyConstraint('id'),
45+
sa.UniqueConstraint('token_hash'),
46+
)
47+
with op.batch_alter_table('refresh_tokens', schema=None) as batch_op:
48+
batch_op.create_index(batch_op.f('ix_refresh_tokens_user_id'), ['user_id'], unique=False)
49+
batch_op.create_index(batch_op.f('ix_refresh_tokens_token_hash'), ['token_hash'], unique=True)
50+
51+
op.create_table('email_verifications',
52+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
53+
sa.Column('email', sa.String(length=255), nullable=False),
54+
sa.Column('code', sa.String(length=10), nullable=False),
55+
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
56+
sa.Column('is_used', sa.Boolean(), nullable=False),
57+
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
58+
sa.PrimaryKeyConstraint('id'),
59+
)
60+
with op.batch_alter_table('email_verifications', schema=None) as batch_op:
61+
batch_op.create_index(batch_op.f('ix_email_verifications_email'), ['email'], unique=False)
62+
63+
64+
def downgrade() -> None:
65+
op.drop_table('email_verifications')
66+
op.drop_table('refresh_tokens')
67+
with op.batch_alter_table('login_attempts', schema=None) as batch_op:
68+
batch_op.drop_index('ix_login_attempts_id_time')
69+
batch_op.drop_index(batch_op.f('ix_login_attempts_identifier'))
70+
op.drop_table('login_attempts')

extension/src/engineClient.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,21 @@ export class EngineClient {
472472
return this._cloudGet("/auth/me", token);
473473
}
474474

475+
/** 用 Refresh Token 换取新的 Access Token + 新 Refresh Token */
476+
async cloudRefreshToken(refreshToken: string): Promise<{
477+
access_token: string;
478+
refresh_token: string;
479+
token_type: string;
480+
user: { id: number; email: string; username: string; role: string };
481+
}> {
482+
return this._cloudPost("/auth/refresh", { refresh_token: refreshToken });
483+
}
484+
485+
/** 发送邮箱注册验证码 */
486+
async cloudSendEmailCode(email: string): Promise<{ ok: boolean; message: string; dev_code?: string }> {
487+
return this._cloudPost("/auth/send-code", { email });
488+
}
489+
475490
// ── 云端经验库 API ───────────────────────────────
476491

477492
/** 直接分享经验到社区(Bug 自动上传) */

extension/src/sidebarProvider.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
143143
try {
144144
const result = await this._client.cloudLogin(msg.emailOrUsername, msg.password);
145145
await this._context.secrets.store("testpilot.token", result.access_token);
146+
if (result.refresh_token) {
147+
await this._context.secrets.store("testpilot.refresh_token", result.refresh_token);
148+
}
146149
await this._context.secrets.store("testpilot.username", result.user.username);
147150
this._postMessage({
148151
command: "authResult",
@@ -162,6 +165,9 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
162165
try {
163166
const result = await this._client.cloudRegister(msg.email, msg.username, msg.password);
164167
await this._context.secrets.store("testpilot.token", result.access_token);
168+
if (result.refresh_token) {
169+
await this._context.secrets.store("testpilot.refresh_token", result.refresh_token);
170+
}
165171
await this._context.secrets.store("testpilot.username", result.user.username);
166172
this._postMessage({
167173
command: "authResult",
@@ -179,6 +185,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
179185

180186
private async _handleLogout(): Promise<void> {
181187
await this._context.secrets.delete("testpilot.token");
188+
await this._context.secrets.delete("testpilot.refresh_token");
182189
await this._context.secrets.delete("testpilot.username");
183190
this._postMessage({ command: "authResult", success: false, user: null });
184191
}
@@ -193,8 +200,21 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
193200
const user = await this._client.cloudGetMe(token);
194201
this._postMessage({ command: "authResult", success: true, user });
195202
} catch {
196-
// token 过期或无效,清除
203+
// Access Token 过期,尝试用 Refresh Token 自动续期
204+
const refreshToken = await this._context.secrets.get("testpilot.refresh_token");
205+
if (refreshToken) {
206+
try {
207+
const result = await this._client.cloudRefreshToken(refreshToken);
208+
await this._context.secrets.store("testpilot.token", result.access_token);
209+
await this._context.secrets.store("testpilot.refresh_token", result.refresh_token);
210+
this._postMessage({ command: "authResult", success: true, user: result.user });
211+
return;
212+
} catch {
213+
// Refresh Token 也失效,清除并要求重新登录
214+
}
215+
}
197216
await this._context.secrets.delete("testpilot.token");
217+
await this._context.secrets.delete("testpilot.refresh_token");
198218
await this._context.secrets.delete("testpilot.username");
199219
this._postMessage({ command: "authResult", success: false, user: null });
200220
}

fix_alembic_version.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import sqlite3
2+
conn = sqlite3.connect('data/testpilot.db')
3+
cur = conn.cursor()
4+
cur.execute("UPDATE alembic_version SET version_num='f03fbc681fc5'")
5+
conn.commit()
6+
print("version_num updated to f03fbc681fc5, rows:", cur.rowcount)
7+
conn.close()

src/auth/models.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,60 @@ class TeamMember(Base):
199199

200200
def __repr__(self) -> str:
201201
return f"<TeamMember team={self.team_id} user={self.user_id} role={self.role}>"
202+
203+
204+
# ── 安全增强(v14.0-E)──────────────────────────
205+
206+
class LoginAttempt(Base):
207+
"""登录失败记录(持久化锁定,替代内存字典)。"""
208+
__tablename__ = "login_attempts"
209+
210+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
211+
identifier: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
212+
attempted_at: Mapped[datetime] = mapped_column(
213+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
214+
)
215+
is_success: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
216+
217+
__table_args__ = (
218+
Index("ix_login_attempts_id_time", "identifier", "attempted_at"),
219+
)
220+
221+
def __repr__(self) -> str:
222+
return f"<LoginAttempt {self.identifier} at {self.attempted_at}>"
223+
224+
225+
class RefreshToken(Base):
226+
"""JWT Refresh Token(7天有效,刷新时自动轮换)。"""
227+
__tablename__ = "refresh_tokens"
228+
229+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
230+
user_id: Mapped[int] = mapped_column(
231+
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
232+
)
233+
token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
234+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
235+
is_revoked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
236+
created_at: Mapped[datetime] = mapped_column(
237+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
238+
)
239+
240+
def __repr__(self) -> str:
241+
return f"<RefreshToken user={self.user_id} revoked={self.is_revoked}>"
242+
243+
244+
class EmailVerification(Base):
245+
"""邮箱验证码(注册时验证真实邮箱)。"""
246+
__tablename__ = "email_verifications"
247+
248+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
249+
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
250+
code: Mapped[str] = mapped_column(String(10), nullable=False)
251+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
252+
is_used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
253+
created_at: Mapped[datetime] = mapped_column(
254+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
255+
)
256+
257+
def __repr__(self) -> str:
258+
return f"<EmailVerification {self.email}>"

src/auth/routes.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,24 @@ class RegisterRequest(BaseModel):
2020
email: str = Field(..., description="邮箱")
2121
username: str = Field(..., min_length=2, max_length=50)
2222
password: str = Field(..., min_length=6, max_length=100)
23+
email_code: str | None = Field(default=None, description="邮箱验证码(开启验证时必填)")
2324

2425
class LoginRequest(BaseModel):
2526
email_or_username: str = Field(..., description="邮箱或用户名")
2627
password: str = Field(...)
2728

2829
class TokenResponse(BaseModel):
2930
access_token: str
31+
refresh_token: str | None = None
3032
token_type: str = "bearer"
3133
user: dict
3234

35+
class RefreshRequest(BaseModel):
36+
refresh_token: str = Field(..., description="Refresh Token")
37+
38+
class SendCodeRequest(BaseModel):
39+
email: str = Field(..., description="接收验证码的邮箱")
40+
3341
class ProjectRequest(BaseModel):
3442
name: str = Field(..., min_length=1, max_length=200)
3543
description: str = Field(default="", max_length=1000)
@@ -69,17 +77,23 @@ def _project_dict(p) -> dict:
6977

7078
@router.post("/auth/register", tags=["认证"])
7179
async def register(req: RegisterRequest, db: Session = Depends(get_db)) -> TokenResponse:
80+
# 开启邮箱验证时必须校验验证码
81+
if service.REQUIRE_EMAIL_VERIFICATION:
82+
if not req.email_code:
83+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先获取邮箱验证码")
84+
if not service.verify_email_code(db, req.email, req.email_code):
85+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
7286
try:
7387
user = service.register_user(db, req.email, req.username, req.password)
7488
except ValueError as e:
7589
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
76-
token = service.create_access_token(user.id, user.username, user.role)
77-
return TokenResponse(access_token=token, user=_user_dict(user))
90+
access_token, refresh_token = service.create_token_pair(db, user)
91+
return TokenResponse(access_token=access_token, refresh_token=refresh_token, user=_user_dict(user))
7892

7993
@router.post("/auth/login", tags=["认证"])
8094
async def login(req: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
81-
# 锁定检查
82-
locked, remaining = service.is_account_locked(req.email_or_username)
95+
# 锁定检查(DB持久化)
96+
locked, remaining = service.is_account_locked(db, req.email_or_username)
8397
if locked:
8498
raise HTTPException(
8599
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
@@ -88,7 +102,7 @@ async def login(req: LoginRequest, db: Session = Depends(get_db)) -> TokenRespon
88102

89103
user = service.authenticate_user(db, req.email_or_username, req.password)
90104
if not user:
91-
failures = service.record_login_failure(req.email_or_username)
105+
failures = service.record_login_failure(db, req.email_or_username)
92106
remain_chances = max(0, service._MAX_FAILURES - failures)
93107
if remain_chances > 0:
94108
raise HTTPException(
@@ -101,15 +115,41 @@ async def login(req: LoginRequest, db: Session = Depends(get_db)) -> TokenRespon
101115
detail=f"连续失败次数过多,账号已锁定 {service._LOCK_SECONDS // 60} 分钟"
102116
)
103117

104-
service.clear_login_failures(req.email_or_username)
105-
token = service.create_access_token(user.id, user.username, user.role)
106-
return TokenResponse(access_token=token, user=_user_dict(user))
118+
service.clear_login_failures(db, req.email_or_username)
119+
access_token, refresh_token = service.create_token_pair(db, user)
120+
return TokenResponse(access_token=access_token, refresh_token=refresh_token, user=_user_dict(user))
107121

108122
@router.get("/auth/me", tags=["认证"])
109123
async def get_me(user: User = Depends(get_current_user)) -> dict:
110124
return _user_dict(user)
111125

112126

127+
@router.post("/auth/refresh", tags=["认证"])
128+
async def refresh_token_endpoint(req: RefreshRequest, db: Session = Depends(get_db)) -> TokenResponse:
129+
"""用 Refresh Token 换取新的 Access Token + 新 Refresh Token(轮换机制)。"""
130+
result = service.verify_and_rotate_refresh_token(db, req.refresh_token)
131+
if not result:
132+
raise HTTPException(
133+
status_code=status.HTTP_401_UNAUTHORIZED,
134+
detail="refresh_token 无效或已过期,请重新登录",
135+
)
136+
access_token, new_refresh, user = result
137+
return TokenResponse(access_token=access_token, refresh_token=new_refresh, user=_user_dict(user))
138+
139+
140+
@router.post("/auth/send-code", tags=["认证"])
141+
async def send_verification_code(req: SendCodeRequest, db: Session = Depends(get_db)) -> dict:
142+
"""向指定邮箱发送6位注册验证码(10分钟有效)。"""
143+
code = service.create_verification_code(db, req.email)
144+
sent = service.send_verification_email(req.email, code)
145+
if not sent:
146+
# SMTP 未配置时,开发模式下直接返回验证码(生产环境应删除此行)
147+
if not service.REQUIRE_EMAIL_VERIFICATION:
148+
return {"ok": True, "message": "开发模式:验证码已生成", "dev_code": code}
149+
raise HTTPException(status_code=503, detail="邮件服务未配置,请联系管理员")
150+
return {"ok": True, "message": f"验证码已发送至 {req.email}"}
151+
152+
113153
# ── 项目端点 ──
114154

115155
@router.post("/projects", tags=["项目"])

0 commit comments

Comments
 (0)