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
+358
View File
@@ -0,0 +1,358 @@
# 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
import ormar
import pydantic
from email_validator import validate_email, EmailNotValidError
from fastapi import APIRouter, Response, HTTPException, Request, Depends, status
from fastapi.background import BackgroundTasks
from fastapi.responses import JSONResponse, RedirectResponse, PlainTextResponse
from fastapi.security import OAuth2PasswordRequestForm
import html
from jose import jwt, JWTError
from classquiz import oauth
from classquiz.helpers.avatar import gzipped_user_avatar
import base64
from classquiz.oauth.authenticate_user import rememberme_check, log_user_in
from classquiz.auth import (
get_password_hash,
verify_password,
authenticate_user,
get_current_user,
)
from classquiz.cache import clear_cache_for_account
from classquiz.config import redis, settings, meilisearch
import uuid
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
password: str
email: str
async def _sign_out_everywhere(user: User) -> None:
await UserSession.objects.filter(user=user).delete()
await clear_cache_for_account(user)
router.include_router(oauth.router, tags=["users", "oauth"], prefix="/oauth")
@router.post(
"/create",
response_model=User,
response_model_include={"id": ..., "verified": ..., "email": ...},
)
async def create_user(user: RouteUser, background_task: BackgroundTasks) -> User | JSONResponse:
user = User(**user.dict(), id=uuid.uuid4(), avatar=gzipped_user_avatar())
try:
validate_email(user.email)
except EmailNotValidError as e:
raise HTTPException(status_code=400, detail=str(e))
user.verify_key = str(os.urandom(16).hex())
res = await User.objects.filter((User.email == user.email) | (User.username == user.username)).all()
if len(res) != 0:
raise HTTPException(status_code=409, detail="User already exists")
user.password = get_password_hash(user.password)
user.username = html.unescape(bleach.clean(user.username, tags=[], strip=True))
if len(user.username) == 32:
return JSONResponse({"details": "Username mustn't be 32 characters long"}, 400)
await user.save()
print(settings.skip_email_verification)
if settings.skip_email_verification:
user.verify_key = None
user.verified = True
await user.update()
else:
background_task.add_task(send_register_email, user)
await redis.delete("global_user_count")
return user
@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)
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",
)
return await log_user_in(response=response, request=request, user=user)
@router.get("/token/rememberme")
async def rememberme_token(request: Request, response: Response):
rememberme_token_lol = request.cookies.get("rememberme_token")
if rememberme_token_lol is None:
raise HTTPException(status_code=400, detail="No rememberme cookie")
return await rememberme_check(rememberme_token=rememberme_token_lol, response=response)
@router.get("/logout")
async def logout(request: Request, response: Response):
remember_token = request.cookies.get("rememberme_token")
if remember_token is not None:
await UserSession.objects.filter(session_key=remember_token).delete()
response.delete_cookie("access_token")
response.delete_cookie("expiry")
response.delete_cookie("rememberme")
response.delete_cookie("rememberme_token")
response.status_code = 302
response.headers["Location"] = "/"
return response
@router.get("/check")
async def check_token(user: User = Depends(get_current_user)):
return {"email": user.email}
@router.get("/verify/{verify_key}")
async def verify_user(verify_key: str):
user = await User.objects.filter(verify_key=verify_key).get_or_none()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
user.verified = True
user.verify_key = None
await user.update()
return RedirectResponse(url="/account/login?verified=true")
@router.put("/password/update")
async def change_password(
password_data: UpdatePassword,
response: Response,
user: User = Depends(get_current_user),
):
if not verify_password(password_data.old_password, user.password):
raise HTTPException(status_code=400, detail="Incorrect password")
user.password = get_password_hash(password_data.new_password)
await user.update()
await clear_cache_for_account(user)
await UserSession.objects.filter(user=user).delete()
response.delete_cookie("access_token")
response.delete_cookie("expiry")
response.delete_cookie("rememberme")
response.delete_cookie("rememberme_token")
return {"message": "Password updated successfully"}
@router.delete("/signout-everywhere")
async def signout_everywhere(response: Response, user: User = Depends(get_current_user)):
await _sign_out_everywhere(user)
response.delete_cookie("access_token")
response.delete_cookie("expiry")
response.delete_cookie("rememberme")
response.delete_cookie("rememberme_token")
return {"message": "Signout everywhere"}
@router.get(
"/me",
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)):
return user
class ForgotPassword(BaseModel):
email: str
@router.post("/forgot-password")
async def forgotten_password(forgot_password: ForgotPassword, background_task: BackgroundTasks):
user = await User.objects.filter(email=forgot_password.email, verified=True).get_or_none()
if user is not None:
background_task.add_task(send_forgotten_password_email, email=user.email)
return {"message": "Password reset email sent"}
class ResetPassword(BaseModel):
password: str
token: str
@router.post("/reset-password")
async def reset_password_with_token(reset_password: ResetPassword, response: Response):
redis_res = await redis.get(f"reset_passwd:{reset_password.token}")
if redis_res is None:
raise HTTPException(status_code=400, detail="Invalid token")
user = await User.objects.filter(id=uuid.UUID(redis_res)).get_or_none()
if user is None:
raise HTTPException(status_code=400, detail="Invalid token")
user.password = get_password_hash(reset_password.password)
await user.update()
await _sign_out_everywhere(user)
response.delete_cookie("access_token")
response.delete_cookie("expiry")
response.delete_cookie("rememberme")
response.delete_cookie("rememberme_token")
return {"message": "Password updated successfully"}
@router.get("/sessions/list", response_model=list[UserSession], response_model_exclude={"user", "session_key", "quizs"})
async def list_sessions(user: User = Depends(get_current_user)):
sessions = await UserSession.objects.filter(user=user).all()
return [session.dict() for session in sessions]
@router.delete("/sessions/{session_id}")
async def delete_session(session_id: str, user: User = Depends(get_current_user)):
try:
session_id = uuid.UUID(session_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid session id")
await UserSession.objects.filter(user=user, id=session_id).delete()
return {"message": "Session deleted"}
@router.get(
"/session", response_model=UserSession, response_model_exclude={"user": ..., "session_key": ..., "quizs": ...}
)
async def get_session(request: Request, user: User = Depends(get_current_user)):
try:
session = await UserSession.objects.filter(
user=user, session_key=request.cookies.get("rememberme_token")
).first()
return session
except ormar.NoMatch:
raise HTTPException(status_code=404, detail="Session not found")
class DeleteUserInput(BaseModel):
password: str
@router.delete("/me")
async def delete_user_account(input_data: DeleteUserInput, user: User = Depends(get_current_user)):
if not verify_password(input_data.password, user.password):
raise HTTPException(status_code=400, detail="Incorrect password")
user = await User.objects.filter(id=user.id).get_or_none()
await UserSession.objects.filter(user=user).delete()
quizzes = await Quiz.objects.filter(user_id=user).all()
quizzes_to_delete = []
for quiz in quizzes:
if quiz.public:
quizzes_to_delete.append(str(quiz.id))
if len(quizzes_to_delete) > 0:
meilisearch.index(settings.meilisearch_index).delete_documents(quizzes_to_delete)
await Quiz.objects.filter(user_id=user).delete()
await User.objects.filter(id=user.id).delete()
await user.delete()
@router.get("/avatar", response_class=PlainTextResponse)
async def get_own_avatar(respo: Response, user: User = Depends(get_current_user)):
respo.headers.append("Content-Type", "image/svg+xml")
return gzip.decompress(base64.b64decode(user.avatar))
@router.get("/avatar/{user_id}", response_class=PlainTextResponse)
async def get_other_avatar(respo: Response, user_id: uuid.UUID):
user = await User.objects.filter(id=user_id).get_or_none()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
respo.headers.append("Content-Type", "image/svg+xml")
return gzip.decompress(base64.b64decode(user.avatar))
class InternalAuthData(BaseModel):
rememberme: str
jwt: str | None
@router.post("/auth/internal")
async def internal_auth(data: InternalAuthData, resp: Response):
try:
data.jwt = data.jwt.replace("Bearer ", "")
except AttributeError:
pass
if data.jwt is not None:
try:
payload = jwt.decode(data.jwt, settings.secret_key, algorithms=["HS256"])
email: str = payload.get("sub")
if email is None:
resp.status_code = 401
return resp
except JWTError:
resp.status_code = 401
return resp
else:
return await rememberme_check(data.rememberme, resp)
class GetEmailFromJWT(BaseModel):
jwt: str
@router.post("/auth/internal/email")
async def get_email_from_jwt(data: GetEmailFromJWT):
try:
data.jwt = data.jwt.replace("Bearer ", "")
except AttributeError:
pass
try:
payload = jwt.decode(data.jwt, settings.secret_key, algorithms=["HS256"])
return payload.get("sub")
except JWTError as e:
print(e)
raise HTTPException(status_code=401)
@router.post("/api_keys", response_model=ApiKey, response_model_include={"key"})
async def generate_api_key(user: User = Depends(get_current_user)):
key = ApiKey(key=os.urandom(24).hex(), user=user)
await key.save()
return key.dict(include={"key"})
@router.get("/api_keys", response_model=list[ApiKey], response_model_include={"key"})
async def list_api_keys(user: User = Depends(get_current_user)):
keys = await ApiKey.objects.filter(user=user).all()
return keys
@router.delete("/api_keys")
async def delete_api_key(api_key: str, user: User = Depends(get_current_user)):
key = await ApiKey.objects.get_or_none(key=api_key)
if key is None:
raise HTTPException(status_code=404, detail="Key not found")
await redis.delete(f"apikey:{key.key}")
await key.delete()
+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()