finished auth overhaul and started tenants
This commit is contained in:
@@ -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"])}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
<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 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 %}
|
||||
|
||||
@@ -19,7 +19,20 @@
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Create an Account</h1>
|
||||
<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>
|
||||
|
||||
53
app/templates/set_token.html
Normal file
53
app/templates/set_token.html
Normal 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>
|
||||
|
||||
11
app/utils.py
11
app/utils.py
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user