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
+2
View File
@@ -34,6 +34,8 @@ puremagic = "*"
py-avataaars-no-png = "*"
cryptography = "*"
scheduler = "*"
webauthn = "*"
pyotp = "*"
[dev-packages]
coverage = "*"
Generated
+451 -457
View File
File diff suppressed because it is too large Load Diff
+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()
+8 -5
View File
@@ -26,14 +26,16 @@
"@fontsource/marck-script": "^4.5.11",
"@sentry/browser": "^7.23.0",
"@sentry/tracing": "^7.23.0",
"@sveltejs/adapter-auto": "next",
"@simplewebauthn/browser": "^6.2.2",
"@sveltejs/adapter-auto": "^1.0.0",
"@sveltejs/adapter-node": "^1.0.0-next.101",
"@sveltejs/kit": "next",
"@sveltejs/kit": "^1.0.1",
"@tailwindcss/typography": "^0.5.8",
"@types/canvas-confetti": "^1.6.0",
"@types/cookie": "^0.5.1",
"@types/js-cookie": "^3.0.2",
"@types/luxon": "^2.4.0",
"@types/qrcode": "^1.5.0",
"@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
@@ -69,11 +71,12 @@
"postcss-load-config": "^4.0.1",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.8.1",
"qrcode": "^1.5.1",
"sass": "^1.56.1",
"socket.io-client": "^4.5.4",
"svelte": "^3.53.1",
"svelte-check": "^2.10.0",
"svelte-preprocess": "^4.10.7",
"svelte-preprocess": "^5.0.0",
"svelte-range-slider-pips": "^2.1.0",
"svelte-tippy": "^1.3.2",
"swiper": "^8.4.5",
@@ -82,8 +85,8 @@
"tslib": "^2.4.1",
"typescript": "~4.7.4",
"ua-parser-js": "^1.0.32",
"vite": "^3.2.4",
"vite-plugin-iso-import": "^0.1.3",
"vite": "^4.0.1",
"vite-plugin-iso-import": "^1.0.0",
"yup": "^0.32.11"
},
"type": "module",
+1104 -773
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -150,7 +150,8 @@
"practice": "Practice",
"error": "Error",
"voting": "Voting",
"download": "Download"
"download": "Download",
"continue": "Continue"
},
"editor": {
"time_in_seconds": "Time in seconds",
+91 -364
View File
@@ -5,12 +5,20 @@
-->
<script lang="ts">
import * as Sentry from '@sentry/browser';
import { navbarVisible } from '$lib/stores';
import { alertModal, navbarVisible } from '$lib/stores';
import { slide } from 'svelte/transition';
import { browser } from '$app/environment';
import Cookies from 'js-cookie';
import { getLocalization } from '$lib/i18n';
import Footer from '$lib/footer.svelte';
import { google_auth_enabled, github_auth_enabled } from '$lib/config';
import VerifiedBadge from './verified_badge.svelte';
import StartWindow from './start_window.svelte';
import SelectMethod from './select_method.svelte';
import PasswordComponent from './password_component.svelte';
import WebauthnComponent from './webauthn_component.svelte';
import BackupComponent from './backup_component.svelte';
import TotpComponent from './totp_component.svelte';
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
const { t } = getLocalization();
@@ -19,385 +27,104 @@
export let data;
const { verified }: boolean = data;
let loginData = {
email: '',
password: ''
};
let emailEmpty = true;
let passwordEmpty = true;
let responseData = {
open: false,
data: ''
};
let inputValid = false;
let isSubmitting = false;
let session_data = {};
let step = 0;
let selected_method = null;
let done = false;
$: emailEmpty = loginData.email === '';
$: passwordEmpty = loginData.password === '';
$: inputValid = !emailEmpty && !passwordEmpty;
const redirect_back = (done_var: boolean) => {
if (done_var) {
window.location.reload();
}
};
let alertModalOpen = false;
$: redirect_back(done);
const reloadWindow = () => {
const expireIn60Sec = new Date(new Date().getTime() + 60 * 1000);
if (Cookies.get('reload') === undefined) {
Cookies.set('reload', '1', { expires: expireIn60Sec });
} else {
const cookie: string = Cookies.get('reload');
if (parseInt(cookie) >= 3) {
if (import.meta.env.VITE_SENTRY !== null) {
Sentry.captureException(new Error('Reload loop'));
alertModal.subscribe((data) => {
if (!alertModalOpen && data.open) {
alertModalOpen = true;
}
if (alertModalOpen && !data.open) {
window.location.reload();
}
});
const check_auto = () => {
if (step === 1) {
if (!browserSupportsWebAuthn()) {
for (let i = 0; i < session_data.step_1.length; i++) {
if (session_data.step_1[i] === 'PASSKEY') {
session_data.step_1.splice(i, 1);
}
}
fetch('/api/v1/users/logout').then();
Cookies.remove('reload');
session_data.step_1 = session_data.step_1;
}
if (session_data.step_1.length === 1) {
selected_method = session_data.step_1[0];
}
Cookies.set('reload', String(parseInt(cookie) + 1), { expires: expireIn60Sec });
}
window.location.reload();
};
const checkRememberMe = async () => {
const res = await fetch('/api/v1/users/token/rememberme');
if (res.status === 200) {
reloadWindow();
if (step === 2) {
if (!browserSupportsWebAuthn()) {
for (let i = 0; i < session_data.step_2.length; i++) {
if (session_data.step_2[i] === 'PASSKEY') {
session_data.step_2.splice(i, 1);
}
}
session_data.step_2 = session_data.step_2;
}
if (session_data.step_2.length === 1) {
selected_method = session_data.step_2[0];
}
}
};
if (browser) {
checkRememberMe();
$: {
check_auto();
step;
}
const login = async (): Promise<void> => {
if (emailEmpty || passwordEmpty) {
return;
}
const formData = new FormData();
formData.append('username', loginData.email);
formData.append('password', loginData.password);
const res = await fetch('/api/v1/users/token/cookie', {
method: 'post',
body: formData
});
isSubmitting = true;
if (res.status === 200) {
responseData.data = loginData.password === '' ? 'magic' : 'password';
} else if (res.status === 401) {
responseData.data = '404';
} else {
responseData.data = 'error';
}
responseData.open = true;
isSubmitting = false;
};
</script>
<svelte:head>
<title>ClassQuiz - Login</title>
</svelte:head>
<div class="flex items-center justify-center h-full px-4">
<div>
{#if verified}
<div
class="flex items-center justify-center p-4 text-green-700 border-2 border-current rounded-lg bg-white"
role="alert"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
class="w-6 h-6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.2426 16.3137L6 12.071L7.41421 10.6568L10.2426 13.4853L15.8995 7.8284L17.3137 9.24262L10.2426 16.3137Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12ZM12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21Z"
fill="currentColor"
/>
</svg>
{#if verified}
<VerifiedBadge />
{/if}
<h3 class="ml-3 text-sm font-medium">
You've successfully confirmed your email address.
</h3>
<div
class="lg:w-1/3 max-w-sm mx-auto overflow-hidden bg-white rounded-lg shadow-md dark:bg-gray-800"
>
{#if step === 0}
<!-- <p>StartWindow</p>-->
<div transition:slide>
<StartWindow bind:session_data bind:step />
</div>
{:else if selected_method === null}
<!-- <p>SelectWindow</p>-->
<div transition:slide>
<SelectMethod bind:session_data bind:step bind:selected_method />
</div>
{:else if selected_method === 'PASSWORD'}
<!-- <p>PasswordWindow</p>-->
<div transition:slide>
<PasswordComponent bind:session_data bind:done bind:step bind:selected_method />
</div>
{:else if selected_method === 'PASSKEY'}
<!-- <p>WebauthnWindow</p>-->
<div transition:slide>
<WebauthnComponent bind:session_data bind:done bind:step bind:selected_method />
</div>
{:else if selected_method === 'BACKUP'}
<!-- <p>BackupWindow</p>-->
<div transition:slide>
<BackupComponent bind:session_data bind:done bind:step bind:selected_method />
</div>
{:else if selected_method === 'TOTP'}
<!-- <p>TotpWindow</p>-->
<div transition:slide>
<TotpComponent bind:session_data bind:done bind:step bind:selected_method />
</div>
{/if}
<span class="p-4" />
<div
class="w-full max-w-sm mx-auto overflow-hidden bg-white rounded-lg shadow-md dark:bg-gray-800"
>
<div class="px-6 py-4">
<h2 class="text-3xl font-bold text-center text-gray-700 dark:text-white">
ClassQuiz
</h2>
<h3 class="mt-1 text-xl font-medium text-center text-gray-600 dark:text-gray-200">
{$t('login_page.welcome_back')}
</h3>
<p class="mt-1 text-center text-gray-500 dark:text-gray-400">
{$t('login_page.login_or_create_account')}
</p>
<form on:submit|preventDefault={login}>
<div class="w-full mt-4">
<div class="dark:bg-gray-800 bg-white p-4 rounded-lg">
<div class="relative bg-inherit w-full">
<input
id="email"
bind:value={loginData.email}
name="email"
type="email"
class="w-full peer bg-transparent h-10 rounded-lg text-gray-700 dark:text-white placeholder-transparent ring-2 px-2 ring-gray-500 focus:ring-sky-600 focus:outline-none focus:border-rose-600"
placeholder={$t('words.email')}
/>
<label
for="email"
class="absolute cursor-text left-0 -top-3 text-sm text-gray-700 dark:text-white bg-inherit mx-1 px-1 peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-500 peer-placeholder-shown:top-2 peer-focus:-top-3 peer-focus:text-sky-600 peer-focus:text-sm transition-all"
>
{$t('words.email')}
</label>
</div>
</div>
<div class="dark:bg-gray-800 bg-white p-4 rounded-lg">
<div class="relative bg-inherit w-full">
<input
id="password"
name="password"
type="password"
bind:value={loginData.password}
class="w-full peer bg-transparent h-10 rounded-lg text-gray-700 dark:text-white placeholder-transparent ring-2 px-2 ring-gray-500 focus:ring-sky-600 focus:outline-none focus:border-rose-600"
placeholder={$t('words.password')}
/>
<label
for="password"
class="absolute cursor-text left-0 -top-3 text-sm text-gray-500 bg-inherit mx-1 px-1 peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-500 peer-placeholder-shown:top-2 peer-focus:-top-3 peer-focus:text-sky-600 peer-focus:text-sm transition-all"
>
{$t('words.password')}
</label>
</div>
</div>
<div class="flex items-center justify-between mt-4">
<a
href="/account/reset-password"
class="text-sm text-gray-600 dark:text-gray-200 hover:text-gray-500"
>{$t('register_page.forgot_password?')}</a
>
<button
class="px-4 py-2 leading-5 text-white transition-colors duration-200 transform bg-gray-700 rounded hover:bg-gray-600 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={!inputValid}
type="submit"
>
{#if isSubmitting}
<svg
class="h-4 w-4 animate-spin mx-auto my-20"
viewBox="3 3 18 18"
>
<path
class="fill-black"
d="M12 5C8.13401 5 5 8.13401 5 12C5 15.866 8.13401 19 12 19C15.866 19 19 15.866 19 12C19 8.13401 15.866 5 12 5ZM3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12Z"
/>
<path
class="fill-blue-100"
d="M16.9497 7.05015C14.2161 4.31648 9.78392 4.31648 7.05025 7.05015C6.65973 7.44067 6.02656 7.44067 5.63604 7.05015C5.24551 6.65962 5.24551 6.02646 5.63604 5.63593C9.15076 2.12121 14.8492 2.12121 18.364 5.63593C18.7545 6.02646 18.7545 6.65962 18.364 7.05015C17.9734 7.44067 17.3403 7.44067 16.9497 7.05015Z"
/>
</svg>
{:else}
{$t('words.login')}
{/if}
</button>
</div>
{#if google_auth_enabled}
<div class="flex items-center justify-center pt-4">
<a
href="/api/v1/users/oauth/google/login"
class="inline-flex w-fit p-1 rounded-lg border-gray-500 border border-2 hover:bg-[#4285F4] transition"
>Google Login
<svg
class="h-6 w-6 ml-4 dark:fill-gray-300"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><title> Google</title>
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
/>
</svg>
</a>
</div>
{/if}
{#if github_auth_enabled}
<div class="flex items-center w-full justify-center pt-4">
<a
href="/api/v1/users/oauth/github/login"
class="inline-flex w-fit p-1 rounded-lg border-gray-500 border border-2 hover:bg-[#181717] transition hover:text-white group"
>GitHub Login
<svg
class="h-6 w-6 ml-4 dark:fill-gray-300 group-hover:fill-white transition"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><title> GitHub</title>
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/>
</svg>
</a>
</div>
{/if}
</div>
</form>
</div>
<div
class="flex items-center justify-center py-4 text-center bg-gray-50 dark:bg-gray-700"
>
<span class="text-sm text-gray-600 dark:text-gray-200"
>{$t('login_page.already_have_account')}
</span>
<a
href="/account/register"
class="mx-2 text-sm font-bold text-blue-500 dark:text-blue-400 hover:underline"
>{$t('words.register')}</a
>
</div>
</div>
</div>
</div>
<Footer />
<div
class="fixed z-10 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
class:hidden={!responseData.open}
>
<div
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
>
<!--
Background overlay, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0"
To: "opacity-100"
Leaving: "ease-in duration-200"
From: "opacity-100"
To: "opacity-0"
-->
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
aria-hidden="true"
/>
<!-- This element is to trick the browser into centering the modal contents. -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true"
>&#8203;</span
>
<!--
Modal panel, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
To: "opacity-100 translate-y-0 sm:scale-100"
Leaving: "ease-in duration-200"
From: "opacity-100 translate-y-0 sm:scale-100"
To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
-->
<div
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"
>
<!-- Heroicon name: outline/exclamation -->
{#if responseData.data === '404' || responseData.data === 'error'}
<svg
class="h-6 w-6 text-red-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
{:else}
<svg
class="w-6 h-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{/if}
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{#if responseData.data === 'magic'}
{$t('login_page.modal.success.success_check_mail')}
{:else if responseData.data === 'password'}
{$t('login_page.modal.success.success')}
{:else if responseData.data === '404'}
{$t('login_page.modal.error.wrong_creds')}
{:else if responseData.data === 'error'}
{$t('login_page.modal.error.unexpected')}
{:else}
You stupid Mawoka!
{/if}
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
{#if responseData.data === 'magic'}
{$t('login_page.modal.success.description.success_check_mail')}
{:else if responseData.data === 'password'}
{$t('login_page.modal.success.description.success')}
{:else if responseData.data === '404'}
{$t('login_page.modal.error.description.wrong_creds')}
{:else if responseData.data === 'error'}
{$t('login_page.modal.error.description.unexpected')}
{:else}
You stupid Mawoka!
{/if}
</p>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
on:click={() => {
responseData.open = false;
if (responseData.data === 'magic' || responseData.data === 'password') {
reloadWindow();
}
}}
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>{$t('words.close')}
</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,96 @@
<!--
- 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/.
-->
<script lang="ts">
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
export let session_data;
export let selected_method;
export let done;
export let step;
let backup_code = '';
let isSubmitting = false;
let backup_code_valid = false;
$: backup_code_valid = backup_code.length === 64;
const continue_in_login = async () => {
if (!backup_code_valid) {
return;
}
isSubmitting = true;
const res = await fetch(`/api/v1/login/step/1?session_id=${session_data.session_id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ auth_type: 'BACKUP', data: backup_code })
});
if (res.status === 200) {
done = true;
} else {
step += 1;
selected_method = null;
}
};
</script>
<div class="px-6 py-4">
<h2 class="text-3xl font-bold text-center text-gray-700 dark:text-white">ClassQuiz</h2>
<form on:submit|preventDefault={continue_in_login}>
<div class="w-full mt-4">
<div class="dark:bg-gray-800 bg-white p-4 rounded-lg">
<div class="relative bg-inherit w-full">
<input
id="backup_code"
bind:value={backup_code}
name="backup_code"
type="text"
class="w-full peer bg-transparent h-10 rounded-lg text-gray-700 dark:text-white placeholder-transparent ring-2 px-2 ring-gray-500 focus:ring-sky-600 focus:outline-none focus:border-rose-600"
placeholder={$t('words.backup_code')}
/>
<label
for="backup_code"
class="absolute cursor-text left-0 -top-3 text-sm text-gray-700 dark:text-white bg-inherit mx-1 px-1 peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-500 peer-placeholder-shown:top-2 peer-focus:-top-3 peer-focus:text-sky-600 peer-focus:text-sm transition-all"
>
{$t('words.backup_code')}
</label>
</div>
</div>
<div class="flex items-center justify-between mt-4">
<button
on:click={() => {
selected_method = 'BACKUP';
}}
class="text-sm text-gray-600 dark:text-gray-200 hover:text-gray-500"
>{$t('login_page.use_backup_code')}</button
>
<button
class="px-4 py-2 leading-5 text-white transition-colors duration-200 transform bg-gray-700 rounded hover:bg-gray-600 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={!backup_code_valid}
type="submit"
>
{#if isSubmitting}
<svg class="h-4 w-4 animate-spin mx-auto" viewBox="3 3 18 18">
<path
class="fill-black"
d="M12 5C8.13401 5 5 8.13401 5 12C5 15.866 8.13401 19 12 19C15.866 19 19 15.866 19 12C19 8.13401 15.866 5 12 5ZM3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12Z"
/>
<path
class="fill-blue-100"
d="M16.9497 7.05015C14.2161 4.31648 9.78392 4.31648 7.05025 7.05015C6.65973 7.44067 6.02656 7.44067 5.63604 7.05015C5.24551 6.65962 5.24551 6.02646 5.63604 5.63593C9.15076 2.12121 14.8492 2.12121 18.364 5.63593C18.7545 6.02646 18.7545 6.65962 18.364 7.05015C17.9734 7.44067 17.3403 7.44067 16.9497 7.05015Z"
/>
</svg>
{:else}
{$t('words.continue')}
{/if}
</button>
</div>
</div>
</form>
</div>
@@ -0,0 +1,113 @@
<!--
- 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/.
-->
<script lang="ts">
import { getLocalization } from '$lib/i18n';
import { alertModal } from '../../../lib/stores';
export let session_data;
export let selected_method;
export let done;
export let step;
const { t } = getLocalization();
let isSubmitting;
let password;
const continue_in_login = async () => {
if (!password) {
return;
}
const res = await fetch(`/api/v1/login/step/1?session_id=${session_data.session_id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ auth_type: 'PASSWORD', data: password })
});
if (res.status === 200) {
done = true;
} else if (res.status === 202) {
step += 1;
selected_method = null;
} else if (res.status === 401) {
let data;
try {
data = await res.json();
} catch {
alertModal.set({
open: true,
body: "This shouldn't happen. Please try again.",
title: 'Unknown error'
});
window.location.reload();
}
if (data.detail === 'wrong credentials') {
alertModal.set({
open: true,
body: 'Please try again. Your email and or password were incorrect.',
title: 'Wrong Credentials'
});
}
}
};
</script>
<div class="px-6 py-4">
<h2 class="text-3xl font-bold text-center text-gray-700 dark:text-white">ClassQuiz</h2>
<form on:submit|preventDefault={continue_in_login}>
<div class="w-full mt-4">
<div class="dark:bg-gray-800 bg-white p-4 rounded-lg">
<div class="relative bg-inherit w-full">
<input
id="password"
bind:value={password}
name="password"
type="password"
class="w-full peer bg-transparent h-10 rounded-lg text-gray-700 dark:text-white placeholder-transparent ring-2 px-2 ring-gray-500 focus:ring-sky-600 focus:outline-none focus:border-rose-600"
placeholder={$t('words.password')}
autocomplete="current-password"
/>
<label
for="password"
class="absolute cursor-text left-0 -top-3 text-sm text-gray-700 dark:text-white bg-inherit mx-1 px-1 peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-500 peer-placeholder-shown:top-2 peer-focus:-top-3 peer-focus:text-sky-600 peer-focus:text-sm transition-all"
>
{$t('words.password')}
</label>
</div>
</div>
<div class="flex items-center justify-between mt-4">
<button
on:click={() => {
selected_method = 'BACKUP';
}}
class="text-sm text-gray-600 dark:text-gray-200 hover:text-gray-500"
>{$t('login_page.use_backup_code')}</button
>
<button
class="px-4 py-2 leading-5 text-white transition-colors duration-200 transform bg-gray-700 rounded hover:bg-gray-600 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={!password}
type="submit"
>
{#if isSubmitting}
<svg class="h-4 w-4 animate-spin mx-auto" viewBox="3 3 18 18">
<path
class="fill-black"
d="M12 5C8.13401 5 5 8.13401 5 12C5 15.866 8.13401 19 12 19C15.866 19 19 15.866 19 12C19 8.13401 15.866 5 12 5ZM3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12Z"
/>
<path
class="fill-blue-100"
d="M16.9497 7.05015C14.2161 4.31648 9.78392 4.31648 7.05025 7.05015C6.65973 7.44067 6.02656 7.44067 5.63604 7.05015C5.24551 6.65962 5.24551 6.02646 5.63604 5.63593C9.15076 2.12121 14.8492 2.12121 18.364 5.63593C18.7545 6.02646 18.7545 6.65962 18.364 7.05015C17.9734 7.44067 17.3403 7.44067 16.9497 7.05015Z"
/>
</svg>
{:else}
{$t('words.continue')}
{/if}
</button>
</div>
</div>
</form>
</div>
@@ -0,0 +1,132 @@
<!--
- 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/.
-->
<script lang="ts">
export let session_data = {};
export let step;
export let selected_method;
let available_methods;
const set_available_methods = (step_var: number) => {
if (step_var === 1) {
available_methods = session_data.step_1;
} else if (step_var === 2) {
available_methods = session_data.step_2;
}
};
$: set_available_methods(step);
</script>
<div class="px-6 py-4">
<h2 class="text-3xl font-bold text-center text-gray-700 dark:text-white">ClassQuiz</h2>
<div class="w-full mt-4">
<div class="dark:bg-gray-800 bg-white p-4 rounded-lg">
<ul class="flex flex-col gap-4">
{#if available_methods.includes('PASSKEY')}
<div
class="flex flex-row bg-gray-100 dark:bg-gray-700 rounded-lg p-2 hover:cursor-pointer hover:bg-gray-200 transition"
on:click={() => {
selected_method = 'PASSKEY';
}}
>
<!-- heroicons/key -->
<svg
class="w-12 h-12"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
<div class="ml-2">
<p>Key</p>
<p class="text-sm">Authenticate using a security key</p>
</div>
</div>
{/if}
{#if available_methods.includes('PASSWORD')}
<div
class="flex flex-row bg-gray-100 dark:bg-gray-700 rounded-lg p-2 hover:cursor-pointer hover:bg-gray-200 transition"
on:click={() => {
selected_method = 'PASSWORD';
}}
>
<!-- iconoir/password-cursor -->
<svg
class="w-12 h-12 dark:text-white"
stroke-width="2"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 13V8a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h7"
stroke="currentColor"
stroke-width="2.03"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
clip-rule="evenodd"
d="M20.879 16.917c.494.304.463 1.043-.045 1.101l-2.567.291-1.151 2.312c-.228.459-.933.234-1.05-.334l-1.255-6.116c-.099-.48.333-.782.75-.525l5.318 3.271z"
stroke="currentColor"
stroke-width="2.03"
/>
<path
d="M12 11.01l.01-.011M16 11.01l.01-.011M8 11.01l.01-.011"
stroke="currentColor"
stroke-width="2.03"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div class="ml-2">
<p>Password</p>
<p class="text-sm">Authenticate using a Password</p>
</div>
</div>
{/if}
{#if available_methods.includes('TOTP')}
<div
class="flex flex-row bg-gray-100 rounded-lg p-2 hover:cursor-pointer hover:bg-gray-200 transition"
on:click={() => {
selected_method = 'TOTP';
}}
>
<!-- heroicons/clock -->
<svg
class="w-12 h-12"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="ml-2">
<p>Totp</p>
<p class="text-sm">Authenticate using a one-time password</p>
</div>
</div>
{/if}
</ul>
</div>
</div>
</div>
@@ -0,0 +1,149 @@
<!--
- 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/.
-->
<script lang="ts">
import { github_auth_enabled, google_auth_enabled } from '$lib/config';
import { getLocalization } from '$lib/i18n';
export let session_data = {};
export let step;
const { t } = getLocalization();
let email = '';
let emailEmpty = true;
let isSubmitting = false;
$: emailEmpty = email === '';
const start_login = async (): Promise<void> => {
if (emailEmpty) {
return;
}
isSubmitting = true;
const res = await fetch('/api/v1/login/start', {
method: 'post',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email: email })
});
session_data = await res.json();
step = 1;
};
</script>
<div class="px-6 py-4">
<h2 class="text-3xl font-bold text-center text-gray-700 dark:text-white">ClassQuiz</h2>
<h3 class="mt-1 text-xl font-medium text-center text-gray-600 dark:text-gray-200">
{$t('login_page.welcome_back')}
</h3>
<p class="mt-1 text-center text-gray-500 dark:text-gray-400">
{$t('login_page.login_or_create_account')}
</p>
<form on:submit|preventDefault={start_login}>
<div class="w-full mt-4">
<div class="dark:bg-gray-800 bg-white p-4 rounded-lg">
<div class="relative bg-inherit w-full">
<input
id="email"
bind:value={email}
name="email"
type="email"
class="w-full peer bg-transparent h-10 rounded-lg text-gray-700 dark:text-white placeholder-transparent ring-2 px-2 ring-gray-500 focus:ring-sky-600 focus:outline-none focus:border-rose-600"
placeholder={$t('words.email')}
autocomplete="email"
/>
<label
for="email"
class="absolute cursor-text left-0 -top-3 text-sm text-gray-700 dark:text-white bg-inherit mx-1 px-1 peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-500 peer-placeholder-shown:top-2 peer-focus:-top-3 peer-focus:text-sky-600 peer-focus:text-sm transition-all"
>
{$t('words.email')}
</label>
</div>
</div>
<div class="flex items-center justify-between mt-4">
<a
href="/account/reset-password"
class="text-sm text-gray-600 dark:text-gray-200 hover:text-gray-500"
>{$t('register_page.forgot_password?')}</a
>
<button
class="px-4 py-2 leading-5 text-white transition-colors duration-200 transform bg-gray-700 rounded hover:bg-gray-600 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={emailEmpty}
type="submit"
>
{#if isSubmitting}
<svg class="h-4 w-4 animate-spin mx-auto" viewBox="3 3 18 18">
<path
class="fill-black"
d="M12 5C8.13401 5 5 8.13401 5 12C5 15.866 8.13401 19 12 19C15.866 19 19 15.866 19 12C19 8.13401 15.866 5 12 5ZM3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12Z"
/>
<path
class="fill-blue-100"
d="M16.9497 7.05015C14.2161 4.31648 9.78392 4.31648 7.05025 7.05015C6.65973 7.44067 6.02656 7.44067 5.63604 7.05015C5.24551 6.65962 5.24551 6.02646 5.63604 5.63593C9.15076 2.12121 14.8492 2.12121 18.364 5.63593C18.7545 6.02646 18.7545 6.65962 18.364 7.05015C17.9734 7.44067 17.3403 7.44067 16.9497 7.05015Z"
/>
</svg>
{:else}
{$t('words.continue')}
{/if}
</button>
</div>
{#if google_auth_enabled}
<div class="flex items-center justify-center pt-4">
<a
href="/api/v1/users/oauth/google/login"
class="inline-flex w-fit p-1 rounded-lg border-gray-500 border border-2 hover:bg-[#4285F4] transition"
>Google Login
<svg
class="h-6 w-6 ml-4 dark:fill-gray-300"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><title> Google</title>
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
/>
</svg>
</a>
</div>
{/if}
{#if github_auth_enabled}
<div class="flex items-center w-full justify-center pt-4">
<a
href="/api/v1/users/oauth/github/login"
class="inline-flex w-fit p-1 rounded-lg border-gray-500 border border-2 hover:bg-[#181717] transition hover:text-white group"
>GitHub Login
<svg
class="h-6 w-6 ml-4 dark:fill-gray-300 group-hover:fill-white transition"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><title> GitHub</title>
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/>
</svg>
</a>
</div>
{/if}
</div>
</form>
</div>
<div class="flex items-center justify-center py-4 text-center bg-gray-50 dark:bg-gray-700">
<span class="text-sm text-gray-600 dark:text-gray-200"
>{$t('login_page.already_have_account')}
</span>
<a
href="/account/register"
class="mx-2 text-sm font-bold text-blue-500 dark:text-blue-400 hover:underline"
>{$t('words.register')}</a
>
</div>
@@ -0,0 +1,121 @@
<!--
- 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/.
-->
<script lang="ts">
import { getLocalization } from '$lib/i18n';
import { alertModal } from '$lib/stores';
export let session_data;
export let selected_method;
export let done;
export let step;
const { t } = getLocalization();
let isSubmitting;
let totp = '';
let totp_valid = false;
$: totp_valid = totp.length === 6;
const continue_in_login = async () => {
if (!totp_valid) {
return;
}
const res = await fetch(
`/api/v1/login/step/${step}?session_id=${session_data.session_id}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ auth_type: 'TOTP', data: totp })
}
);
if (res.status === 200) {
done = true;
} else if (res.status === 202) {
step += 1;
selected_method = null;
} else if (res.status === 401) {
let data;
try {
data = await res.json();
} catch {
alertModal.set({
open: true,
body: "This shouldn't happen. Please try again.",
title: 'Unknown error'
});
window.location.reload();
}
if (data.detail === 'totp wrong') {
alertModal.set({
open: true,
body: 'Wrong Totp-Code. please try again.',
title: 'Totp Error'
});
totp = '';
}
}
};
</script>
<div class="px-6 py-4">
<h2 class="text-3xl font-bold text-center text-gray-700 dark:text-white">ClassQuiz</h2>
<form on:submit|preventDefault={continue_in_login}>
<div class="w-full mt-4">
<div class="dark:bg-gray-800 bg-white p-4 rounded-lg">
<div class="relative bg-inherit w-full">
<input
id="totp"
bind:value={totp}
name="totp"
type="text"
class="w-full peer bg-transparent h-10 rounded-lg text-gray-700 dark:text-white placeholder-transparent ring-2 px-2 ring-gray-500 focus:ring-sky-600 focus:outline-none focus:border-rose-600"
placeholder={$t('words.totp')}
autocomplete="one-time-code"
/>
<label
for="totp"
class="absolute cursor-text left-0 -top-3 text-sm text-gray-700 dark:text-white bg-inherit mx-1 px-1 peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-500 peer-placeholder-shown:top-2 peer-focus:-top-3 peer-focus:text-sky-600 peer-focus:text-sm transition-all"
>
{$t('words.totp')}
</label>
</div>
</div>
<div class="flex items-center justify-between mt-4">
<button
on:click={() => {
selected_method = 'BACKUP';
}}
class="text-sm text-gray-600 dark:text-gray-200 hover:text-gray-500"
>{$t('login_page.use_backup_code')}</button
>
<button
class="px-4 py-2 leading-5 text-white transition-colors duration-200 transform bg-gray-700 rounded hover:bg-gray-600 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={!totp_valid}
type="submit"
>
{#if isSubmitting}
<svg class="h-4 w-4 animate-spin mx-auto" viewBox="3 3 18 18">
<path
class="fill-black"
d="M12 5C8.13401 5 5 8.13401 5 12C5 15.866 8.13401 19 12 19C15.866 19 19 15.866 19 12C19 8.13401 15.866 5 12 5ZM3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12Z"
/>
<path
class="fill-blue-100"
d="M16.9497 7.05015C14.2161 4.31648 9.78392 4.31648 7.05025 7.05015C6.65973 7.44067 6.02656 7.44067 5.63604 7.05015C5.24551 6.65962 5.24551 6.02646 5.63604 5.63593C9.15076 2.12121 14.8492 2.12121 18.364 5.63593C18.7545 6.02646 18.7545 6.65962 18.364 7.05015C17.9734 7.44067 17.3403 7.44067 16.9497 7.05015Z"
/>
</svg>
{:else}
{$t('words.continue')}
{/if}
</button>
</div>
</div>
</form>
</div>
@@ -0,0 +1,32 @@
<!--
- 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/.
-->
<div
class="flex items-center justify-center p-4 text-green-700 border-2 border-current rounded-lg bg-white"
role="alert"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
class="w-6 h-6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.2426 16.3137L6 12.071L7.41421 10.6568L10.2426 13.4853L15.8995 7.8284L17.3137 9.24262L10.2426 16.3137Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12ZM12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21Z"
fill="currentColor"
/>
</svg>
<h3 class="ml-3 text-sm font-medium">You've successfully confirmed your email address.</h3>
</div>
@@ -0,0 +1,110 @@
<!--
- 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/.
-->
<script lang="ts">
import { startAuthentication } from '@simplewebauthn/browser';
import { getLocalization } from '$lib/i18n';
import { alertModal } from '$lib/stores';
const { t } = getLocalization();
export let session_data;
export let selected_method;
export let done;
export let step;
let isLoading = false;
const start_thing = async () => {
const data = JSON.parse(session_data.webauthn_data);
let asseResp;
isLoading = true;
try {
asseResp = await startAuthentication(data);
} catch (e) {
console.error(e);
alertModal.set({
open: true,
body: e,
title: 'Unknown error'
});
isLoading = false;
}
const res = await fetch(
`/api/v1/login/step/${step}?session_id=${session_data.session_id}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ auth_type: 'PASSKEY', data: asseResp })
}
);
if (res.status === 200) {
done = true;
} else if (res.status === 202) {
step += 1;
selected_method = null;
} else if (res.status === 401) {
let data;
try {
data = await res.json();
} catch {
alertModal.set({
open: true,
body: "This shouldn't happen. Please try again.",
title: 'Unknown error'
});
window.location.reload();
}
if (data.detail === 'webauthn failed') {
alertModal.set({
open: true,
body: 'Webauthn failed. Please try again.',
title: 'Webauthn Error'
});
}
}
isLoading = false;
};
</script>
<div class="px-6 py-4">
<h2 class="text-3xl font-bold text-center text-gray-700 dark:text-white">ClassQuiz</h2>
<p class="mt-1 text-center text-gray-500 dark:text-gray-400">
Start the Security-Key verification
</p>
<div class="w-full mt-4">
<div class="flex items-center justify-between mt-4">
<button
on:click={() => {
selected_method = 'BACKUP';
}}
class="text-sm text-gray-600 dark:text-gray-200 hover:text-gray-500"
>{$t('login_page.use_backup_code')}</button
>
<button
class="px-4 py-2 leading-5 text-white transition-colors duration-200 transform bg-gray-700 rounded hover:bg-gray-600 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={isLoading}
on:click={start_thing}
>
{#if isLoading}
<svg class="h-4 w-4 animate-spin mx-auto" viewBox="3 3 18 18">
<path
class="fill-black"
d="M12 5C8.13401 5 5 8.13401 5 12C5 15.866 8.13401 19 12 19C15.866 19 19 15.866 19 12C19 8.13401 15.866 5 12 5ZM3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12Z"
/>
<path
class="fill-blue-100"
d="M16.9497 7.05015C14.2161 4.31648 9.78392 4.31648 7.05025 7.05015C6.65973 7.44067 6.02656 7.44067 5.63604 7.05015C5.24551 6.65962 5.24551 6.02646 5.63604 5.63593C9.15076 2.12121 14.8492 2.12121 18.364 5.63593C18.7545 6.02646 18.7545 6.65962 18.364 7.05015C17.9734 7.44067 17.3403 7.44067 16.9497 7.05015Z"
/>
</svg>
{:else}
{$t('words.start')}
{/if}
</button>
</div>
</div>
</div>
@@ -161,20 +161,29 @@
{#await getUser()}
<Spinner />
{:then user}
<div class="w-full">
<div class="sm:flex space-x-7 md:items-start items-center">
<div class="mb-4">
<img
class="rounded-md md:w-80"
src="/api/v1/users/avatar"
alt="Profile image of {user.username}"
/>
<div class="w-full grid grid-cols-6">
<img
class="rounded-md md:w-80"
src="/api/v1/users/avatar"
alt="Profile image of {user.username}"
/>
<div class="grid grid-rows-2 col-start-2 col-end-7">
<div class="grid grid-cols-2">
<div>
<h1 class="text-4xl font-bold my-2">{user.username}</h1>
<p class="text-lg mb-6 md:max-w-lg">
{$t('words.email')}: {user.email}
</p>
</div>
<div class="p-4 flex justify-center">
<a
href="/account/settings/security"
class="text-lg rounded-lg bg-[#B07156] p-2 hover:bg-opacity-80 transition h-fit m-auto"
>Security-Settings</a
>
</div>
</div>
<div>
<h1 class="text-4xl font-bold my-2">{user.username}</h1>
<p class="text-lg mb-6 md:max-w-lg">
{$t('words.email')}: {user.email}
</p>
<form class="flex flex-col md:flex-row" on:submit|preventDefault={changePassword}>
<label
>{$t('settings_page.old_password')}:<input
@@ -0,0 +1,215 @@
<!--
- 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/.
-->
<script lang="ts">
import Spinner from '$lib/Spinner.svelte';
import { browser } from '$app/environment';
import { startRegistration } from '@simplewebauthn/browser';
import TotpSetup from './totp_setup.svelte';
import BackupCodes from './backup_codes.svelte';
let user_data: object | undefined;
let security_keys: Array<{ id: number }> | undefined;
let totp_activated: boolean | undefined;
let totp_data;
let backup_code;
const get_data = async () => {
const res1 = await fetch('/api/v1/users/me');
user_data = await res1.json();
const res2 = await fetch('/api/v1/users/webauthn/list');
security_keys = await res2.json();
const res3 = await fetch('/api/v1/users/2fa/totp');
totp_activated = (await res3.json()).activated;
};
let data = get_data();
const save_password_required = async () => {
console.log(user_data?.require_password, 'here');
if (!browser || user_data?.require_password === undefined) {
return;
}
const res = await fetch('/api/v1/users/2fa/require_password', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ require_password: user_data?.require_password })
});
user_data.require_password = (await res.json()).require_password;
};
const add_security_key = async () => {
const res1 = await fetch('/api/v1/users/webauthn/add_key');
if (!res1.ok) {
throw Error('Response not ok');
}
let attResp;
const resp_data = await res1.json();
try {
resp_data.authenticatorSelection.authenticatorAttachment = 'cross-platform';
for (let i = 0; i++; i < resp_data.excludeCredentials.length) {
resp_data.excludeCredentials[i].transports = undefined;
}
attResp = await startRegistration(resp_data);
} catch (e) {
throw e;
}
await fetch('/api/v1/users/webauthn/add_key', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(attResp)
});
data = get_data();
};
const remove_security_key = async (key_id: number) => {
await fetch(`/api/v1/users/webauthn/key/${key_id}`, { method: 'DELETE' });
data = get_data();
};
const disable_totp = async () => {
await fetch(`/api/v1/users/2fa/totp`, { method: 'DELETE' });
data = get_data();
};
const enable_totp = async () => {
const res = await fetch('/api/v1/users/2fa/totp', { method: 'POST' });
data = get_data();
totp_data = await res.json();
};
const get_backup_code = async () => {
const res = await fetch('/api/v1/users/2fa/backup_code');
backup_code = (await res.json()).code;
};
$: console.log(user_data?.require_password, 'hello');
</script>
{#await data}
<Spinner my_20={false} />
{:then _}
<div class="grid grid-rows-2 h-screen">
<div class="grid grid-cols-2 h-full border-b-2 border-black">
<div class="h-full w-full border-r-2 border-black">
<h2 class="text-center text-2xl">Backup-Code</h2>
<div class="flex h-full w-full justify-center">
<button
class="m-auto text-lg rounded-lg bg-[#B07156] p-4 hover:bg-opacity-80 transition"
on:click={get_backup_code}
>Get Backup-Codes
</button>
</div>
</div>
<div class="h-full w-full">
<h2 class="text-center text-2xl">Activate 2 Factor</h2>
<div class="flex h-full w-full justify-center flex-col">
<div class="m-auto">
{#if user_data.require_password}
<div class="flex items-center space-x-2">
<button
on:click={() => {
user_data.require_password = !user_data.require_password;
save_password_required();
}}
type="button"
role="switch"
aria-checked="true"
class="relative inline-flex h-5 w-8 shrink-0 cursor-pointer appearance-none rounded-full border-2 border-transparent bg-blue-700 transition focus:outline-none focus:ring focus:ring-blue-200"
>
<span
aria-hidden="true"
class="pointer-events-none inline-block h-4 w-4 translate-x-3 rounded-full bg-white transition will-change-transform"
/>
</button>
<span class="text-sm font-medium text-gray-700"
>Two Factor authentication is activated</span
>
</div>
{:else}
<div class="flex items-center space-x-2">
<button
type="button"
on:click={() => {
user_data.require_password = !user_data.require_password;
save_password_required();
}}
role="switch"
aria-checked="false"
class="relative inline-flex h-5 w-8 shrink-0 cursor-pointer appearance-none rounded-full border-2 border-transparent bg-gray-200 transition focus:outline-none focus:ring focus:ring-blue-200"
>
<span
aria-hidden="true"
class="pointer-events-none inline-block h-4 w-4 translate-x-0 rounded-full bg-white transition will-change-transform"
/>
</button>
<span class="text-sm font-medium text-gray-700"
>Two Factor authentication is deactivated</span
>
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="grid grid-cols-2 h-full">
<div class="h-full w-full flex flex-col border-r-2 border-black">
<h2 class="text-center text-2xl">Webauthn</h2>
<div class="flex justify-center">
{#if security_keys.length > 0}
<p>Webauthn is available</p>
{:else}
<p>Webauthn is not available</p>
{/if}
</div>
<div class="flex justify-center">
<button on:click={add_security_key}>Add Security-Key</button>
</div>
<div class="flex justify-center">
<ul class="list-disc block">
{#each security_keys as key, i}
<li>
<button
on:click={() => {
remove_security_key(key.id);
}}
class="hover:line-through transition">{i + 1}</button
>
</li>
{/each}
</ul>
</div>
</div>
<div class="h-full w-full flex flex-col">
<h2 class="text-center text-2xl">Totp</h2>
<div class="flex justify-center">
{#if totp_activated}
<p>Totp is available</p>
{:else}
<p>Totp is not available</p>
{/if}
</div>
<div class="flex justify-center">
{#if totp_activated}
<button on:click={disable_totp}>Disable Totp</button>
{:else}
<button on:click={enable_totp}>Enable Totp</button>
{/if}
</div>
</div>
</div>
</div>
{/await}
{#if totp_data}
<TotpSetup bind:totp_data />
{/if}
{#if backup_code}
<BackupCodes bind:backup_code />
{/if}
@@ -0,0 +1,54 @@
<!--
- 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/.
-->
<script lang="ts">
export let backup_code;
let already_downloaded = false;
const download_code = (force: boolean = false) => {
if (already_downloaded && !force) {
return;
}
const el = document.createElement('a');
el.setAttribute('href', `data:text/plain;charset=utf-8,${backup_code}`);
el.setAttribute('download', 'ClassQuiz-Backup-Code.txt');
el.style.display = 'none';
document.body.appendChild(el);
el.click();
document.body.removeChild(el);
already_downloaded = true;
};
</script>
<div class="w-screen h-screen fixed top-0 left-0 p-48 z-30 bg-black bg-opacity-50">
<div class="w-full h-full">
<button
class="bg-gray-200 px-2 py-1 rounded-t-lg hover:bg-gray-300 transition"
on:click={() => {
backup_code = undefined;
}}
>Close
</button>
<div class="bg-white rounded-b-lg rounded-tr-lg w-full h-full flex flex-col">
<h2 class="text-3xl m-auto">Your Backup-Code</h2>
<p
class="select-all font-mono text-xl m-auto"
on:click={() => {
download_code(false);
}}
>
{backup_code}
</p>
<p class="m-auto">Save this somewhere safe!</p>
<button
on:click={() => {
download_code(true);
}}
class="m-auto p-2 bg-[#B07156] rounded-lg">Download code</button
>
</div>
</div>
</div>
@@ -0,0 +1,60 @@
<!--
- 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/.
-->
<script lang="ts">
import QRCode from 'qrcode';
import Spinner from '$lib/Spinner.svelte';
export let totp_data: { url: string; secret: string } | undefined;
const get_image_url = async () => {
return await QRCode.toDataURL(totp_data.url);
};
</script>
<div class="w-screen h-screen fixed top-0 left-0 p-48 z-30 bg-black bg-opacity-50">
<div class="w-full h-full">
<button
class="bg-gray-200 px-2 py-1 rounded-t-lg hover:bg-gray-300 transition"
on:click={() => {
totp_data = undefined;
}}
>Close
</button>
<div class="bg-white rounded-b-lg rounded-tr-lg w-full h-full">
<div class="grid grid-cols-3 w-full h-full">
<div class="flex flex-col justify-center w-full h-5/6">
<span class="m-auto" />
<div class="h-5/6 flex">
<p class="my-auto ml-auto">Scan this to set up the code</p>
</div>
<div class="flex">
<p class="my-auto ml-auto">
Enter this as the secret if you can't scan the code
</p>
</div>
</div>
<div class="flex flex-col justify-start w-full h-5/6">
<h2 class="text-2xl m-auto">Totp-Setup</h2>
{#await get_image_url()}
<Spinner my_20={false} />
{:then data}
<div class="m-auto h-5/6 object-contain w-full">
<img
src={data}
alt="QR-Code for Totp-setup"
class="w-full h-full object-contain"
/>
</div>
{/await}
<p class="m-auto select-all font-mono">{totp_data.secret}</p>
</div>
<div class="flex justify-center h-5/6 w-full">
<p class="m-auto text-3xl p-4">Do not forget to save your recovery-code!</p>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,40 @@
"""Added webauthn
Revision ID: 25f2c34a69c8
Revises: 97144a8cf6b6
Create Date: 2022-12-17 16:53:28.909124
"""
from alembic import op
import sqlalchemy as sa
import ormar
# revision identifiers, used by Alembic.
revision = "25f2c34a69c8"
down_revision = "97144a8cf6b6"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"fido_credentials",
sa.Column("pk", sa.Integer(), nullable=False),
sa.Column("id", sa.LargeBinary(length=256), nullable=False),
sa.Column("public_key", sa.LargeBinary(length=256), nullable=False),
sa.Column("sign_count", sa.Integer(), nullable=False),
sa.Column("user", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True),
sa.ForeignKeyConstraint(["user"], ["users.id"], name="fk_fido_credentials_users_id_user"),
sa.PrimaryKeyConstraint("pk"),
)
op.add_column("users", sa.Column("require_password", sa.Boolean(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("users", "require_password")
op.drop_table("fido_credentials")
# ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""Added totp
Revision ID: 694cb11c6886
Revises: 901dfcdf8d38
Create Date: 2022-12-18 13:20:48.091675
"""
from alembic import op
import sqlalchemy as sa
import ormar
# revision identifiers, used by Alembic.
revision = "694cb11c6886"
down_revision = "901dfcdf8d38"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("users", sa.Column("totp_secret", sa.String(length=32), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("users", "totp_secret")
# ### end Alembic commands ###
@@ -0,0 +1,45 @@
"""Added Backup-code
Revision ID: 901dfcdf8d38
Revises: 25f2c34a69c8
Create Date: 2022-12-18 11:48:22.772981
"""
import os
from alembic import op
import sqlalchemy as sa
from sqlalchemy.orm import Session
import ormar
# revision identifiers, used by Alembic.
revision = "901dfcdf8d38"
down_revision = "25f2c34a69c8"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("users", sa.Column("backup_code", sa.String(length=64), nullable=True))
conn = op.get_bind()
session = Session(bind=conn)
res = session.execute("SELECT id from users;")
for row in res:
user_id = str(row).strip(",.'()")
session.execute(
sa.sql.text("UPDATE users SET backup_code = :backup_code WHERE users.id=:user_id"),
{"user_id": user_id, "backup_code": os.urandom(32).hex()},
)
op.alter_column("users", "backup_code", nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("users", "backup_code")
# ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""Added github_user_id
Revision ID: 97144a8cf6b6
Revises: b2acaede5c2f
Create Date: 2022-12-17 16:25:44.446361
"""
from alembic import op
import sqlalchemy as sa
import ormar
# revision identifiers, used by Alembic.
revision = "97144a8cf6b6"
down_revision = "b2acaede5c2f"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("users", sa.Column("github_user_id", sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("users", "github_user_id")
# ### end Alembic commands ###