Skip to content

Commit 027222a

Browse files
EstrellaXDclaude
andcommitted
fix: resolve WebAuthn passkey compatibility with py_webauthn 2.7.0
- Fix aaguid type (str not bytes) in registration verification - Fix missing credential_backup_eligible field (use credential_device_type) - Remove invalid credential_id param from verify_authentication_response - Fix origin detection to use browser Origin header for WebAuthn verification - Add async database engine support (aiosqlite) for passkey operations - Convert UserDatabase to async-compatible with sync/async session detection - Update Database class to support both sync and async context managers Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d2cfd9b commit 027222a

7 files changed

Lines changed: 121 additions & 73 deletions

File tree

backend/src/module/api/auth.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
@router.post("/login", response_model=dict)
2323
async def login(response: Response, form_data=Depends(OAuth2PasswordRequestForm)):
2424
user = User(username=form_data.username, password=form_data.password)
25-
resp = auth_user(user)
25+
resp = await auth_user(user)
2626
if resp.status:
2727
token = create_access_token(
2828
data={"sub": user.username}, expires_delta=timedelta(days=1)
@@ -58,7 +58,7 @@ async def logout(response: Response):
5858
@router.post("/update", response_model=dict, dependencies=[Depends(get_current_user)])
5959
async def update_user(user_data: UserUpdate, response: Response):
6060
old_user = active_user[0]
61-
if update_user_info(user_data, old_user):
61+
if await update_user_info(user_data, old_user):
6262
token = create_access_token(
6363
data={"sub": old_user}, expires_delta=timedelta(days=1)
6464
)

backend/src/module/api/passkey.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,22 +29,25 @@
2929
def _get_webauthn_from_request(request: Request):
3030
"""
3131
从请求中构造 WebAuthnService
32-
根据 Host header 动态确定 RP ID 和 origin
32+
优先使用浏览器的 Origin header(与 clientDataJSON 中的 origin 一致)
3333
"""
34-
host = request.headers.get("host", "localhost:7892")
35-
rp_id = host.split(":")[0] # 去掉端口
36-
37-
# 判断协议
38-
forwarded_proto = request.headers.get("x-forwarded-proto")
39-
if forwarded_proto:
40-
scheme = forwarded_proto
41-
else:
42-
scheme = request.url.scheme
43-
44-
if scheme == "https":
45-
origin = f"https://{host}"
46-
else:
47-
origin = f"http://{host}"
34+
from urllib.parse import urlparse
35+
36+
origin = request.headers.get("origin")
37+
if not origin:
38+
# Fallback: 从 Referer 或 Host 推断
39+
referer = request.headers.get("referer", "")
40+
if referer:
41+
parsed = urlparse(referer)
42+
origin = f"{parsed.scheme}://{parsed.netloc}"
43+
else:
44+
host = request.headers.get("host", "localhost:7892")
45+
forwarded_proto = request.headers.get("x-forwarded-proto")
46+
scheme = forwarded_proto if forwarded_proto else request.url.scheme
47+
origin = f"{scheme}://{host}"
48+
49+
parsed_origin = urlparse(origin)
50+
rp_id = parsed_origin.hostname or "localhost"
4851

4952
return get_webauthn_service(rp_id, "AutoBangumi", origin)
5053

backend/src/module/database/combine.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from module.models import Bangumi, Passkey, User
55

66
from .bangumi import BangumiDatabase
7-
from .engine import async_session_factory, engine as e
7+
from .engine import async_engine, async_session_factory, engine as e
88
from .passkey import PasskeyDatabase
99
from .rss import RSSDatabase
1010
from .torrent import TorrentDatabase
@@ -13,13 +13,28 @@
1313

1414
class Database:
1515
def __init__(self):
16-
self._session: AsyncSession | None = None
16+
self._session = None
1717
self.rss: RSSDatabase | None = None
1818
self.torrent: TorrentDatabase | None = None
1919
self.bangumi: BangumiDatabase | None = None
2020
self.user: UserDatabase | None = None
2121
self.passkey: PasskeyDatabase | None = None
2222

23+
# Sync context manager (for legacy code)
24+
def __enter__(self):
25+
from .engine import db_session
26+
27+
self._session = db_session
28+
self.rss = RSSDatabase(self._session)
29+
self.torrent = TorrentDatabase(self._session)
30+
self.bangumi = BangumiDatabase(self._session)
31+
self.user = UserDatabase(self._session)
32+
return self
33+
34+
def __exit__(self, exc_type, exc_val, exc_tb):
35+
pass
36+
37+
# Async context manager (for passkey and new async code)
2338
async def __aenter__(self):
2439
self._session = async_session_factory()
2540
self.rss = RSSDatabase(self._session)
@@ -30,25 +45,31 @@ async def __aenter__(self):
3045
return self
3146

3247
async def __aexit__(self, exc_type, exc_val, exc_tb):
33-
if self._session:
48+
if self._session and isinstance(self._session, AsyncSession):
3449
await self._session.close()
3550

3651
async def create_table(self):
37-
async with e.begin() as conn:
52+
async with async_engine.begin() as conn:
3853
await conn.run_sync(SQLModel.metadata.create_all)
3954

4055
async def drop_table(self):
41-
async with e.begin() as conn:
56+
async with async_engine.begin() as conn:
4257
await conn.run_sync(SQLModel.metadata.drop_all)
4358

4459
async def commit(self):
4560
if self._session:
46-
await self._session.commit()
61+
if isinstance(self._session, AsyncSession):
62+
await self._session.commit()
63+
else:
64+
self._session.commit()
4765

4866
async def add(self, obj):
4967
if self._session:
5068
self._session.add(obj)
51-
await self._session.commit()
69+
if isinstance(self._session, AsyncSession):
70+
await self._session.commit()
71+
else:
72+
self._session.commit()
5273

5374
async def migrate(self):
5475
# Run migration online
Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
2+
from sqlalchemy.orm import sessionmaker
13
from sqlmodel import Session, create_engine
24

35
from module.conf import DATA_PATH
46

7+
# Sync engine (for legacy code)
58
engine = create_engine(DATA_PATH)
6-
79
db_session = Session(engine)
10+
11+
# Async engine (for passkey and new async code)
12+
ASYNC_DATA_PATH = DATA_PATH.replace("sqlite:///", "sqlite+aiosqlite:///")
13+
async_engine = create_async_engine(ASYNC_DATA_PATH)
14+
async_session_factory = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)

