340 lines
12 KiB
Python
340 lines
12 KiB
Python
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)
|