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

175
app.py
View File

@@ -5,9 +5,15 @@ from datetime import datetime, time, timedelta
import os
from sqlalchemy import func
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
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
app = Flask(__name__)
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['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
db.init_app(app)
@@ -22,8 +45,7 @@ db.init_app(app)
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
flash('Please log in to access this page', 'error')
if g.user is None:
return redirect(url_for('login', next=request.url))
return f(*args, **kwargs)
return decorated_function
@@ -32,13 +54,8 @@ def login_required(f):
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
flash('Please log in 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')
if g.user is None or not g.user.is_admin:
flash('You need administrator privileges to access this page.', 'error')
return redirect(url_for('home'))
return f(*args, **kwargs)
return decorated_function
@@ -50,29 +67,25 @@ def load_logged_in_user():
g.user = None
else:
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('/')
def home():
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()
# 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(
entries = TimeEntry.query.filter(
TimeEntry.user_id == g.user.id,
TimeEntry.arrival_time >= today_start,
TimeEntry.arrival_time <= today_end
TimeEntry.arrival_time >= datetime.combine(today, time.min),
TimeEntry.arrival_time <= datetime.combine(today, time.max)
).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:
# Show landing page for non-logged in users
return render_template('index.html', title='Home')
@app.route('/login', methods=['GET', 'POST'])
@@ -80,31 +93,27 @@ def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
remember = 'remember' in request.form
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
session.clear()
session['user_id'] = user.id
if remember:
session.permanent = True
if user is None or not user.check_password(password):
flash('Invalid username or password', 'error')
return redirect(url_for('login'))
next_page = request.args.get('next')
if not next_page or not next_page.startswith('/'):
next_page = url_for('home')
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'))
flash(f'Welcome back, {user.username}!', 'success')
return redirect(next_page)
flash('Invalid username or password', 'error')
session.clear()
session['user_id'] = user.id
return redirect(url_for('home'))
return render_template('login.html', title='Login')
@app.route('/logout')
def logout():
session.clear()
flash('You have been logged out', 'info')
flash('You have been logged out.', 'info')
return redirect(url_for('login'))
@app.route('/register', methods=['GET', 'POST'])
@@ -131,19 +140,62 @@ def register():
error = 'Email already registered'
if error is None:
new_user = User(username=username, email=email)
new_user.set_password(password)
try:
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')
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')
@admin_required
def admin_dashboard():
@@ -163,6 +215,7 @@ def create_user():
email = request.form.get('email')
password = request.form.get('password')
is_admin = 'is_admin' in request.form
auto_verify = 'auto_verify' in request.form # New checkbox for auto verification
# Validate input
error = None
@@ -178,13 +231,34 @@ def create_user():
error = 'Email already registered'
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)
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.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'))
flash(error, 'error')
@@ -422,10 +496,15 @@ def create_tables():
# Check if we need to add new columns
from sqlalchemy import inspect
inspector = inspect(db.engine)
columns = [column['name'] for column in inspector.get_columns('time_entry')]
if 'is_paused' not in columns or 'pause_start_time' not in columns or 'total_break_duration' not in columns:
print("WARNING: Database schema is outdated. Please run migrate_db.py to update it.")
# Check if user table exists
if 'user' in inspector.get_table_names():
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'])
@login_required

View File

@@ -82,6 +82,23 @@ def migrate_database():
print("Adding user_id column to work_config...")
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
conn.commit()
conn.close()
@@ -97,13 +114,20 @@ def migrate_database():
admin = User(
username='admin',
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
db.session.add(admin)
db.session.commit()
print("Created admin user with username 'admin' and password 'admin'")
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
orphan_entries = TimeEntry.query.filter_by(user_id=None).all()
@@ -115,9 +139,15 @@ def migrate_database():
for config in orphan_configs:
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()
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"Marked {len(existing_users)} existing users as verified")
if __name__ == "__main__":
migrate_database()

View File

@@ -1,19 +1,26 @@
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
from datetime import datetime, timedelta
import secrets
db = SQLAlchemy()
class User(db.Model):
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)
password_hash = db.Column(db.String(128))
is_admin = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationship with TimeEntry
time_entries = db.relationship('TimeEntry', backref='user', lazy='dynamic')
# Email verification fields
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):
self.password_hash = generate_password_hash(password)
@@ -21,6 +28,21 @@ class User(db.Model):
def check_password(self, 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):
return f'<User {self.username}>'

View File

@@ -35,6 +35,13 @@
</label>
</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">
<button type="submit" class="btn btn-success">Create User</button>
<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>
{% if g.user %}
<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>
<!-- Admin dropdown menu moved to the rightmost position -->
@@ -22,10 +21,21 @@
<a href="#" class="dropdown-toggle">Admin</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('admin_dashboard') }}">Dashboard</a></li>
<li><a href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</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 %}
{% else %}
<li><a href="{{ url_for('login') }}">Login</a></li>

View File

@@ -21,6 +21,7 @@
<div class="form-group">
<label for="email">Email</label>
<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 class="form-group">
@@ -40,6 +41,10 @@
<div class="auth-links">
<p>Already have an account? <a href="{{ url_for('login') }}">Login here</a></p>
</div>
<div class="verification-notice">
<p>After registration, you'll need to verify your email address before you can log in.</p>
</div>
</form>
</div>
{% endblock %}