backend/src/module/database/user.py

Lines changed: 55 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import logging
22

33
from fastapi import HTTPException
4-
from sqlmodel import Session, select
4+
from sqlalchemy.ext.asyncio import AsyncSession
5+
from sqlmodel import select
56

67
from module.models import ResponseModel
78
from module.models.user import User, UserLogin, UserUpdate
@@ -11,28 +12,36 @@
1112

1213

1314
class UserDatabase:
14-
def __init__(self, session: Session):
15+
def __init__(self, session):
1516
self.session = session
1617

17-
def get_user(self, username):
18+
async def get_user(self, username):
1819
statement = select(User).where(User.username == username)
19-
result = self.session.exec(statement).first()
20-
if not result:
20+
if isinstance(self.session, AsyncSession):
21+
result = await self.session.execute(statement)
22+
user = result.scalar_one_or_none()
23+
else:
24+
user = self.session.exec(statement).first()
25+
if not user:
2126
raise HTTPException(status_code=404, detail="User not found")
22-
return result
27+
return user
2328

24-
def auth_user(self, user: User):
29+
async def auth_user(self, user: User):
2530
statement = select(User).where(User.username == user.username)
26-
result = self.session.exec(statement).first()
31+
if isinstance(self.session, AsyncSession):
32+
result = await self.session.execute(statement)
33+
db_user = result.scalar_one_or_none()
34+
else:
35+
db_user = self.session.exec(statement).first()
2736
if not user.password:
2837
return ResponseModel(
2938
status_code=401, status=False, msg_en="Incorrect password format", msg_zh="密码格式不正确"
3039
)
31-
if not result:
40+
if not db_user:
3241
return ResponseModel(
3342
status_code=401, status=False, msg_en="User not found", msg_zh="用户不存在"
3443
)
35-
if not verify_password(user.password, result.password):
44+
if not verify_password(user.password, db_user.password):
3645
return ResponseModel(
3746
status_code=401,
3847
status=False,
@@ -43,36 +52,59 @@ def auth_user(self, user: User):
4352
status_code=200, status=True, msg_en="Login successfully", msg_zh="登录成功"
4453
)
4554

