finished auth overhaul and started tenants

This commit is contained in:
2025-10-14 01:30:22 +02:00
parent 5591a77870
commit 02fc60151f
10 changed files with 215 additions and 86 deletions

View File

@@ -9,7 +9,7 @@ users: Dict[str, User]
def init_auth(app: Flask):
login_manager.init_app(app)
login_manager.login_view = "main.login" # type: ignore
login_manager.login_view = "main.admin_login" # type: ignore
global users
users = {"admin": User("admin", app.config["ADMIN_PASSWORD"])}

View File

@@ -5,9 +5,8 @@ from typing import List, Optional
import pytz
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Boolean, Column, DateTime, String, TypeDecorator
from sqlalchemy import Boolean, Column, DateTime, String, TypeDecorator, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
db : SQLAlchemy = SQLAlchemy()
deck_password = db.Table(
@@ -69,19 +68,25 @@ class Deck(db.Model):
passwords : Mapped[List[Password]] = relationship("Password", secondary=deck_password, back_populates='decks')
tokens : Mapped[List[Token]] = relationship("Token", secondary=deck_token, back_populates='decks')
course_id: Mapped[int] = mapped_column(ForeignKey("course.id"))
course: Mapped['Course'] = relationship("Course", back_populates='decks')
def __init__(self, name: str, deck: str, description: str = "") -> None:
def __init__(self, name: str, deck: str, course: Course, description: str = "") -> None:
self.name = name
self.deck = deck
self.description = description
self.course = course
def __repr__(self) -> str:
return f"<Deck(name={self.name}, deck={self.deck})>"
return f"<Deck(name={self.name}, deck={self.deck}, course={self.course})>"
class Token(db.Model):
__tablename__ = "token"
id : Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(60), unique=True, nullable=False)
active_course_id: Mapped[int] = mapped_column(ForeignKey("course.id"), nullable=True)
active_course: Mapped[Course] = relationship("Course", foreign_keys=[active_course_id])
decks : Mapped[List[Deck]]= relationship("Deck", secondary=deck_token, back_populates='tokens')
def __init__(self, name: str) -> None:
@@ -93,6 +98,19 @@ class Token(db.Model):
def is_unlocked(self, deck: str) -> bool :
return deck in [deck.deck for deck in self.decks]
class Course(db.Model):
__tablename__ = "course"
id : Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(60), unique=True, nullable=False)
decks: Mapped[List['Deck']] = relationship(back_populates='course')
def __init__(self, name: str) -> None:
self.name = name
def __repr__(self) -> str:
return f"<Course(id={self.id}, name={self.name})>"
class InfoBanner(db.Model):
__tablename__ = "info_banner"
id : Mapped[int] = mapped_column(primary_key=True)

View File

