Require registration by mail link.

This commit is contained in:
Jens Luedicke
2025-06-28 10:33:18 +02:00
parent 5fa044e69c
commit 44809e34f0
6 changed files with 212 additions and 59 deletions

185
app.py
View File

@@ -5,9 +5,15 @@ from datetime import datetime, time, timedelta
import os import os
from sqlalchemy import func from sqlalchemy import func
from functools import wraps from functools import wraps
from flask_mail import Mail, Message
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# Configure logging # Configure logging
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
app = Flask(__name__) app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///timetrack.db' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///timetrack.db'
@@ -15,6 +21,23 @@ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev_key_for_timetrack') app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev_key_for_timetrack')
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Session lasts for 7 days app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Session lasts for 7 days
# Configure Flask-Mail
app.config['MAIL_SERVER'] = os.environ.get('MAIL_SERVER', 'smtp.example.com')
app.config['MAIL_PORT'] = int(os.environ.get('MAIL_PORT', 587))
app.config['MAIL_USE_TLS'] = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']
app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME', 'your-email@example.com')
app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD', 'your-password')
app.config['MAIL_DEFAULT_SENDER'] = os.environ.get('MAIL_DEFAULT_SENDER', 'TimeTrack <noreply@timetrack.com>')
# Log mail configuration (without password)
logger.info(f"Mail server: {app.config['MAIL_SERVER']}")
logger.info(f"Mail port: {app.config['MAIL_PORT']}")
logger.info(f"Mail use TLS: {app.config['MAIL_USE_TLS']}")
logger.info(f"Mail username: {app.config['MAIL_USERNAME']}")
logger.info(f"Mail default sender: {app.config['MAIL_DEFAULT_SENDER']}")
mail = Mail(app)
# Initialize the database with the app # Initialize the database with the app
db.init_app(app) db.init_app(app)
@@ -22,8 +45,7 @@ db.init_app(app)
def login_required(f): def login_required(f):
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
if 'user_id' not in session: if g.user is None:
flash('Please log in to access this page', 'error')
return redirect(url_for('login', next=request.url)) return redirect(url_for('login', next=request.url))
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
@@ -32,13 +54,8 @@ def login_required(f):
def admin_required(f): def admin_required(f):
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
if 'user_id' not in session: if g.user is None or not g.user.is_admin:
flash('Please log in to access this page', 'error') flash('You need administrator privileges to access this page.', 'error')
return redirect(url_for('login', next=request.url))
user = User.query.get(session['user_id'])
if not user or not user.is_admin:
flash('You need administrator privileges to access this page', 'error')
return redirect(url_for('home')) return redirect(url_for('home'))
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
@@ -50,29 +67,25 @@ def load_logged_in_user():
g.user = None g.user = None
else: else:
g.user = User.query.get(user_id) g.user = User.query.get(user_id)
if g.user and not g.user.is_verified and request.endpoint not in ['verify_email', 'static', 'logout']:
# Allow unverified users to access only verification and static resources
if request.endpoint not in ['login', 'register']:
flash('Please verify your email address before accessing this page.', 'warning')
session.clear()
return redirect(url_for('login'))
@app.route('/') @app.route('/')
def home(): def home():
if g.user: if g.user:
# Get active time entry (if any) for the current user
active_entry = TimeEntry.query.filter_by(user_id=g.user.id, departure_time=None).first()
# Get today's date
today = datetime.now().date() today = datetime.now().date()
entries = TimeEntry.query.filter(
# Get time entries for today only for the current user
today_start = datetime.combine(today, time.min)
today_end = datetime.combine(today, time.max)
today_entries = TimeEntry.query.filter(
TimeEntry.user_id == g.user.id, TimeEntry.user_id == g.user.id,
TimeEntry.arrival_time >= today_start, TimeEntry.arrival_time >= datetime.combine(today, time.min),
TimeEntry.arrival_time <= today_end TimeEntry.arrival_time <= datetime.combine(today, time.max)
).order_by(TimeEntry.arrival_time.desc()).all() ).order_by(TimeEntry.arrival_time.desc()).all()
return render_template('index.html', title='Home', active_entry=active_entry, history=today_entries) return render_template('index.html', title='Home', entries=entries)
else: else:
# Show landing page for non-logged in users
return render_template('index.html', title='Home') return render_template('index.html', title='Home')
@app.route('/login', methods=['GET', 'POST']) @app.route('/login', methods=['GET', 'POST'])
@@ -80,31 +93,27 @@ def login():
if request.method == 'POST': if request.method == 'POST':
username = request.form.get('username') username = request.form.get('username')
password = request.form.get('password') password = request.form.get('password')
remember = 'remember' in request.form
user = User.query.filter_by(username=username).first() user = User.query.filter_by(username=username).first()
if user and user.check_password(password): if user is None or not user.check_password(password):
session.clear() flash('Invalid username or password', 'error')
session['user_id'] = user.id return redirect(url_for('login'))
if remember:
session.permanent = True
next_page = request.args.get('next')
if not next_page or not next_page.startswith('/'):
next_page = url_for('home')
flash(f'Welcome back, {user.username}!', 'success')
return redirect(next_page)
flash('Invalid username or password', 'error') if not user.is_verified:
flash('Please verify your email address before logging in. Check your inbox for the verification link.', 'warning')
return redirect(url_for('login'))
session.clear()
session['user_id'] = user.id
return redirect(url_for('home'))
return render_template('login.html', title='Login') return render_template('login.html', title='Login')
@app.route('/logout') @app.route('/logout')
def logout(): def logout():
session.clear() session.clear()
flash('You have been logged out', 'info') flash('You have been logged out.', 'info')
return redirect(url_for('login')) return redirect(url_for('login'))
@app.route('/register', methods=['GET', 'POST']) @app.route('/register', methods=['GET', 'POST'])
@@ -131,19 +140,62 @@ def register():
error = 'Email already registered' error = 'Email already registered'
if error is None: if error is None:
new_user = User(username=username, email=email) try:
new_user.set_password(password) new_user = User(username=username, email=email, is_verified=False)
new_user.set_password(password)
db.session.add(new_user)
db.session.commit() # Generate verification token
token = new_user.generate_verification_token()
flash('Registration successful! You can now log in.', 'success')
return redirect(url_for('login')) db.session.add(new_user)
db.session.commit()
# Send verification email
verification_url = url_for('verify_email', token=token, _external=True)
msg = Message('Verify your TimeTrack account', recipients=[email])
msg.body = f'''Hello {username},
Thank you for registering with TimeTrack. To complete your registration, please click on the link below:
{verification_url}
This link will expire in 24 hours.
If you did not register for TimeTrack, please ignore this email.
Best regards,
The TimeTrack Team
'''
mail.send(msg)
logger.info(f"Verification email sent to {email}")
flash('Registration initiated! Please check your email to verify your account.', 'success')
return redirect(url_for('login'))
except Exception as e:
db.session.rollback()
logger.error(f"Error during registration: {str(e)}")
error = f"An error occurred during registration: {str(e)}"
flash(error, 'error') flash(error, 'error')
return render_template('register.html', title='Register') return render_template('register.html', title='Register')
@app.route('/verify_email/<token>')
def verify_email(token):
user = User.query.filter_by(verification_token=token).first()
if not user:
flash('Invalid or expired verification link.', 'error')
return redirect(url_for('login'))
if user.verify_token(token):
db.session.commit()
flash('Email verified successfully! You can now log in.', 'success')
else:
flash('Invalid or expired verification link.', 'error')
return redirect(url_for('login'))
@app.route('/admin/dashboard') @app.route('/admin/dashboard')
@admin_required @admin_required
def admin_dashboard(): def admin_dashboard():
@@ -163,6 +215,7 @@ def create_user():
email = request.form.get('email') email = request.form.get('email')
password = request.form.get('password') password = request.form.get('password')
is_admin = 'is_admin' in request.form is_admin = 'is_admin' in request.form
auto_verify = 'auto_verify' in request.form # New checkbox for auto verification
# Validate input # Validate input
error = None error = None
@@ -178,13 +231,34 @@ def create_user():
error = 'Email already registered' error = 'Email already registered'
if error is None: if error is None:
new_user = User(username=username, email=email, is_admin=is_admin) new_user = User(username=username, email=email, is_admin=is_admin, is_verified=auto_verify)
new_user.set_password(password) new_user.set_password(password)
if not auto_verify:
# Generate verification token and send email
token = new_user.generate_verification_token()
verification_url = url_for('verify_email', token=token, _external=True)
msg = Message('Verify your TimeTrack account', recipients=[email])
msg.body = f'''Hello {username},
An administrator has created an account for you on TimeTrack. To activate your account, please click on the link below:
{verification_url}
This link will expire in 24 hours.
Best regards,
The TimeTrack Team
'''
mail.send(msg)
db.session.add(new_user) db.session.add(new_user)
db.session.commit() db.session.commit()
flash(f'User {username} created successfully!', 'success') if auto_verify:
flash(f'User {username} created and automatically verified!', 'success')
else:
flash(f'User {username} created! Verification email sent.', 'success')
return redirect(url_for('admin_users')) return redirect(url_for('admin_users'))
flash(error, 'error') flash(error, 'error')
@@ -422,10 +496,15 @@ def create_tables():
# Check if we need to add new columns # Check if we need to add new columns
from sqlalchemy import inspect from sqlalchemy import inspect
inspector = inspect(db.engine) inspector = inspect(db.engine)
columns = [column['name'] for column in inspector.get_columns('time_entry')]
# Check if user table exists
if 'is_paused' not in columns or 'pause_start_time' not in columns or 'total_break_duration' not in columns: if 'user' in inspector.get_table_names():
print("WARNING: Database schema is outdated. Please run migrate_db.py to update it.") columns = [column['name'] for column in inspector.get_columns('user')]
# Check for verification columns
if 'is_verified' not in columns or 'verification_token' not in columns or 'token_expiry' not in columns:
logger.warning("Database schema is outdated. Please run migrate_db.py to update it.")
print("WARNING: Database schema is outdated. Please run migrate_db.py to update it.")
@app.route('/api/delete/<int:entry_id>', methods=['DELETE']) @app.route('/api/delete/<int:entry_id>', methods=['DELETE'])
@login_required @login_required

