Files
TimeTracker/app/models/user.py
Dries Peeters 5c11010095 feat(oidc): add optional OIDC login via Authlib; config, routes, docs
- Add AUTH_METHOD switch (local | oidc | both); default remains local
- Update login UI to conditionally show SSO button and/or local form
- Add Authlib and initialize OAuth client (discovery-based) in app factory
- Implement OIDC Authorization Code flow with PKCE:
  - GET /login/oidc → starts auth flow, preserves `next`
  - GET /auth/oidc/callback → exchanges code, parses ID token, fetches userinfo
  - Maps claims to username/full_name/email; admin mapping via group/email
  - Logs user in and redirects to intended page
- Add optional OIDC end-session on logout (falls back gracefully if unsupported)
- Extend User model with `email`, `oidc_issuer`, `oidc_sub` and unique constraint
- Add Alembic migration 015 (adds columns, index, unique constraint)
- Update env.example with OIDC variables and AUTH_METHOD
- Add docs/OIDC_SETUP.md with provider-agnostic setup guide and examples
- fix: remove invalid walrus usage in OIDC client registration

Migration:
- Run database migrations (e.g., `flask db upgrade`) to apply revision 015

Config:
- AUTH_METHOD=local|oidc|both
- OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI
- OIDC_SCOPES (default: "openid profile email")
- OIDC_USERNAME_CLAIM, OIDC_FULL_NAME_CLAIM, OIDC_EMAIL_CLAIM, OIDC_GROUPS_CLAIM
- OIDC_ADMIN_GROUP (optional), OIDC_ADMIN_EMAILS (optional)
- OIDC_POST_LOGOUT_REDIRECT_URI (optional)

Routes:
- /login (respects AUTH_METHOD), /login/oidc, /auth/oidc/callback, /logout

Docs:
- See docs/OIDC_SETUP.md for full setup, provider notes, and troubleshooting
2025-10-05 11:46:20 +02:00

100 lines
3.7 KiB
Python

from datetime import datetime
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app import db
class User(UserMixin, db.Model):
"""User model for username-based authentication"""
__tablename__ = 'users'
__table_args__ = (
db.UniqueConstraint('oidc_issuer', 'oidc_sub', name='uq_users_oidc_issuer_sub'),
)
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
email = db.Column(db.String(200), nullable=True, index=True)
full_name = db.Column(db.String(200), nullable=True)
role = db.Column(db.String(20), default='user', nullable=False) # 'user' or 'admin'
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
last_login = db.Column(db.DateTime, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
theme_preference = db.Column(db.String(10), default=None, nullable=True) # 'light' | 'dark' | None=system
preferred_language = db.Column(db.String(8), default=None, nullable=True) # e.g., 'en', 'de'
oidc_sub = db.Column(db.String(255), nullable=True)
oidc_issuer = db.Column(db.String(255), nullable=True)
# Relationships
time_entries = db.relationship('TimeEntry', backref='user', lazy='dynamic', cascade='all, delete-orphan')
def __init__(self, username, role='user', email=None, full_name=None):
self.username = username.lower().strip()
self.role = role
self.email = (email or None)
self.full_name = (full_name or None)
def __repr__(self):
return f'<User {self.username}>'
@property
def is_admin(self):
"""Check if user is an admin"""
return self.role == 'admin'
@property
def active_timer(self):
"""Get the user's currently active timer"""
from .time_entry import TimeEntry
return TimeEntry.query.filter_by(
user_id=self.id,
end_time=None
).first()
@property
def total_hours(self):
"""Calculate total hours worked by this user"""
from .time_entry import TimeEntry
total_seconds = db.session.query(
db.func.sum(TimeEntry.duration_seconds)
).filter(
TimeEntry.user_id == self.id,
TimeEntry.end_time.isnot(None)
).scalar() or 0
return round(total_seconds / 3600, 2)
@property
def display_name(self):
"""Preferred display name: full name if available, else username"""
if self.full_name and self.full_name.strip():
return self.full_name.strip()
return self.username
def get_recent_entries(self, limit=10):
"""Get recent time entries for this user"""
from .time_entry import TimeEntry
return self.time_entries.filter(
TimeEntry.end_time.isnot(None)
).order_by(
TimeEntry.start_time.desc()
).limit(limit).all()
def update_last_login(self):
"""Update the last login timestamp"""
self.last_login = datetime.utcnow()
db.session.commit()
def to_dict(self):
"""Convert user to dictionary for API responses"""
return {
'id': self.id,
'username': self.username,
'email': self.email,
'full_name': self.full_name,
'display_name': self.display_name,
'role': self.role,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_login': self.last_login.isoformat() if self.last_login else None,
'is_active': self.is_active,
'total_hours': self.total_hours
}