@@ -12,7 +12,6 @@ from flask import (
abort,
current_app,
flash,
jsonify,
make_response,
redirect,
render_template,
@@ -29,8 +28,8 @@ from .models import Deck, InfoBanner, Password, db
from .utils import (
create_token,
generate_password,
generate_token,
get_active_banner,
get_all_courses,
get_available_decks,
get_token,
get_unlocked_decks,
@@ -38,6 +37,7 @@ from .utils import (
retrieve_token,
serialize_token,
update_token,
get_course
)
@@ -98,9 +98,39 @@ def inject_confused_button(response):
def index():
token = get_token()
if not token:
return redirect(url_for("main.register"))
return redirect(url_for("main.set_token"))
else:
return render_template('index.html', active_banner=get_active_banner(), decks=get_unlocked_decks(token), user_name=token.name)
return render_template('index.html', active_banner=get_active_banner(), decks=get_unlocked_decks(token), user_name=token.name, active_course_id=token.active_course_id, courses=get_all_courses())
@main.route('/set_token', methods=['POST', 'GET'])
def set_token():
"""
Handles.
GET: Renders the registration form.
POST: Processes the form submission.
"""
if request.method == 'POST':
# Get form data
user_name = request.form.get('username')
# Simple validation
if not user_name:
flash('Username is required.', 'error')
return render_template('set_token.html')
token = retrieve_token(user_name)
if not token:
flash(f'The username "{user_name}" does not exist.', 'error')
return render_template('set_token.html')
resp = make_response(redirect(url_for("main.index")))
resp.set_cookie('access_token', serialize_token(token), httponly=True)
return resp
# If GET request, render the form
return render_template('set_token.html')
@main.route('/register', methods=['POST', 'GET'])
def register():
@@ -134,15 +164,34 @@ def register():
resp = make_response(redirect(url_for("main.index")))
resp.set_cookie('access_token', serialize_token(token), httponly=True)
flash(f'Registration successful for {user_name}!', 'success')
# Redirect to the login page after successful registration
return resp
# If GET request, render the form
return render_template('register.html')
@main.route('/login', methods=['POST', 'GET'])
def login():
@main.route('/logout', methods=['POST'])
def logout():
resp = make_response(redirect(url_for('main.index'))) # Redirect to a safe page, e.g., index or login
resp.set_cookie('access_token', '', expires=0) # Clears the cookie
return resp
@main.route('/switch_course/<new_course>', methods=['POST'])
def switch_course(new_course: int):
token = get_token()
if not token:
return redirect(url_for('main.set_token'))
switched = get_course(new_course)
if switched:
token.active_course = switched
db.session.commit()
return redirect(url_for("main.index"))
@main.route('/admin/login', methods=['POST', 'GET'])
def admin_login():
form = LoginForm()
if form.validate_on_submit():
assert isinstance(form.user_name.data, str)
@@ -152,8 +201,6 @@ def login():
user = user_loader(user_name)
if user and check_password(user, password):
flask_login.login_user(user)
flash('Login successful!', 'success')
next = request.args.get('next')
if next is None:
return redirect(url_for('main.index'))
@@ -164,7 +211,7 @@ def login():
return redirect(next)
else:
flash('Incorrect username or password. Please try again.', 'error')
return render_template("login.html", form=form)
return render_template("admin_login.html", form=form)
@main.route('/access', methods=['POST'])
@@ -174,7 +221,7 @@ def access():
token = get_token()
if password is not None and not password.is_expired():
update_token(password.decks)
return redirect('/')
return redirect(url_for('main.index'))
return render_template('index.html', error="Invalid password.", decks=get_unlocked_decks(token))
@@ -185,14 +232,13 @@ def access_link(access_code: str):
token = get_token()
if password is not None and not password.is_expired():
update_token(password.decks)
return redirect('/')
return redirect(url_for('main.index'))
return render_template('index.html', error="Invalid password.", decks=get_unlocked_decks(token))
@main.route('/confused', methods=['POST'])
def confused():
socketio.emit(
"confused",
{
@@ -202,18 +248,6 @@ def confused():
)
return ("", 204)
@main.route('/retrieve-token', methods=['POST'])
def set_username_token():
new_username : str = request.form.get("user_name", "").strip()
resp = make_response(redirect('/'))
if new_username:
token = retrieve_token(new_username)
if token:
resp.set_cookie('access_token', serialize_token(token), httponly=True)
return resp
@main.route('/slides/<deck>/')
@main.route('/slides/<deck>/<path:path>')
def serve_deck(deck, path='index.html'):
@@ -221,7 +255,7 @@ def serve_deck(deck, path='index.html'):
abort(404)
if not is_deck_unlocked(deck):
return redirect('/')
return redirect(url_for('main.index'))
slides_dir = current_app.config['SLIDES_DIR']
deck_path = os.path.join(slides_dir, deck)

View File

@@ -17,7 +17,7 @@
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
<div class="w-full max-w-sm p-6 bg-gray-800 rounded-2xl shadow-lg">
<h2 class="text-2xl font-bold mb-6 text-center text-white">Login</h2>
<h2 class="text-2xl font-bold mb-6 text-center text-white">Admin Login</h2>
<!-- Display Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}

View File

@@ -41,59 +41,64 @@
<!-- Header -->
<header class="bg-surface border-b border-border shadow-sm">
<div
class="max-w-7xl mx-auto px-6 py-4
grid grid-cols-2 items-center gap-4"
>
<!-- 1) form in column 1 -->
<form
method="POST"
action="/retrieve-token"
class="relative z-10 flex flex-col space-y-2"
>
<input
type="text"
name="user_name"
value="{{ user_name }}"
class="bg-transparent text-muted text-sm font-medium w-auto min-w-max whitespace-nowrap px-2 py-1 rounded-md border border-transparent focus:border-primary focus:outline-none focus:bg-background"
size="{{ user_name|length }}"
disabled
id="usernameInput"
/>
<div class="w-auto min-w-max whitespace-nowrap">
<button
type="button"
onclick="enableUsernameEdit()"
class="text-sm text-white bg-primary px-3 py-1 rounded-md hover:bg-blue-700"
id="editBtn"
<div
class="w-full px-6 py-4
flex items-center justify-between"
>
Username ändern
<div class="relative z-20">
<button id="menu-toggle" class="text-white hover:text-gray-300 focus:outline-none p-2 rounded-md">
<svg class="w-6 h-6" 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="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<button
type="submit"
class="hidden text-sm text-white bg-primary px-3 py-1 rounded-md hover:bg-blue-700"
id="saveBtn"
>
Save
</button>
</div>
</form>
<!-- 2) admin button in column 2 -->
<div class="flex justify-end">
<a href="/admin">
<button class="bg-primary hover:bg-blue-700 text-white px-3 py-1 rounded-md transition">
Admin
</button>
</a>
<div class="hamburger-menu">
<button class="menu-toggle" aria-controls="course-selector">Menu</button>
<select id="course-selector" name="course_selection" onchange="this.form.submit()">
<option value="" disabled selected>Select a Course</option>
{% for course in courses %}
<option value="{{ course.id }}">
{{ course.display_name }}
</option>
{% endfor %}
</select>
</div>
</div>
</header>
<!-- Main Content -->
<div class="flex items-center space-x-1 relative z-10">
<span class="text-white text-base font-medium whitespace-nowrap py-1">
User: {{ user_name }}
</span>
<form method="POST" action="/logout">
<button
type="submit"
class="text-sm text-white bg-red-600 px-3 py-1 rounded-md hover:bg-red-700 transition"
>
Logout
</button>
</form>
<a href="/admin" class="ml-4">
<button class="bg-primary hover:bg-blue-700 text-white px-3 py-1 rounded-md transition">
Admin
</button>
</a>
</div>
</div>
</header>
<script>
document.getElementById('menu-toggle').addEventListener('click', function() {
document.getElementById('course-menu').classList.toggle('hidden');
});
</script> <!-- Main Content -->
<main class="max-w-5xl mx-auto px-6 py-10 space-y-12">
<h3 class="text-3xl font-bold text-center mb-8">GRNVS Tutorium SS 25</h3>
<h3 class="text-3xl font-bold text-center mb-8">{{course_display_name}}</h3>
{% if active_banner %}