View File

@@ -82,6 +82,23 @@ def migrate_database():
print("Adding user_id column to work_config...") print("Adding user_id column to work_config...")
cursor.execute("ALTER TABLE work_config ADD COLUMN user_id INTEGER") cursor.execute("ALTER TABLE work_config ADD COLUMN user_id INTEGER")
# Check if the user table exists and has the verification columns
cursor.execute("PRAGMA table_info(user)")
user_columns = [column[1] for column in cursor.fetchall()]
# Add new columns to user table for email verification
if 'is_verified' not in user_columns:
print("Adding is_verified column to user table...")
cursor.execute("ALTER TABLE user ADD COLUMN is_verified BOOLEAN DEFAULT 0")
if 'verification_token' not in user_columns:
print("Adding verification_token column to user table...")
cursor.execute("ALTER TABLE user ADD COLUMN verification_token VARCHAR(100)")
if 'token_expiry' not in user_columns:
print("Adding token_expiry column to user table...")
cursor.execute("ALTER TABLE user ADD COLUMN token_expiry TIMESTAMP")
# Commit changes and close connection # Commit changes and close connection
conn.commit() conn.commit()
conn.close() conn.close()
@@ -97,13 +114,20 @@ def migrate_database():
admin = User( admin = User(
username='admin', username='admin',
email='admin@timetrack.local', email='admin@timetrack.local',
is_admin=True is_admin=True,
is_verified=True # Admin is automatically verified
) )
admin.set_password('admin') # Default password, should be changed admin.set_password('admin') # Default password, should be changed
db.session.add(admin) db.session.add(admin)
db.session.commit() db.session.commit()
print("Created admin user with username 'admin' and password 'admin'") print("Created admin user with username 'admin' and password 'admin'")
print("Please change the admin password after first login!") print("Please change the admin password after first login!")
else:
# Make sure existing admin user is verified
if not hasattr(admin, 'is_verified') or not admin.is_verified:
admin.is_verified = True
db.session.commit()
print("Marked existing admin user as verified")
# Update existing time entries to associate with admin user # Update existing time entries to associate with admin user
orphan_entries = TimeEntry.query.filter_by(user_id=None).all() orphan_entries = TimeEntry.query.filter_by(user_id=None).all()
@@ -115,9 +139,15 @@ def migrate_database():
for config in orphan_configs: for config in orphan_configs:
config.user_id = admin.id config.user_id = admin.id
# Mark all existing users as verified for backward compatibility
existing_users = User.query.filter_by(is_verified=None).all()
for user in existing_users:
user.is_verified = True
db.session.commit() db.session.commit()
print(f"Associated {len(orphan_entries)} existing time entries with admin user") print(f"Associated {len(orphan_entries)} existing time entries with admin user")
print(f"Associated {len(orphan_configs)} existing work configs with admin user") print(f"Associated {len(orphan_configs)} existing work configs with admin user")
print(f"Marked {len(existing_users)} existing users as verified")
if __name__ == "__main__": if __name__ == "__main__":
migrate_database() migrate_database()

View File

@@ -1,19 +1,26 @@
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime from datetime import datetime, timedelta
import secrets
db = SQLAlchemy() db = SQLAlchemy()
class User(db.Model): class User(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False) username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(128)) password_hash = db.Column(db.String(128))
is_admin = db.Column(db.Boolean, default=False) is_admin = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationship with TimeEntry # Email verification fields
time_entries = db.relationship('TimeEntry', backref='user', lazy='dynamic') is_verified = db.Column(db.Boolean, default=False)
verification_token = db.Column(db.String(100), unique=True, nullable=True)
token_expiry = db.Column(db.DateTime, nullable=True)
# Relationships
time_entries = db.relationship('TimeEntry', backref='user', lazy=True)
work_config = db.relationship('WorkConfig', backref='user', lazy=True, uselist=False)
def set_password(self, password): def set_password(self, password):
self.password_hash = generate_password_hash(password) self.password_hash = generate_password_hash(password)
@@ -21,6 +28,21 @@ class User(db.Model):
def check_password(self, password): def check_password(self, password):
return check_password_hash(self.password_hash, password) return check_password_hash(self.password_hash, password)
def generate_verification_token(self):
"""Generate a verification token that expires in 24 hours"""
self.verification_token = secrets.token_urlsafe(32)
self.token_expiry = datetime.utcnow() + timedelta(hours=24)
return self.verification_token
def verify_token(self, token):
"""Verify the token and mark user as verified if valid"""
if token == self.verification_token and self.token_expiry > datetime.utcnow():
self.is_verified = True
self.verification_token = None
self.token_expiry = None
return True
return False
def __repr__(self): def __repr__(self):
return f'<User {self.username}>' return f'<User {self.username}>'

View File

@@ -35,6 +35,13 @@
</label> </label>
</div> </div>
<div class="form-group">
<label class="checkbox-container">
<input type="checkbox" name="auto_verify"> Auto-verify (skip email verification)
<span class="checkmark"></span>
</label>
</div>
<div class="form-group"> <div class="form-group">
<button type="submit" class="btn btn-success">Create User</button> <button type="submit" class="btn btn-success">Create User</button>
<a href="{{ url_for('admin_users') }}" class="btn btn-secondary">Cancel</a> <a href="{{ url_for('admin_users') }}" class="btn btn-secondary">Cancel</a>

View File

@@ -13,7 +13,6 @@
<li><a href="{{ url_for('home') }}">Home</a></li> <li><a href="{{ url_for('home') }}">Home</a></li>
{% if g.user %} {% if g.user %}
<li><a href="{{ url_for('history') }}">History</a></li> <li><a href="{{ url_for('history') }}">History</a></li>
<li><a href="{{ url_for('config') }}">Config</a></li>
<li><a href="{{ url_for('about') }}">About</a></li> <li><a href="{{ url_for('about') }}">About</a></li>
<!-- Admin dropdown menu moved to the rightmost position --> <!-- Admin dropdown menu moved to the rightmost position -->
@@ -22,10 +21,21 @@
<a href="#" class="dropdown-toggle">Admin</a> <a href="#" class="dropdown-toggle">Admin</a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a href="{{ url_for('profile') }}">Profile</a></li> <li><a href="{{ url_for('profile') }}">Profile</a></li>
<li><a href="{{ url_for('config') }}">Config</a></li>
<li><a href="{{ url_for('admin_dashboard') }}">Dashboard</a></li> <li><a href="{{ url_for('admin_dashboard') }}">Dashboard</a></li>
<li><a href="{{ url_for('logout') }}">Logout</a></li> <li><a href="{{ url_for('logout') }}">Logout</a></li>
</ul> </ul>
</li> </li>
{% else %}
<!-- User dropdown menu for non-admin users -->
<li class="dropdown admin-dropdown">
<a href="#" class="dropdown-toggle">{{ g.user.username }}</a>
<ul class="dropdown-menu">
<li><a href="{{ url_for('profile') }}">Profile</a></li>
<li><a href="{{ url_for('config') }}">Config</a></li>
<li><a href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</li>
{% endif %} {% endif %}
{% else %} {% else %}
<li><a href="{{ url_for('login') }}">Login</a></li> <li><a href="{{ url_for('login') }}">Login</a></li>

View File

@@ -21,6 +21,7 @@
<div class="form-group"> <div class="form-group">
<label for="email">Email</label> <label for="email">Email</label>
<input type="email" id="email" name="email" class="form-control" required> <input type="email" id="email" name="email" class="form-control" required>
<small class="form-text text-muted">A verification link will be sent to this email address.</small>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -40,6 +41,10 @@
<div class="auth-links"> <div class="auth-links">
<p>Already have an account? <a href="{{ url_for('login') }}">Login here</a></p> <p>Already have an account? <a href="{{ url_for('login') }}">Login here</a></p>
</div> </div>
<div class="verification-notice">
<p>After registration, you'll need to verify your email address before you can log in.</p>
</div>
</form> </form>
</div> </div>
{% endblock %} {% endblock %}