霖雨寺
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request, Response, status
|
||||
|
||||
from app.config import auth_settings
|
||||
from app.database import Database
|
||||
from app.schemas import AuthUser, CaptchaResponse, LoginRequest, LoginResponse
|
||||
from app.security import create_token, hash_secret, verify_password
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
CAPTCHA_TTL_SECONDS = 300
|
||||
SESSION_COOKIE_NAME = "admin_session"
|
||||
SESSION_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||
CAPTCHA_FAILURE_THRESHOLD = 5
|
||||
LOCK_FAILURE_THRESHOLD = 10
|
||||
FAILURE_WINDOW_MINUTES = 15
|
||||
LOCK_MINUTES = 15
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.utcnow().replace(microsecond=0)
|
||||
|
||||
|
||||
def to_db_time(value: datetime) -> str:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def from_db_time(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
forwarded_for = request.headers.get("x-forwarded-for")
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def get_user_agent(request: Request) -> str:
|
||||
return request.headers.get("user-agent", "")[:300]
|
||||
|
||||
|
||||
def is_local_request(request: Request) -> bool:
|
||||
host = request.url.hostname or ""
|
||||
return host in {"127.0.0.1", "localhost", "::1"}
|
||||
|
||||
|
||||
def ensure_https(request: Request) -> None:
|
||||
if not auth_settings.require_https:
|
||||
return
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto", "")
|
||||
is_https = request.url.scheme == "https" or forwarded_proto.lower() == "https"
|
||||
if is_https:
|
||||
return
|
||||
if auth_settings.allow_insecure_local and is_local_request(request):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="登录必须通过 HTTPS 连接,请使用安全地址后重试",
|
||||
)
|
||||
|
||||
|
||||
def create_auth_user(row) -> AuthUser:
|
||||
return AuthUser(
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
name=row["name"],
|
||||
is_admin=bool(row["is_admin"]),
|
||||
)
|
||||
|
||||
|
||||
def count_recent_failures(db: Database, username: str, ip_address: str) -> int:
|
||||
since = to_db_time(utc_now() - timedelta(minutes=FAILURE_WINDOW_MINUTES))
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM login_attempts
|
||||
WHERE success = 0
|
||||
AND created_at >= ?
|
||||
AND (lower(username) = ? OR ip_address = ?)
|
||||
""",
|
||||
(since, username, ip_address),
|
||||
).fetchone()
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def record_login_attempt(
|
||||
db: Database,
|
||||
username: str,
|
||||
ip_address: str,
|
||||
user_agent: str,
|
||||
success: bool,
|
||||
failure_reason: str | None = None,
|
||||
) -> None:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO login_attempts (username, ip_address, user_agent, success, failure_reason)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(username, ip_address, user_agent, int(success), failure_reason),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def detect_new_login_context(db: Database, user_id: int, ip_address: str, user_agent: str) -> tuple[bool, bool]:
|
||||
old_ip = db.execute(
|
||||
"""
|
||||
SELECT 1 FROM login_events
|
||||
WHERE user_id = ? AND ip_address = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id, ip_address),
|
||||
).fetchone()
|
||||
old_device = db.execute(
|
||||
"""
|
||||
SELECT 1 FROM login_events
|
||||
WHERE user_id = ? AND user_agent = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id, user_agent),
|
||||
).fetchone()
|
||||
had_login = db.execute("SELECT 1 FROM login_events WHERE user_id = ? LIMIT 1", (user_id,)).fetchone()
|
||||
return had_login is not None and old_ip is None, had_login is not None and old_device is None
|
||||
|
||||
|
||||
def record_login_event(db: Database, user_id: int, ip_address: str, user_agent: str, is_new_ip: bool, is_new_device: bool) -> None:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO login_events (user_id, ip_address, user_agent, is_new_ip, is_new_device)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(user_id, ip_address, user_agent, int(is_new_ip), int(is_new_device)),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=SESSION_TTL_SECONDS,
|
||||
httponly=True,
|
||||
secure=auth_settings.cookie_secure,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(key=SESSION_COOKIE_NAME, path="/")
|
||||
|
||||
|
||||
@router.get("/captcha", response_model=CaptchaResponse)
|
||||
def get_captcha(db: Database) -> CaptchaResponse:
|
||||
left = secrets.randbelow(8) + 2
|
||||
right = secrets.randbelow(8) + 2
|
||||
answer = str(left + right)
|
||||
captcha_id = uuid4().hex
|
||||
expires_at = utc_now() + timedelta(seconds=CAPTCHA_TTL_SECONDS)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO login_captchas (id, answer_hash, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(captcha_id, hash_secret(answer.lower()), to_db_time(expires_at)),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return CaptchaResponse(
|
||||
captcha_id=captcha_id,
|
||||
challenge=f"{left} + {right} = ?",
|
||||
expires_in=CAPTCHA_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def verify_captcha(db: Database, captcha_id: str | None, captcha_code: str | None) -> None:
|
||||
if not captcha_id or not captcha_code:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请输入验证码后再登录")
|
||||
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT id, answer_hash, expires_at, used_at
|
||||
FROM login_captchas
|
||||
WHERE id = ?
|
||||
""",
|
||||
(captcha_id,),
|
||||
).fetchone()
|
||||
if row is None or row["used_at"]:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码已失效,请刷新后重试")
|
||||
|
||||
expires_at = from_db_time(row["expires_at"])
|
||||
if expires_at is None or utc_now() > expires_at:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码已过期,请刷新后重试")
|
||||
|
||||
db.execute("UPDATE login_captchas SET used_at = ? WHERE id = ?", (to_db_time(utc_now()), captcha_id))
|
||||
db.commit()
|
||||
|
||||
if hash_secret(captcha_code.strip().lower()) != row["answer_hash"]:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误,请重新输入")
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(payload: LoginRequest, request: Request, response: Response, db: Database) -> LoginResponse:
|
||||
ensure_https(request)
|
||||
|
||||
username = payload.username.strip().lower()
|
||||
ip_address = get_client_ip(request)
|
||||
user_agent = get_user_agent(request)
|
||||
recent_failures = count_recent_failures(db, username, ip_address)
|
||||
|
||||
try:
|
||||
verify_captcha(db, payload.captcha_id, payload.captcha_code)
|
||||
except HTTPException as exc:
|
||||
record_login_attempt(db, username, ip_address, user_agent, False, "captcha_error")
|
||||
raise exc
|
||||
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT id, username, name, is_admin, password_hash, failed_login_count, locked_until
|
||||
FROM users
|
||||
WHERE lower(username) = ?
|
||||
""",
|
||||
(username,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
record_login_attempt(db, username, ip_address, user_agent, False, "unknown_user")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在,请检查用户名")
|
||||
|
||||
locked_until = from_db_time(row["locked_until"])
|
||||
if locked_until is not None and utc_now() < locked_until:
|
||||
record_login_attempt(db, username, ip_address, user_agent, False, "locked")
|
||||
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail="账号已临时锁定,请 15 分钟后再试")
|
||||
|
||||
password_hash = row["password_hash"]
|
||||
if not password_hash:
|
||||
record_login_attempt(db, username, ip_address, user_agent, False, "password_missing")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号尚未设置密码,请联系管理员")
|
||||
|
||||
if not verify_password(payload.password, password_hash):
|
||||
failure_count = int(row["failed_login_count"] or 0) + 1
|
||||
total_failures = max(failure_count, recent_failures + 1)
|
||||
locked_value = None
|
||||
message = "密码错误,请重新输入"
|
||||
if total_failures >= LOCK_FAILURE_THRESHOLD:
|
||||
locked_value = to_db_time(utc_now() + timedelta(minutes=LOCK_MINUTES))
|
||||
message = "密码连续错误次数过多,账号已临时锁定 15 分钟"
|
||||
elif total_failures >= CAPTCHA_FAILURE_THRESHOLD:
|
||||
message = "密码错误,请输入验证码后重试"
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET failed_login_count = ?, locked_until = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(failure_count, locked_value, row["id"]),
|
||||
)
|
||||
db.commit()
|
||||
record_login_attempt(db, username, ip_address, user_agent, False, "wrong_password")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=message)
|
||||
|
||||
token = create_token()
|
||||
expires_at = utc_now() + timedelta(seconds=SESSION_TTL_SECONDS)
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO auth_sessions (token_hash, user_id, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(hash_secret(token), row["id"], to_db_time(expires_at)),
|
||||
)
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET failed_login_count = 0, locked_until = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(row["id"],),
|
||||
)
|
||||
db.commit()
|
||||
set_session_cookie(response, token)
|
||||
record_login_attempt(db, username, ip_address, user_agent, True)
|
||||
|
||||
is_new_ip, is_new_device = detect_new_login_context(db, row["id"], ip_address, user_agent)
|
||||
record_login_event(db, row["id"], ip_address, user_agent, is_new_ip, is_new_device)
|
||||
requires_second_verification = is_new_ip or is_new_device
|
||||
security_notice = None
|
||||
if requires_second_verification:
|
||||
security_notice = "检测到新 IP 或新设备登录,后续应接入短信/邮箱二次验证与通知"
|
||||
|
||||
return LoginResponse(
|
||||
expires_in=SESSION_TTL_SECONDS,
|
||||
user=create_auth_user(row),
|
||||
requires_second_verification=requires_second_verification,
|
||||
security_notice=security_notice,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=AuthUser)
|
||||
def get_current_user(db: Database, admin_session: str | None = Cookie(default=None)) -> AuthUser:
|
||||
if not admin_session:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录")
|
||||
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT users.id, users.username, users.name, users.is_admin, auth_sessions.expires_at, auth_sessions.revoked_at
|
||||
FROM auth_sessions
|
||||
JOIN users ON users.id = auth_sessions.user_id
|
||||
WHERE auth_sessions.token_hash = ?
|
||||
""",
|
||||
(hash_secret(admin_session),),
|
||||
).fetchone()
|
||||
if row is None or row["revoked_at"]:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效,请重新登录")
|
||||
|
||||
expires_at = from_db_time(row["expires_at"])
|
||||
if expires_at is None or utc_now() > expires_at:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录")
|
||||
|
||||
return create_auth_user(row)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def logout(response: Response, db: Database, admin_session: str | None = Cookie(default=None)) -> None:
|
||||
if admin_session:
|
||||
db.execute(
|
||||
"UPDATE auth_sessions SET revoked_at = ? WHERE token_hash = ?",
|
||||
(to_db_time(utc_now()), hash_secret(admin_session)),
|
||||
)
|
||||
db.commit()
|
||||
clear_session_cookie(response)
|
||||
Reference in New Issue
Block a user