add admin init page

This commit is contained in:
Markbeep
2025-02-15 21:05:53 +01:00
parent 5b51a822c5
commit 180d00d5b1
6 changed files with 146 additions and 22 deletions
+7 -1
View File
@@ -20,12 +20,18 @@ async def redirect_to_init(request: Request, call_next: Any):
Initial redirect if no user exists. We force the user to create a new login
"""
global user_exists
if not user_exists and request.url.path not in ["/init", "/globals.css"]:
if (
not user_exists
and request.url.path not in ["/init", "/globals.css"]
and request.method == "GET"
):
session = next(get_session())
user_count = session.exec(select(func.count()).select_from(User)).one()
if user_count == 0:
return RedirectResponse("/init")
else:
user_exists = True
elif user_exists and request.url.path.startswith("/init"):
return RedirectResponse("/")
response = await call_next(request)
return response
+2 -2
View File
@@ -7,5 +7,5 @@ class BaseModel(SQLModel):
class User(BaseModel, table=True):
username: bytes = Field(primary_key=True)
password: bytes
username: str = Field(primary_key=True)
password: str
+34 -2
View File
@@ -1,9 +1,13 @@
import re
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import FileResponse
from jinja2_fragments.fastapi import Jinja2Blocks
from sqlmodel import Session
from app.util.auth import get_username
from app.db import get_session
from app.util.auth import create_user, get_username
router = APIRouter()
@@ -25,3 +29,31 @@ def read_root(request: Request, username: Annotated[str, Depends(get_username)])
@router.get("/init")
def read_init(request: Request):
return templates.TemplateResponse("init.html", {"request": request})
validate_password_regex = re.compile(
r"^(?=[^A-Z]*[A-Z])(?=[^a-z]*[a-z])(?=\D*\d).{8,}$"
)
@router.post("/init", status_code=201)
def create_init(
username: Annotated[str, Form()],
password: Annotated[str, Form()],
confirm_password: Annotated[str, Form()],
session: Annotated[Session, Depends(get_session)],
):
if password != confirm_password:
raise HTTPException(status_code=400, detail="Passwords do not match")
print(username, password, confirm_password, validate_password_regex.match(password))
if not validate_password_regex.match(password):
raise HTTPException(
status_code=400,
detail="Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number",
)
user = create_user(username, password)
session.add(user)
session.commit()
+19 -10
View File
@@ -1,24 +1,29 @@
from typing import Annotated
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBasic, HTTPBasicCredentials
import secrets
from sqlmodel import Session, select
from app.db import get_session
from app.models import User
security = HTTPBasic()
ph = PasswordHasher()
def create_user(username: str, password: str) -> User:
password_hash = ph.hash(password)
return User(username=username, password=password_hash)
def get_username(
session: Annotated[Session, Depends(get_session)],
credentials: Annotated[HTTPBasicCredentials, Depends(security)],
):
username_bytes = credentials.username.encode("utf-8")
password_bytes = credentials.password.encode("utf-8")
user = session.exec(
select(User).where(User.username == username_bytes)
select(User).where(User.username == credentials.username)
).one_or_none()
if not user:
@@ -28,14 +33,18 @@ def get_username(
headers={"WWW-Authenticate": "Basic"},
)
is_correct_username = secrets.compare_digest(user.username, username_bytes)
is_correct_password = secrets.compare_digest(user.password, password_bytes)
if not (is_correct_username and is_correct_password):
try:
ph.verify(user.password, credentials.password)
except VerifyMismatchError:
raise HTTPException(
status_code=401,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
if ph.check_needs_rehash(user.password):
user.password = ph.hash(credentials.password)
session.add(user)
session.commit()
return credentials.username
+1
View File
@@ -46,3 +46,4 @@ aiohttp==3.11.12
alembic==1.14.1
jinja2-fragments==1.7.0
pytailwindcss==0.2.0
argon2-cffi==23.1.0
+83 -7
View File
@@ -1,15 +1,91 @@
<!-- Admin initialization page -->
{% extends "base.html" %} {% block head %}
<title>Initialize user</title>
<title>Initialize admin user</title>
<script>
const checkEqualPasswords = function () {
const first = document.getElementById("password").value;
const second = document.getElementById("confirm_password").value;
if (first !== second && first.length > 0 && second.length > 0) {
document.getElementById("message").style.display = "block";
document.getElementById("submit").disabled = true;
} else {
document.getElementById("message").style.display = "none";
if (first.length > 0 && second.length > 0) {
document.getElementById("submit").disabled = false;
}
}
const rule = /^(?=[^A-Z]*[A-Z])(?=[^a-z]*[a-z])(?=\D*\d).{8,}$/;
if (!rule.test(first)) {
document.getElementById("password-req").style.display = "block";
document.getElementById("submit").disabled = true;
} else {
document.getElementById("password-req").style.display = "none";
}
};
</script>
{% endblock %} {% block body %}
<div class="h-screen w-full flex items-center justify-center">
<form class="flex flex-col gap-1">
<form class="flex flex-col gap-2 max-w-[30rem]" method="post" id="form">
<script>
// Redirect to the home page after initialization
const onSubmit = event => {
event.preventDefault();
const formData = new FormData(event.target);
console.log(formData);
fetch("/init", {
method: "POST",
body: formData,
})
.then(response => {
if (response.ok) {
window.location.href = "/";
} else {
alert("An error occurred. Please try again.");
}
})
.catch(error => {
console.error("Error:", error);
});
};
document.getElementById("form").addEventListener("submit", onSubmit);
</script>
<p>
No user was found in the database. Please create an admin user to get set
up.
</p>
<label for="username">Username</label>
<input id="username" type="text" class="input" />
<input id="username" name="username" type="text" class="input" required />
<label for="password">Password</label>
<input id="password" type="password" />
<label for="password">Confirm password</label>
<input id="password" type="password" />
<button>Create account</button>
<input
id="password"
name="password"
type="password"
class="input"
onkeyup="checkEqualPasswords();"
required
/>
<label for="confirm_password">Confirm password</label>
<input
id="confirm_password"
name="confirm_password"
type="password"
class="input"
onkeyup="checkEqualPasswords();"
required
/>
<span id="message" class="text-red-400 hidden">Passwords do not match</span>
<span id="password-req" class="text-red-400 hidden"
>Password must be at least 8 characters long and contain at least one
uppercase letter, one lowercase letter, and one number</span
>
<button id="submit" class="btn btn-primary" type="submit" disabled>
Create account
</button>
</form>
</div>
{% endblock %}