View File

@@ -20,6 +20,19 @@
<p class="text-muted text-sm">Choose a username to get started.</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="space-y-3 mb-6">
{% for category, message in messages %}
{% set alert_class = 'bg-red-900 border-red-400 text-red-100' if category == 'error' else 'bg-green-900 border-green-400 text-green-100' %}
<div class="p-3 border rounded-lg {{ alert_class }}" role="alert">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form action="/register" method="POST" class="space-y-6">
<!-- Flask-WTF form fields will be injected here -->
<div class="mb-4">
@@ -46,7 +59,7 @@
</form>
<p class="text-center mt-4 text-sm">
Already have an account? <a href="/login" class="text-blue-400 hover:underline">Log In</a>
Already have an account? <a href="/set_token" class="text-blue-400 hover:underline">Log In</a>
</p>
</div>
</body>

View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register for Tutortool</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
@apply bg-background text-text font-sans;
background-color: #1a202c;
color: #a0aec0;
}
</style>
</head>
<body class="flex items-center justify-center min-h-screen">
<div class="bg-gray-800 p-8 rounded-lg shadow-lg max-w-md w-full border border-gray-700">
<div class="text-center mb-6">
<h1 class="text-3xl font-bold text-white mb-2">Login</h1>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="space-y-3 mb-6">
{% for category, message in messages %}
{% set alert_class = 'bg-red-900 border-red-400 text-red-100' if category == 'error' else 'bg-green-900 border-green-400 text-green-100' %}
<div class="p-3 border rounded-lg {{ alert_class }}" role="alert">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form action="/set_token" method="POST" class="space-y-6">
<!-- Flask-WTF form fields will be injected here -->
<div class="mb-4">
<label for="username" class="block text-sm font-medium text-gray-400 mb-2">Username</label>
<input type="text" id="username" name="username" placeholder="Your unique username" required class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg transition-colors">
Login
</button>
</form>
<p class="text-center mt-4 text-sm">
Do not have an Account? <a href="/register" class="text-blue-400 hover:underline">Create one</a>
</p>
</div>
</body>
</html>

View File

@@ -5,7 +5,7 @@ from typing import cast
from itsdangerous import BadSignature, URLSafeSerializer
from xkcdpass import xkcd_password
from .models import Deck, InfoBanner, Token, db
from .models import Deck, InfoBanner, Token, db, Course
def init_password_generator():
wordfile = xkcd_password.locate_wordfile()
@@ -57,7 +57,7 @@ def get_token() -> (Token | None):
return None
try:
id = serializer.loads(token)
token = db.session.execute(db.select(Token).filter_by(id=id)).scalar_one_or_none()
token = db.session.get(Token, id)
return token
except BadSignature:
return None
@@ -65,7 +65,6 @@ def get_token() -> (Token | None):
def retrieve_token(username : str):
return db.session.execute(db.select(Token).filter_by(name=username)).scalar_one_or_none()
def get_unlocked_decks(token: (Token|None)):
if token is None:
return []
@@ -90,3 +89,9 @@ def update_token(decks: list[Deck]):
else:
token.decks = list(set(decks + token.decks))
db.session.commit()
def get_course(id: int) -> (Course | None):
return db.session.get(Course, id)
def get_all_courses() -> list[Course]:
return db.session.execute(db.select(Course).order_by(Course.id)).scalars().all()

View File

@@ -4,4 +4,5 @@ class Config:
SQLALCHEMY_DATABASE_URI =
SQLALCHEMY_TRACK_MODIFICATIONS =
ADMIN_PASSWORD =
COURSE_MAP =

View File

@@ -3,4 +3,4 @@ from app import create_app, socketio
app = create_app()
if __name__ == '__main__':
socketio.run(app, host="127.0.0.1", port=5000, debug=True)
socketio.run(app, host="0.0.0.0", port=5000, debug=True)