46-
def update_user(self, username, update_user: UserUpdate):
47-
# Update username and password
55+
async def update_user(self, username, update_user: UserUpdate):
4856
statement = select(User).where(User.username == username)
49-
result = self.session.exec(statement).first()
50-
if not result:
57+
if isinstance(self.session, AsyncSession):
58+
result = await self.session.execute(statement)
59+
db_user = result.scalar_one_or_none()
60+
else:
61+
db_user = self.session.exec(statement).first()
62+
if not db_user:
5163
raise HTTPException(status_code=404, detail="User not found")
5264
if update_user.username:
53-
result.username = update_user.username
65+
db_user.username = update_user.username
5466
if update_user.password:
55-
result.password = get_password_hash(update_user.password)
56-
self.session.add(result)
57-
self.session.commit()
58-
return result
67+
db_user.password = get_password_hash(update_user.password)
68+
self.session.add(db_user)
69+
if isinstance(self.session, AsyncSession):
70+
await self.session.commit()
71+
else:
72+
self.session.commit()
73+
return db_user
74+
75+
async def add_default_user(self):
76+
statement = select(User)
77+
if isinstance(self.session, AsyncSession):
78+
result = await self.session.execute(statement)
79+
users = list(result.scalars().all())
80+
else:
81+
try:
82+
users = self.session.exec(statement).all()
83+
except Exception:
84+
self.merge_old_user()
85+
users = self.session.exec(statement).all()
86+
if len(users) != 0:
87+
return
88+
user = User(username="admin", password=get_password_hash("adminadmin"))
89+
self.session.add(user)
90+
if isinstance(self.session, AsyncSession):
91+
await self.session.commit()
92+
else:
93+
self.session.commit()
5994

6095
def merge_old_user(self):
61-
# get old data
96+
# Legacy migration - sync only
6297
statement = """
6398
SELECT * FROM user
6499
"""
65100
result = self.session.exec(statement).first()
66101
if not result:
67102
return
68-
# add new data
69103
user = User(username=result.username, password=result.password)
70-
# Drop old table
71104
statement = """
72105
DROP TABLE user
73106
"""
74107
self.session.exec(statement)
75-
# Create new table
76108
statement = """
77109
CREATE TABLE user (
78110
id INTEGER NOT NULL PRIMARY KEY,
@@ -83,18 +115,3 @@ def merge_old_user(self):
83115
self.session.exec(statement)
84116
self.session.add(user)
85117
self.session.commit()
86-
87-
def add_default_user(self):
88-
# Check if user exists
89-
statement = select(User)
90-
try:
91-
result = self.session.exec(statement).all()
92-
except Exception:
93-
self.merge_old_user()
94-
result = self.session.exec(statement).all()
95-
if len(result) != 0:
96-
return
97-
# Add default user
98-
user = User(username="admin", password=get_password_hash("adminadmin"))
99-
self.session.add(user)
100-
self.session.commit()

backend/src/module/security/api.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,18 @@ async def get_token_data(token: str = Depends(oauth2_scheme)):
3434
return payload
3535

3636

37-
def update_user_info(user_data: UserUpdate, current_user):
37+
async def update_user_info(user_data: UserUpdate, current_user):
3838
try:
39-
with Database() as db:
40-
db.user.update_user(current_user, user_data)
39+
async with Database() as db:
40+
await db.user.update_user(current_user, user_data)
4141
return True
4242
except Exception as e:
4343
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
4444

4545

46-
def auth_user(user: User):
47-
with Database() as db:
48-
resp = db.user.auth_user(user)
46+
async def auth_user(user: User):
47+
async with Database() as db:
48+
resp = await db.user.auth_user(user)
4949
if resp.status:
5050
active_user.append(user.username)
5151
return resp

backend/src/module/security/webauthn.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from webauthn.helpers.structs import (
1919
AuthenticatorSelectionCriteria,
2020
AuthenticatorTransport,
21+
CredentialDeviceType,
2122
PublicKeyCredentialDescriptor,
2223
PublicKeyCredentialType,
2324
ResidentKeyRequirement,
@@ -135,8 +136,9 @@ def verify_registration(
135136
"utf-8"
136137
),
137138
sign_count=verification.sign_count,
138-
aaguid=verification.aaguid.hex() if verification.aaguid else None,
139-
backup_eligible=verification.credential_backup_eligible,
139+
aaguid=verification.aaguid if verification.aaguid else None,
140+
backup_eligible=verification.credential_device_type
141+
== CredentialDeviceType.MULTI_DEVICE,
140142
backup_state=verification.credential_backed_up,
141143
)
142144

@@ -214,7 +216,6 @@ def verify_authentication(
214216
try:
215217
# 解码 public key
216218
credential_public_key = base64.b64decode(passkey.public_key)
217-
credential_id = self.base64url_decode(passkey.credential_id)
218219

219220
verification = verify_authentication_response(
220221
credential=credential,
@@ -223,7 +224,6 @@ def verify_authentication(
223224
expected_origin=self.origin,
224225
credential_public_key=credential_public_key,
225226
credential_current_sign_count=passkey.sign_count,
226-
credential_id=credential_id,
227227
)
228228

229229
logger.info(f"Successfully verified authentication for {username}")

0 commit comments

Comments
 (0)