Add passkey (WebAuthn) authentication with self-registration
Enable users to sign up and sign in using passkeys (biometrics/security keys) without needing a manually-created Authentik account. The existing SSO login remains as an alternative. Backend: - Add WebAuthn registration/authentication endpoints via py-webauthn - Issue HS256 JWTs for passkey users, with Redis-backed challenge storage - Dual JWT verification in auth middleware (issuer-based routing: passkey HS256 vs Authentik RS256) - PasskeyCredential model + migration making user.password nullable - UserRepository with full CRUD for users and credentials Frontend: - AuthUser type abstraction unifying OIDC and passkey users - Passkey service using @simplewebauthn/browser for WebAuthn ceremonies - LoginModal redesigned with Sign In / Sign Up tabs - Type migration from oidc-client-ts User to AuthUser across all services and components
This commit is contained in:
parent
95c0ddc4c6
commit
a8b7eace48
26 changed files with 1229 additions and 129 deletions
|
|
@ -1,3 +1,4 @@
|
|||
from models.passkey_credential import PasskeyCredential
|
||||
from models.user import User
|
||||
from sqlalchemy import Engine
|
||||
from sqlmodel import Session, select
|
||||
|
|
@ -6,12 +7,56 @@ from sqlmodel import Session, select
|
|||
class UserRepository:
|
||||
engine: Engine
|
||||
|
||||
def __init__(self, engine: Engine):
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self.engine = engine
|
||||
|
||||
async def get_user_from_token(self, token: str) -> User | None:
|
||||
raise NotImplementedError()
|
||||
query = select(User)
|
||||
|
||||
def get_user_by_email(self, email: str) -> User | None:
|
||||
with Session(self.engine) as session:
|
||||
return session.exec(query).first()
|
||||
statement = select(User).where(User.email == email)
|
||||
return session.exec(statement).first()
|
||||
|
||||
def get_user_by_id(self, user_id: int) -> User | None:
|
||||
with Session(self.engine) as session:
|
||||
return session.get(User, user_id)
|
||||
|
||||
def create_user(self, email: str) -> User:
|
||||
with Session(self.engine) as session:
|
||||
user = User(email=email) # type: ignore[call-arg]
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
def get_credentials_for_user(self, user_id: int) -> list[PasskeyCredential]:
|
||||
with Session(self.engine) as session:
|
||||
statement = select(PasskeyCredential).where(
|
||||
PasskeyCredential.user_id == user_id
|
||||
)
|
||||
return list(session.exec(statement).all())
|
||||
|
||||
def get_credential_by_id(self, credential_id: str) -> PasskeyCredential | None:
|
||||
with Session(self.engine) as session:
|
||||
statement = select(PasskeyCredential).where(
|
||||
PasskeyCredential.credential_id == credential_id
|
||||
)
|
||||
return session.exec(statement).first()
|
||||
|
||||
def save_credential(self, credential: PasskeyCredential) -> PasskeyCredential:
|
||||
with Session(self.engine) as session:
|
||||
session.add(credential)
|
||||
session.commit()
|
||||
session.refresh(credential)
|
||||
return credential
|
||||
|
||||
def update_credential_sign_count(
|
||||
self, credential_id: str, new_sign_count: int
|
||||
) -> None:
|
||||
with Session(self.engine) as session:
|
||||
statement = select(PasskeyCredential).where(
|
||||
PasskeyCredential.credential_id == credential_id
|
||||
)
|
||||
credential = session.exec(statement).first()
|
||||
if credential:
|
||||
credential.sign_count = new_sign_count
|
||||
session.add(credential)
|
||||
session.commit()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue