diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index b95a0d2..ac0cf85 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -35,7 +35,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): return RegisterResponse(message="注册请求已提交,等待管理员审批") -@router.post("/login", response_model=TokenResponse) +@router.post("/login") async def login( req: LoginRequest, request: Request, @@ -81,6 +81,17 @@ async def login( if user.status == "disabled": raise HTTPException(status_code=403, detail="账户已被禁用") clear_login_failures(ip, account) + + # 检查是否启用 2FA + if user.two_factor_enabled and user.two_factor_secret: + # 生成临时 token,等待 2FA 验证 + from app.core.security import create_token + temp_token = create_token( + {"sub": str(user.id), "type": "2fa_pending"}, + timedelta(minutes=5) + ) + return {"require_2fa": True, "temp_token": temp_token} + return TokenResponse( access_token=create_access_token(user.id), refresh_token=create_refresh_token(user.id), diff --git a/backend/app/api/twofa.py b/backend/app/api/twofa.py new file mode 100644 index 0000000..92829c4 --- /dev/null +++ b/backend/app/api/twofa.py @@ -0,0 +1,153 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel +import pyotp +import qrcode +import io +import base64 +from app.core.database import get_db +from app.core.deps import get_current_user +from app.models.models import User + +router = APIRouter(prefix="/auth/2fa", tags=["双因素认证"]) + + +class TwoFASetupResponse(BaseModel): + secret: str + qr_code: str # base64 encoded QR code image + otpauth_url: str + + +class TwoFAVerifyRequest(BaseModel): + code: str + + +class TwoFALoginRequest(BaseModel): + temp_token: str + code: str + + +@router.post("/setup", response_model=TwoFASetupResponse) +async def setup_2fa( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """生成 2FA 密钥和二维码""" + secret = pyotp.random_base32() + + totp = pyotp.TOTP(secret) + otpauth_url = totp.provisioning_uri( + name=user.email, + issuer_name="债务管理系统" + ) + + # 生成 QR 码 + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(otpauth_url) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + + # 转换为 base64 + buffer = io.BytesIO() + img.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode() + + # 临时存储 secret(验证后才会真正保存) + user.two_factor_secret = secret + await db.commit() + + return TwoFASetupResponse( + secret=secret, + qr_code=f"data:image/png;base64,{qr_base64}", + otpauth_url=otpauth_url + ) + + +@router.post("/verify") +async def verify_2fa_setup( + req: TwoFAVerifyRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """验证 2FA 设置(首次验证后启用)""" + if not user.two_factor_secret: + raise HTTPException(status_code=400, detail="请先执行 2FA 设置") + + totp = pyotp.TOTP(user.two_factor_secret) + if not totp.verify(req.code, valid_window=1): + raise HTTPException(status_code=400, detail="验证码错误") + + # 启用 2FA + user.two_factor_enabled = 1 + await db.commit() + + return {"message": "双因素认证已启用"} + + +@router.post("/disable") +async def disable_2fa( + req: TwoFAVerifyRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """禁用 2FA(需要验证当前验证码)""" + if not user.two_factor_enabled: + raise HTTPException(status_code=400, detail="未启用双因素认证") + + if not user.two_factor_secret: + raise HTTPException(status_code=400, detail="2FA 配置异常") + + totp = pyotp.TOTP(user.two_factor_secret) + if not totp.verify(req.code, valid_window=1): + raise HTTPException(status_code=400, detail="验证码错误") + + user.two_factor_secret = None + user.two_factor_enabled = 0 + await db.commit() + + return {"message": "双因素认证已禁用"} + + +@router.post("/verify-login") +async def verify_2fa_login( + req: TwoFALoginRequest, + db: AsyncSession = Depends(get_db), +): + """验证 2FA 登录(密码验证后,2FA 验证前)""" + from app.core.security import decode_token, create_access_token, create_refresh_token + + # 验证临时 token + payload = decode_token(req.temp_token) + if not payload or payload.get("type") != "2fa_pending": + raise HTTPException(status_code=401, detail="无效的验证请求") + + user_id = int(payload["sub"]) + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user or not user.two_factor_secret: + raise HTTPException(status_code=401, detail="用户状态异常") + + # 验证 2FA + totp = pyotp.TOTP(user.two_factor_secret) + if not totp.verify(req.code, valid_window=1): + raise HTTPException(status_code=401, detail="验证码错误") + + # 返回正式 token + return { + "access_token": create_access_token(user.id), + "refresh_token": create_refresh_token(user.id), + "token_type": "bearer" + } + + +@router.get("/status") +async def get_2fa_status( + user: User = Depends(get_current_user), +): + """获取 2FA 状态""" + return { + "enabled": bool(user.two_factor_enabled), + "has_secret": bool(user.two_factor_secret) + } diff --git a/backend/app/main.py b/backend/app/main.py index e24a4a3..4fa0e93 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from app.core.config import get_settings from app.core.database import engine, async_session from app.models.models import Base, User, Debt, RepaymentPlan, RepaymentRecord, Group from app.core.security import hash_password -from app.api import auth, debts, repayments, admin, loans, notifications +from app.api import auth, debts, repayments, admin, loans, notifications, twofa from sqlalchemy import select, text settings = get_settings() @@ -52,10 +52,23 @@ async def migrate_account_names(): async def migrate_add_real_email(): + async with engine.begin() as conn: + try: + await conn.execute(text( + "ALTER TABLE users ADD COLUMN IF NOT EXISTS real_email VARCHAR(255) DEFAULT ''" + )) + except Exception: + pass # 表不存在时跳过,由 seed_admin 创建 + +async def migrate_add_2fa(): async with engine.begin() as conn: await conn.execute(text( - "ALTER TABLE users ADD COLUMN IF NOT EXISTS real_email VARCHAR(255) DEFAULT ''" + "ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_secret VARCHAR(32)" )) + await conn.execute(text( + "ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_enabled INTEGER DEFAULT 0" + )) + async def migrate_debt_data(): @@ -98,6 +111,7 @@ async def migrate_debt_data(): async def lifespan(app: FastAPI): await seed_admin() await migrate_add_real_email() + await migrate_add_2fa() await migrate_account_names() await migrate_debt_data() from app.services.scheduler import start_scheduler, check_upcoming_repayments @@ -139,6 +153,7 @@ app.include_router(debts.router, prefix=settings.API_V1_PREFIX) app.include_router(repayments.router, prefix=settings.API_V1_PREFIX) app.include_router(admin.router, prefix=settings.API_V1_PREFIX) app.include_router(notifications.router, prefix=settings.API_V1_PREFIX) +app.include_router(twofa.router, prefix=settings.API_V1_PREFIX) @app.get("/health") diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 0752f30..a98f821 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -73,6 +73,8 @@ class User(Base): status = Column(String(20), default=UserStatus.ACTIVE.value) group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) created_at = Column(DateTime, default=lambda: datetime.utcnow()) + two_factor_secret = Column(String(32), nullable=True) + two_factor_enabled = Column(Integer, default=0) group = relationship("Group", back_populates="members") debts = relationship("Debt", back_populates="user") diff --git a/backend/requirements.txt b/backend/requirements.txt index 9e0284a..2d73530 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,3 +14,6 @@ httpx==0.26.0 python-dateutil==2.8.2 apscheduler==3.10.4 aiosmtplib==3.0.1 +pyotp==2.9.0 +qrcode==7.4.2 +pillow==10.2.0 diff --git a/frontend/index.html b/frontend/index.html index 1ee1349..e3f6d28 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -361,6 +361,17 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text" .auth-card h2{font-size:18px;margin-bottom:20px} } + +/* 2FA */ +.twofa-section{margin-top:16px;padding-top:16px;border-top:1px solid var(--border)} +.twofa-status{display:flex;align-items:center;gap:8px;margin-bottom:12px} +.twofa-badge{padding:4px 10px;border-radius:12px;font-size:11px;font-weight:600} +.twofa-badge.enabled{background:rgba(52,199,89,.1);color:var(--green)} +.twofa-badge.disabled{background:rgba(0,0,0,.04);color:var(--text3)} +.twofa-qr{display:flex;justify-content:center;margin:16px 0} +.twofa-qr img{border:1px solid var(--border);border-radius:8px} +.twofa-input{display:flex;gap:8px;align-items:center} +.twofa-input input{flex:1} @@ -539,7 +550,7 @@ let openGroups=new Set(); let editId=null; let hidePaidItems=true; let hidePaidMonths=true; -let hideOthers=true; +let hideOthers=false; let quickPayLoanId=null; let actionLoanId=null; @@ -592,6 +603,7 @@ function showProfile(){ $('profileError').style.display='none'; $('profileSuccess').style.display='none'; $('profileModal').classList.add('show'); + loadTwofaStatus(); } function closeProfile(){$('profileModal').classList.remove('show');} @@ -641,6 +653,158 @@ async function saveProfile(){ setTimeout(()=>{closeProfile();},1000); } + +// 2FA 相关函数 +let twofaTempToken = ''; + +function showTwofaModal(tempToken) { + twofaTempToken = tempToken; + $('twofaCode').value = ''; + $('twofaError').style.display = 'none'; + $('twofaModal').classList.add('show'); +} + +function closeTwofaModal() { + $('twofaModal').classList.remove('show'); + twofaTempToken = ''; +} + +async function verifyTwofaLogin() { + const code = $('twofaCode').value.trim(); + if (!code || code.length !== 6) { + $('twofaError').textContent = '请输入 6 位验证码'; + $('twofaError').style.display = ''; + return; + } + + try { + const res = await fetch(API + '/auth/2fa/verify-login', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({temp_token: twofaTempToken, code}) + }); + + if (res.ok) { + const data = await res.json(); + token = data.access_token; + refreshToken = data.refresh_token; + localStorage.setItem('access_token', token); + localStorage.setItem('refresh_token', refreshToken); + closeTwofaModal(); + await checkAuth(); + await loadLoans(); + } else { + const data = await res.json().catch(() => ({})); + $('twofaError').textContent = data.detail || '验证失败'; + $('twofaError').style.display = ''; + } + } catch(e) { + $('twofaError').textContent = '网络异常'; + $('twofaError').style.display = ''; + } +} + +// 修改 doLogin 处理 2FA + +// 2FA 设置相关函数 +async function loadTwofaStatus() { + const res = await apiFetch('/auth/2fa/status'); + if (res && res.ok) { + const data = await res.json(); + const statusEl = document.getElementById('twofaStatus'); + if (statusEl) { + if (data.enabled) { + statusEl.innerHTML = '已启用'; + } else { + statusEl.innerHTML = '未启用'; + } + } + } +} + +let twofaSetupSecret = ''; +let twofaSetupUrl = ''; + +async function startSetupTwofa() { + const res = await apiFetch('/auth/2fa/setup', {method: 'POST'}); + if (res && res.ok) { + const data = await res.json(); + twofaSetupSecret = data.secret; + twofaSetupUrl = data.otpauth_url; + + const qrSection = document.getElementById('twofaSetupSection'); + const qrImg = document.getElementById('twofaQrCode'); + const secretText = document.getElementById('twofaSecret'); + + if (qrImg) qrImg.src = data.qr_code; + if (secretText) secretText.textContent = data.secret; + if (qrSection) qrSection.style.display = ''; + } +} + +async function verifyTwofaSetup() { + const code = $('twofaSetupCode').value.trim(); + if (!code || code.length !== 6) { + alert('请输入 6 位验证码'); + return; + } + + const res = await apiFetch('/auth/2fa/verify', { + method: 'POST', + body: JSON.stringify({code}) + }); + + if (res && res.ok) { + alert('双因素认证已启用'); + $('twofaSetupSection').style.display = 'none'; + loadTwofaStatus(); + } else { + const data = await res.json().catch(() => ({})); + alert(data.detail || '验证失败'); + } +} + +async function showDisableTwofa() { + const code = prompt('请输入当前验证码以禁用 2FA:'); + if (!code) return; + + const res = await apiFetch('/auth/2fa/disable', { + method: 'POST', + body: JSON.stringify({code}) + }); + + if (res && res.ok) { + alert('双因素认证已禁用'); + loadTwofaStatus(); + } else { + const data = await res.json().catch(() => ({})); + alert(data.detail || '禁用失败'); + } +} + +async function doLogin(){ + const account=$('loginEmail').value.trim(),pwd=$('loginPwd').value; + if(!account||!pwd)return showLoginError('请输入账号和密码'); + hideLoginError(); + try{ + const res=await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account,password:pwd})}); + const data=await res.json(); + if(!res.ok){ + if(res.status===429&&data.retry_after){startLoginCountdown(data.retry_after);} + else{showLoginError(data.detail||'登录失败');} + return; + } + // 检查是否需要 2FA + if(data.require_2fa){ + showTwofaModal(data.temp_token); + return; + } + token=data.access_token;refreshToken=data.refresh_token; + localStorage.setItem('access_token',token);localStorage.setItem('refresh_token',refreshToken); + await checkAuth();await loadLoans(); + }catch(e){showLoginError('网络连接异常,请稍后重试');} +} + async function checkAuth(){ if(!token){showLogin();return false;} const res=await apiFetch('/auth/profile'); @@ -674,12 +838,7 @@ function startLoginCountdown(seconds){ tick(); loginCountdown=setInterval(tick,1000); } -async function doLogin(){ - const account=$('loginEmail').value.trim(),pwd=$('loginPwd').value; - if(!account||!pwd)return showLoginError('请输入账号和密码'); - hideLoginError(); - try{const res=await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account,password:pwd})});const data=await res.json();if(!res.ok){if(res.status===429&&data.retry_after){startLoginCountdown(data.retry_after);}else{showLoginError(data.detail||'登录失败');}return;}token=data.access_token;refreshToken=data.refresh_token;localStorage.setItem('access_token',token);localStorage.setItem('refresh_token',refreshToken);await checkAuth();await loadLoans();}catch(e){showLoginError('网络连接异常,请稍后重试');} -} + async function doRegister(){ const account=$('regEmail').value.trim(),nick=$('regNick').value.trim(),pwd=$('regPwd').value,pwd2=$('regPwd2').value; if(!account||!pwd)return $('regError').textContent='请填写账号和密码'; @@ -852,7 +1011,34 @@ function exportHTML(e){e.preventDefault();const vl=getVisibleLoans();if(!vl.leng .auth-card h2{font-size:18px;margin-bottom:20px} } + +/* 2FA */ +.twofa-section{margin-top:16px;padding-top:16px;border-top:1px solid var(--border)} +.twofa-status{display:flex;align-items:center;gap:8px;margin-bottom:12px} +.twofa-badge{padding:4px 10px;border-radius:12px;font-size:11px;font-weight:600} +.twofa-badge.enabled{background:rgba(52,199,89,.1);color:var(--green)} +.twofa-badge.disabled{background:rgba(0,0,0,.04);color:var(--text3)} +.twofa-qr{display:flex;justify-content:center;margin:16px 0} +.twofa-qr img{border:1px solid var(--border);border-radius:8px} +.twofa-input{display:flex;gap:8px;align-items:center} +.twofa-input input{flex:1}

债务统计报表 - ${today()}

`;vl.forEach(l=>{const ti=l.schedule.reduce((s,r)=>s+r.it,0);const st=l.paid.length>=l.periods;h+=`

${escapeHtml(l.name)} - ${l.date} - ¥${fmt(l.amount)}${st?' 已还清':''}

年化率 ${l.rate}% · ${methodLabel(l.method)} · ${l.periods}期 · 总利息 ¥${ti.toFixed(2)}

`;l.schedule.forEach(r=>{h+=``;});h+=`
期数日期还款额本金利息剩余本金状态用途
${r.p}${r.date}¥${fmt(r.pay)}¥${fmt(r.pr)}¥${fmt(r.it)}¥${fmt(r.rp)}${l.paid.includes(r.p)?'✓ 已还':'待还'}
`;});h+=` + + +