Advanced Authentication

This commit is contained in:
Mawoka
2022-12-18 18:31:06 +01:00
parent bb848adca8
commit 2e159af134
29 changed files with 3315 additions and 1620 deletions
+3 -1
View File
@@ -14,7 +14,7 @@ from classquiz.db import database
from datetime import timedelta
from classquiz.oauth import rememberme_middleware
from classquiz.routers import users, quiz, utils, stats, storage, search, testing_routes, editor, live, eximport
from classquiz.routers import users, quiz, utils, stats, storage, search, testing_routes, editor, live, eximport, login
from classquiz.socket_server import sio
from classquiz.helpers import meilisearch_init, telemetry_ping, bg_tasks
from scheduler.asyncio import Scheduler
@@ -67,6 +67,8 @@ async def auth_middleware_wrapper(request: Request, call_next):
return await rememberme_middleware(request, call_next)
app.include_router(login.router, tags=["auth"], prefix="/api/v1/login", include_in_schema=True)
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
app.include_router(users.router, tags=["users"], prefix="/api/v1/users", include_in_schema=True)
app.include_router(quiz.router, tags=["quiz"], prefix="/api/v1/quiz", include_in_schema=True)
+18 -1
View File
@@ -1,7 +1,7 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import os
import uuid
from datetime import datetime
from typing import Optional
@@ -33,6 +33,10 @@ class User(ormar.Model):
auth_type: UserAuthTypes = ormar.Enum(enum_class=UserAuthTypes, default=UserAuthTypes.LOCAL)
google_uid: Optional[str] = ormar.String(unique=True, max_length=255, nullable=True)
avatar: bytes = ormar.LargeBinary(max_length=25000, represent_as_base64_str=True)
github_user_id: int | None = ormar.Integer(nullable=True)
require_password: bool = ormar.Boolean(default=True, nullable=False)
backup_code: str = ormar.String(max_length=64, min_length=64, nullable=False, default=os.urandom(32).hex())
totp_secret: str = ormar.String(max_length=32, min_length=32, nullable=True, default=None)
class Meta:
tablename = "users"
@@ -43,6 +47,19 @@ class User(ormar.Model):
use_enum_values = True
class FidoCredentials(ormar.Model):
pk: int = ormar.Integer(autoincrement=True, primary_key=True)
id: bytes = ormar.LargeBinary(max_length=256)
public_key: bytes = ormar.LargeBinary(max_length=256)
sign_count: int = ormar.Integer()
user: Optional[User] = ormar.ForeignKey(User)
class Meta:
tablename = "fido_credentials"
metadata = metadata
database = database
class ApiKey(ormar.Model):
key: str = ormar.String(max_length=48, min_length=48, primary_key=True)
user: Optional[User] = ormar.ForeignKey(User)
-2
View File
@@ -45,8 +45,6 @@ async def log_user_in(user: User, request: Request, response: Response):
samesite="lax",
max_age=settings.access_token_expire_minutes * 60,
)
response.set_cookie(key="expiry", value="", max_age=settings.access_token_expire_minutes * 60)
response.set_cookie(key="rememberme", value="", max_age=60 * 60 * 24 * 365)
response.set_cookie(
key="rememberme_token", value=session_key, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 365
)
+3 -1
View File
@@ -7,11 +7,12 @@ from typing import Optional
import asyncpg
import authlib.integrations.base_client
from fastapi import APIRouter, Request, HTTPException, Response
from classquiz.auth import check_token
from classquiz.config import settings
from fastapi.responses import RedirectResponse
from classquiz.db.models import User, UserAuthTypes
from pydantic import BaseModel
from classquiz.auth import check_token
from classquiz.helpers.avatar import gzipped_user_avatar
from classquiz.oauth.authenticate_user import log_user_in, rememberme_check
from datetime import datetime
@@ -104,6 +105,7 @@ async def auth(request: Request, response: Response):
if user_data.email is None:
return RedirectResponse("/account/oauth-error?error=email")
user_in_db = await User.objects.get_or_none(email=user_data.email)
print(user_data.id)
if user_in_db is None:
# REGISTER USER
try:
+206
View File
@@ -0,0 +1,206 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import base64
import enum
import os
import uuid
import pyotp
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel, ValidationError
from classquiz.auth import verify_password
from classquiz.config import redis, settings
from classquiz.db.models import User, FidoCredentials
from webauthn import (
generate_authentication_options,
options_to_json,
verify_authentication_response,
base64url_to_bytes,
)
from webauthn.helpers.structs import (
PublicKeyCredentialDescriptor,
UserVerificationRequirement,
AuthenticationCredential,
)
from classquiz.oauth.authenticate_user import log_user_in
settings = settings()
router = APIRouter()
class StartLoginInput(BaseModel):
email: str
class StartLoginResponseTypes(enum.Enum):
PASSWORD = "PASSWORD"
PASSKEY = "PASSKEY"
TOTP = "TOTP"
BACKUP = "BACKUP"
class LoginSession(BaseModel):
user_id: str
step_1: set[StartLoginResponseTypes]
step_2: set[StartLoginResponseTypes]
webauthn_challenge: str | None
step1_success: bool = False
class StartLoginResponse(BaseModel):
step_1: set[StartLoginResponseTypes]
step_2: set[StartLoginResponseTypes]
session_id: str
webauthn_data: None | str
def verify_webauthn(data, fidocredentialss: list[FidoCredentials], login_session: LoginSession):
try:
credential = AuthenticationCredential.parse_obj(data)
except ValidationError:
print("ValidationError")
raise HTTPException(401)
user_cred: FidoCredentials | None = None
credential.id = base64url_to_bytes(credential.id)
for cred in fidocredentialss:
if cred.id == credential.id:
user_cred = cred
break
if user_cred is None:
print("user_cred not in DB")
raise HTTPException(401)
credential.id = base64.urlsafe_b64encode(credential.raw_id).decode("utf-8").replace("=", "")
credential.response.client_data_json = base64.b64decode(credential.response.client_data_json + b"==")
credential.response.authenticator_data = base64.urlsafe_b64decode(credential.response.authenticator_data + b"==")
credential.response.signature = base64.urlsafe_b64decode(credential.response.signature + b"==")
print(credential)
try:
verify_authentication_response(
credential=credential,
expected_challenge=base64.b64decode(login_session.webauthn_challenge),
expected_rp_id="localhost",
expected_origin=settings.root_address,
credential_public_key=user_cred.public_key,
credential_current_sign_count=user_cred.sign_count,
require_user_verification=False,
)
print("logging in...")
return True
except Exception as err:
print(err)
raise HTTPException(status_code=401)
@router.post("/start")
async def start_login(data: StartLoginInput):
user = await User.objects.select_related("fidocredentialss").get_or_none(email=data.email)
step_1: set[StartLoginResponseTypes] = set()
step_2: set[StartLoginResponseTypes] = set()
webauthn_data = None
webauthn_challenge = None
if user is None:
step_1.add(StartLoginResponseTypes.PASSWORD)
return StartLoginResponse(step_1=step_1, step_2=step_2, session_id=os.urandom(16).hex(), webauthn_data=None)
if user.password is not None:
step_1.add(StartLoginResponseTypes.PASSWORD)
if len(user.fidocredentialss) > 0:
if user.require_password is True:
step_2.add(StartLoginResponseTypes.PASSKEY)
else:
step_1.add(StartLoginResponseTypes.PASSKEY)
webauthn_data = generate_authentication_options(
rp_id="localhost",
allow_credentials=[
PublicKeyCredentialDescriptor(id=cred.id, type="public-key") for cred in user.fidocredentialss
],
user_verification=UserVerificationRequirement.PREFERRED,
)
webauthn_challenge = base64.b64encode(webauthn_data.challenge).decode("utf-8")
webauthn_data = options_to_json(webauthn_data)
if user.totp_secret is not None:
if user.require_password:
step_2.add(StartLoginResponseTypes.TOTP)
else:
step_1.add(StartLoginResponseTypes.TOTP)
login_session = LoginSession(
step_1=step_1,
step_2=step_2,
user_id=user.id.hex,
webauthn_challenge=webauthn_challenge,
)
session_id = os.urandom(16).hex()
await redis.set(f"login_session:{session_id}", login_session.json(), ex=600)
return StartLoginResponse(step_1=step_1, step_2=step_2, session_id=session_id, webauthn_data=webauthn_data)
class StepInput(BaseModel):
auth_type: StartLoginResponseTypes
data: str | dict
@router.post("/step/{step_id}")
async def step_1(session_id: str, data: StepInput, request: Request, response: Response, step_id: int):
if step_id < 0 or step_id > 2:
raise HTTPException(status_code=401)
redis_res = await redis.get(f"login_session:{session_id}")
if redis_res is None:
raise HTTPException(401, detail="wrong credentials")
login_session = LoginSession.parse_raw(redis_res)
if step_id == 1:
if data.auth_type not in {*login_session.step_1, StartLoginResponseTypes.BACKUP}:
print("AUTH_TYPE not in data")
raise HTTPException(401)
elif step_id == 2:
if data.auth_type not in login_session.step_2:
print("AUTH_TYPE not in data")
raise HTTPException(401)
else:
print("unknown step")
raise HTTPException(401)
user = await User.objects.select_related("fidocredentialss").get_or_none(id=uuid.UUID(login_session.user_id))
if data.auth_type == StartLoginResponseTypes.PASSWORD:
if verify_password(data.data, user.password):
if len(login_session.step_2) == 0 or (step_id == 2 and login_session.step1_success is True):
return await log_user_in(user, request, response)
else:
login_session.step1_success = True
await redis.set(f"login_session:{session_id}", login_session.json(), ex=600)
return Response(status_code=202)
else:
print("Wrong Password")
raise HTTPException(401, detail="wrong credentials")
elif data.auth_type == StartLoginResponseTypes.PASSKEY:
res = verify_webauthn(data=data.data, fidocredentialss=user.fidocredentialss, login_session=login_session)
if res is True:
if len(login_session.step_2) == 0 or (step_id == 2 and login_session.step1_success is True):
return await log_user_in(user, request, response)
else:
login_session.step1_success = True
await redis.set(f"login_session:{session_id}", login_session.json(), ex=600)
return Response(status_code=202)
else:
raise HTTPException(401, detail="webauthn failed")
elif data.auth_type == StartLoginResponseTypes.BACKUP:
if user.backup_code == data.data:
user.backup_code = os.urandom(32).hex()
await user.update()
return await log_user_in(user, request, response)
else:
print("Wrong Backup-Code")
raise HTTPException(status_code=401)
elif data.auth_type == StartLoginResponseTypes.TOTP:
if step_id == 1 and user.require_password:
print("TOTP Cant be step 1")
raise HTTPException(401)
totp = pyotp.TOTP(user.totp_secret)
if totp.verify(data.data):
return await log_user_in(user, request, response)
else:
raise HTTPException(401, detail="totp wrong")
@@ -1,6 +1,7 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import gzip
import os
@@ -33,10 +34,14 @@ import bleach
from pydantic import BaseModel
from classquiz.db.models import User, UserSession, UpdatePassword, Token, Quiz, ApiKey
from classquiz.emails import send_register_email, send_forgotten_password_email
from classquiz.routers.users import webauthn, twofa
settings = settings()
router = APIRouter()
router.include_router(webauthn.router, prefix="/webauthn")
router.include_router(twofa.router, prefix="/2fa")
class RouteUser(pydantic.BaseModel):
username: str
@@ -84,14 +89,15 @@ async def create_user(user: RouteUser, background_task: BackgroundTasks) -> User
return user
@router.post("/token/cookie", response_model=Token)
@router.post("/token/cookie", response_model=Token, deprecated=True)
async def login_for_cookie_access_token(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
):
user = await authenticate_user(form_data.username, form_data.password)
if not user:
user = await User.objects.select_related("fidocredentialss").get(id=user.id)
if not user or user.totp_secret is not None or user or len(user.fidocredentialss) != 0:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
@@ -169,7 +175,17 @@ async def signout_everywhere(response: Response, user: User = Depends(get_curren
@router.get(
"/me",
response_model_exclude={"password", "verify_key", "usersessions", "avatar", "google_uid", "quizs"},
response_model_exclude={
"password",
"verify_key",
"usersessions",
"avatar",
"quizs",
"fidocredentialss",
"backup_code",
"apikeys",
"totp_secret",
},
response_model=User,
)
async def get_me(user: User = Depends(get_current_user)):
+75
View File
@@ -0,0 +1,75 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import os
import urllib.parse
import pyotp
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from classquiz.auth import get_current_user
from classquiz.db.models import User
router = APIRouter()
class GetBackupCodeResponse(BaseModel):
code: str
@router.get("/backup_code", response_model=GetBackupCodeResponse)
async def get_backup_code(user: User = Depends(get_current_user)):
backup_code = os.urandom(32).hex()
user = await User.objects.get(id=user.id)
user.backup_code = backup_code
await user.update()
return GetBackupCodeResponse(code=backup_code)
class SetRequirePassword(BaseModel):
require_password: bool
@router.post("/require_password", response_model=SetRequirePassword)
async def set_require_password(data: SetRequirePassword, user: User = Depends(get_current_user)):
user = await User.objects.get(id=user.id)
user.require_password = data.require_password
await user.update()
return data
class SetTotpUpResponse(BaseModel):
url: str
secret: str
@router.post("/totp", response_model=SetTotpUpResponse)
async def set_totp_up(user: User = Depends(get_current_user)):
user = await User.objects.get(id=user.id)
user.totp_secret = pyotp.random_base32()
url = pyotp.totp.TOTP(user.totp_secret).provisioning_uri(
name=urllib.parse.quote(user.username), issuer_name="ClassQuiz"
)
await user.update()
return SetTotpUpResponse(url=url, secret=user.totp_secret)
class GetTotpStatusResponse(BaseModel):
activated: bool
@router.get("/totp", response_model=GetTotpStatusResponse)
async def get_totp_status(user: User = Depends(get_current_user)):
user = await User.objects.get(id=user.id)
if user.totp_secret is None:
return GetTotpStatusResponse(activated=False)
else:
return GetTotpStatusResponse(activated=True)
@router.delete("/totp")
async def disable_totp(user: User = Depends(get_current_user)):
user = await User.objects.get(id=user.id)
user.totp_secret = None
await user.update()
+87
View File
@@ -0,0 +1,87 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from webauthn.helpers.cose import COSEAlgorithmIdentifier
from classquiz.auth import get_current_user
from classquiz.db.models import User, FidoCredentials
from classquiz.config import redis, settings
import base64
from webauthn import generate_registration_options, verify_registration_response
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
UserVerificationRequirement,
RegistrationCredential,
PublicKeyCredentialCreationOptions,
PublicKeyCredentialDescriptor,
)
settings = settings()
router = APIRouter()
@router.get("/add_key", response_model=PublicKeyCredentialCreationOptions)
async def request_add_key_data(user: User = Depends(get_current_user)):
user = await User.objects.select_related("fidocredentialss").get(id=user.id)
options = generate_registration_options(
rp_id="localhost",
rp_name="ClassQuiz",
user_id=user.id.hex,
user_name=user.email,
exclude_credentials=[
PublicKeyCredentialDescriptor(id=cred.id, type="public-key", transports=[])
for cred in user.fidocredentialss
],
authenticator_selection=AuthenticatorSelectionCriteria(user_verification=UserVerificationRequirement.PREFERRED),
supported_pub_key_algs=[
COSEAlgorithmIdentifier.ECDSA_SHA_256,
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
],
timeout=600,
)
await redis.set(f"add_webauthn:{user.id.hex}", base64.b64encode(options.challenge), ex=610)
return options
@router.post("/add_key")
async def confirm_add_key_data(credential: RegistrationCredential, user: User = Depends(get_current_user)):
redis_res = await redis.get(f"add_webauthn:{user.id.hex}")
if redis_res is None:
raise HTTPException(401)
current_registration_challenge = base64.b64decode(redis_res)
credential.id = base64.urlsafe_b64encode(credential.raw_id).decode("utf-8").replace("=", "")
credential.response.client_data_json = base64.b64decode(credential.response.client_data_json + b"==")
credential.response.attestation_object = base64.urlsafe_b64decode(credential.response.attestation_object + b"==")
verification = verify_registration_response(
credential=credential,
expected_challenge=current_registration_challenge,
expected_rp_id="localhost",
expected_origin=settings.root_address,
)
new_credential = FidoCredentials(
id=verification.credential_id,
public_key=verification.credential_public_key,
sign_count=verification.sign_count,
)
user = await User.objects.select_related("fidocredentialss").get(id=user.id)
await user.fidocredentialss.add(new_credential)
class SecurityKey(BaseModel):
id: int
@router.get("/list", response_model=list[SecurityKey])
async def list_security_keys(user: User = Depends(get_current_user)):
user = await User.objects.select_related("fidocredentialss").get(id=user.id)
return [SecurityKey(id=sec.pk) for sec in user.fidocredentialss]
@router.delete("/key/{key_id}")
async def delete_security_key(key_id: int, user: User = Depends(get_current_user)):
key = await FidoCredentials.objects.get_or_none(pk=key_id, user=user.id)
if key is None:
raise HTTPException(status_code=404, detail="Key not found")
await key.delete()