fasthtml-auth 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fasthtml_auth/__init__.py +31 -0
- fasthtml_auth/database.py +34 -0
- fasthtml_auth/forms.py +388 -0
- fasthtml_auth/init.py +0 -0
- fasthtml_auth/manager.py +78 -0
- fasthtml_auth/middleware.py +105 -0
- fasthtml_auth/models.py +76 -0
- fasthtml_auth/repository.py +113 -0
- fasthtml_auth/routes.py +203 -0
- fasthtml_auth/utils.py +45 -0
- fasthtml_auth-0.1.0.dist-info/METADATA +413 -0
- fasthtml_auth-0.1.0.dist-info/RECORD +15 -0
- fasthtml_auth-0.1.0.dist-info/WHEEL +5 -0
- fasthtml_auth-0.1.0.dist-info/licenses/LICENSE +21 -0
- fasthtml_auth-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,31 @@
|
|
1
|
+
"""
|
2
|
+
FastHTML-Auth: Complete authentication system for FastHTML applications.
|
3
|
+
|
4
|
+
Provides user authentication, session management, role-based access control,
|
5
|
+
and beautiful UI components out of the box.
|
6
|
+
"""
|
7
|
+
|
8
|
+
__version__ = "0.1.0"
|
9
|
+
__author__ = "John Richmond"
|
10
|
+
__email__ = "confusedjohn46@gmail.com"
|
11
|
+
|
12
|
+
from .manager import AuthManager
|
13
|
+
from .models import User, Session
|
14
|
+
from .middleware import AuthBeforeware
|
15
|
+
from .database import AuthDatabase
|
16
|
+
from .repository import UserRepository
|
17
|
+
|
18
|
+
__all__ = [
|
19
|
+
'AuthManager',
|
20
|
+
'User',
|
21
|
+
'Session',
|
22
|
+
'AuthBeforeware',
|
23
|
+
'AuthDatabase',
|
24
|
+
'UserRepository'
|
25
|
+
]
|
26
|
+
|
27
|
+
# Convenience imports for common usage patterns
|
28
|
+
from .manager import AuthManager as Auth # Shorter alias
|
29
|
+
|
30
|
+
# Version info tuple
|
31
|
+
VERSION = tuple(map(int, __version__.split('.')))
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# auth/database.py
|
2
|
+
from fasthtml.common import database
|
3
|
+
from pathlib import Path
|
4
|
+
|
5
|
+
class AuthDatabase:
|
6
|
+
"""Database manager owned by auth system"""
|
7
|
+
|
8
|
+
def __init__(self, db_path="data/app.db"):
|
9
|
+
# Ensure directory exists
|
10
|
+
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
11
|
+
# Create database connection
|
12
|
+
self.db = database(db_path)
|
13
|
+
self.db_path = db_path
|
14
|
+
|
15
|
+
# Table references (will be populated)
|
16
|
+
self.users = None
|
17
|
+
self.sessions = None # Optional session storage
|
18
|
+
self.audit_log = None # Optional security audit
|
19
|
+
|
20
|
+
def initialize_auth_tables(self):
|
21
|
+
from .models import User, Session
|
22
|
+
|
23
|
+
# Force User to be fully processed as a dataclass
|
24
|
+
import dataclasses
|
25
|
+
if not dataclasses.is_dataclass(User):
|
26
|
+
raise Exception("User is not a proper dataclass!")
|
27
|
+
|
28
|
+
self.users = self.db.create(User, pk=User.pk)
|
29
|
+
|
30
|
+
return self.db
|
31
|
+
|
32
|
+
def get_db(self):
|
33
|
+
"""Get database instance for app to add tables"""
|
34
|
+
return self.db
|
fasthtml_auth/forms.py
ADDED
@@ -0,0 +1,388 @@
|
|
1
|
+
# auth/forms.py
|
2
|
+
from fasthtml.common import *
|
3
|
+
from monsterui.all import *
|
4
|
+
|
5
|
+
|
6
|
+
def create_login_form(error=None, action="/auth/login", redirect_to="/"):
|
7
|
+
"""Create login form component with consistent styling"""
|
8
|
+
error_message = None
|
9
|
+
if error == 'invalid':
|
10
|
+
error_message = "Invalid username or password. Please try again."
|
11
|
+
elif error == 'inactive':
|
12
|
+
error_message = "Your account has been deactivated. Please contact support."
|
13
|
+
elif error == 'system':
|
14
|
+
error_message = "System error. Please try again."
|
15
|
+
|
16
|
+
return DivCentered(
|
17
|
+
Card(
|
18
|
+
CardHeader(
|
19
|
+
H3("Welcome Back", cls="text-center"),
|
20
|
+
Subtitle("Sign in to your account", cls="text-center")
|
21
|
+
),
|
22
|
+
CardBody(
|
23
|
+
Alert(error_message, cls=AlertT.error) if error_message else None,
|
24
|
+
Form(
|
25
|
+
LabelInput(
|
26
|
+
"Username",
|
27
|
+
id="username",
|
28
|
+
name="username",
|
29
|
+
placeholder="Enter your username",
|
30
|
+
required=True,
|
31
|
+
autofocus=True
|
32
|
+
),
|
33
|
+
LabelInput(
|
34
|
+
"Password",
|
35
|
+
id="password",
|
36
|
+
name="password",
|
37
|
+
type="password",
|
38
|
+
placeholder="Enter your password",
|
39
|
+
required=True
|
40
|
+
),
|
41
|
+
Input(type="hidden", name="redirect_to", value=redirect_to),
|
42
|
+
|
43
|
+
Div(
|
44
|
+
Label(
|
45
|
+
Input(type="checkbox", name="remember_me"),
|
46
|
+
" Remember me",
|
47
|
+
cls="flex items-center text-sm"
|
48
|
+
),
|
49
|
+
cls="mb-4"
|
50
|
+
),
|
51
|
+
|
52
|
+
Button("Sign In", type="submit", cls=(ButtonT.primary, "w-full")),
|
53
|
+
|
54
|
+
Div(
|
55
|
+
A("Forgot password?", href="/auth/forgot",
|
56
|
+
cls="text-sm text-muted-foreground hover:underline"),
|
57
|
+
cls="text-center mt-4"
|
58
|
+
),
|
59
|
+
|
60
|
+
method="post",
|
61
|
+
action=action
|
62
|
+
)
|
63
|
+
),
|
64
|
+
footer=DivCentered(
|
65
|
+
P("Don't have an account? ",
|
66
|
+
A("Register", href="/auth/register", cls="font-medium hover:underline"),
|
67
|
+
cls="text-sm text-muted-foreground"),
|
68
|
+
cls="p-4"
|
69
|
+
),
|
70
|
+
cls="w-full max-w-md shadow-lg"
|
71
|
+
),
|
72
|
+
cls="min-h-screen flex items-center justify-center p-4"
|
73
|
+
)
|
74
|
+
|
75
|
+
def create_register_form(error=None, action="/auth/register"):
|
76
|
+
"""Create registration form component with same style as login"""
|
77
|
+
error_message = None
|
78
|
+
if error == 'username_taken':
|
79
|
+
error_message = "Username already taken. Please choose another."
|
80
|
+
elif error == 'email_taken':
|
81
|
+
error_message = "Email already registered. Please sign in or use another email."
|
82
|
+
elif error == 'password_mismatch':
|
83
|
+
error_message = "Passwords do not match. Please try again."
|
84
|
+
elif error == 'password_weak':
|
85
|
+
error_message = "Password must be at least 8 characters long."
|
86
|
+
elif error == 'invalid_email':
|
87
|
+
error_message = "Please enter a valid email address."
|
88
|
+
elif error == 'creation_failed':
|
89
|
+
error_message = "Failed to create account. Please try again."
|
90
|
+
|
91
|
+
return DivCentered(
|
92
|
+
Card(
|
93
|
+
CardHeader(
|
94
|
+
H3("Create Account", cls="text-center"),
|
95
|
+
Subtitle("Join us today", cls="text-center")
|
96
|
+
),
|
97
|
+
CardBody(
|
98
|
+
Alert(error_message, cls=AlertT.error) if error_message else None,
|
99
|
+
Form(
|
100
|
+
LabelInput(
|
101
|
+
"Username",
|
102
|
+
id="username",
|
103
|
+
name="username",
|
104
|
+
placeholder="Choose a username",
|
105
|
+
required=True,
|
106
|
+
autofocus=True,
|
107
|
+
pattern="[a-zA-Z0-9_]{3,20}",
|
108
|
+
title="3-20 characters, letters, numbers and underscore only"
|
109
|
+
),
|
110
|
+
|
111
|
+
LabelInput(
|
112
|
+
"Email",
|
113
|
+
id="email",
|
114
|
+
name="email",
|
115
|
+
type="email",
|
116
|
+
placeholder="your@email.com",
|
117
|
+
required=True
|
118
|
+
),
|
119
|
+
|
120
|
+
LabelInput(
|
121
|
+
"Password",
|
122
|
+
id="password",
|
123
|
+
name="password",
|
124
|
+
type="password",
|
125
|
+
placeholder="Choose a strong password (8+ characters)",
|
126
|
+
required=True,
|
127
|
+
minlength=8
|
128
|
+
),
|
129
|
+
|
130
|
+
LabelInput(
|
131
|
+
"Confirm Password",
|
132
|
+
id="confirm_password",
|
133
|
+
name="confirm_password",
|
134
|
+
type="password",
|
135
|
+
placeholder="Re-enter your password",
|
136
|
+
required=True,
|
137
|
+
minlength=8
|
138
|
+
),
|
139
|
+
|
140
|
+
Div(
|
141
|
+
Label(
|
142
|
+
Input(type="checkbox", name="accept_terms", required=True),
|
143
|
+
" I accept the Terms and Conditions",
|
144
|
+
cls="flex items-center text-sm"
|
145
|
+
),
|
146
|
+
cls="mb-4"
|
147
|
+
),
|
148
|
+
|
149
|
+
Button("Create Account", type="submit", cls=(ButtonT.primary, "w-full")),
|
150
|
+
|
151
|
+
method="post",
|
152
|
+
action=action
|
153
|
+
)
|
154
|
+
),
|
155
|
+
footer=DivCentered(
|
156
|
+
P("Already have an account? ",
|
157
|
+
A("Sign In", href="/auth/login", cls="font-medium hover:underline"),
|
158
|
+
cls="text-sm text-muted-foreground"),
|
159
|
+
cls="p-4"
|
160
|
+
),
|
161
|
+
cls="w-full max-w-md shadow-lg"
|
162
|
+
),
|
163
|
+
cls="min-h-screen flex items-center justify-center p-4"
|
164
|
+
)
|
165
|
+
|
166
|
+
def create_forgot_password_form(error=None, success=None, action="/auth/forgot"):
|
167
|
+
"""Create password reset request form with same style"""
|
168
|
+
error_message = None
|
169
|
+
success_message = None
|
170
|
+
|
171
|
+
if error == 'user_not_found':
|
172
|
+
error_message = "No account found with that email address."
|
173
|
+
elif error == 'send_failed':
|
174
|
+
error_message = "Failed to send reset email. Please try again."
|
175
|
+
|
176
|
+
if success == 'sent':
|
177
|
+
success_message = "Password reset instructions have been sent to your email."
|
178
|
+
|
179
|
+
return DivCentered(
|
180
|
+
Card(
|
181
|
+
CardHeader(
|
182
|
+
H3("Reset Password", cls="text-center"),
|
183
|
+
Subtitle("We'll send you reset instructions", cls="text-center")
|
184
|
+
),
|
185
|
+
CardBody(
|
186
|
+
Alert(error_message, cls=AlertT.error) if error_message else None,
|
187
|
+
Alert(success_message, cls=AlertT.success) if success_message else None,
|
188
|
+
|
189
|
+
Form(
|
190
|
+
LabelInput(
|
191
|
+
"Email Address",
|
192
|
+
id="email",
|
193
|
+
name="email",
|
194
|
+
type="email",
|
195
|
+
placeholder="your@email.com",
|
196
|
+
required=True,
|
197
|
+
autofocus=True
|
198
|
+
),
|
199
|
+
|
200
|
+
Button("Send Reset Link", type="submit", cls=(ButtonT.primary, "w-full")),
|
201
|
+
|
202
|
+
method="post",
|
203
|
+
action=action
|
204
|
+
) if not success_message else Div(
|
205
|
+
P("Check your email for further instructions.", cls="text-center text-muted-foreground")
|
206
|
+
),
|
207
|
+
|
208
|
+
DivCentered(
|
209
|
+
A("← Back to Sign In", href="/auth/login",
|
210
|
+
cls=(ButtonT.secondary, "mt-4"))
|
211
|
+
)
|
212
|
+
),
|
213
|
+
cls="w-full max-w-md shadow-lg"
|
214
|
+
),
|
215
|
+
cls="min-h-screen flex items-center justify-center p-4"
|
216
|
+
)
|
217
|
+
|
218
|
+
def create_reset_password_form(token, error=None, action="/auth/reset"):
|
219
|
+
"""Create password reset form (after clicking email link)"""
|
220
|
+
error_message = None
|
221
|
+
|
222
|
+
if error == 'invalid_token':
|
223
|
+
error_message = "Invalid or expired reset token. Please request a new one."
|
224
|
+
elif error == 'password_mismatch':
|
225
|
+
error_message = "Passwords do not match. Please try again."
|
226
|
+
elif error == 'password_weak':
|
227
|
+
error_message = "Password must be at least 8 characters long."
|
228
|
+
|
229
|
+
return DivCentered(
|
230
|
+
Card(
|
231
|
+
CardHeader(
|
232
|
+
H3("Choose New Password", cls="text-center"),
|
233
|
+
Subtitle("Enter your new password below", cls="text-center")
|
234
|
+
),
|
235
|
+
CardBody(
|
236
|
+
Alert(error_message, cls=AlertT.error) if error_message else None,
|
237
|
+
|
238
|
+
Form(
|
239
|
+
Input(type="hidden", name="token", value=token),
|
240
|
+
|
241
|
+
LabelInput(
|
242
|
+
"New Password",
|
243
|
+
id="password",
|
244
|
+
name="password",
|
245
|
+
type="password",
|
246
|
+
placeholder="Enter new password (8+ characters)",
|
247
|
+
required=True,
|
248
|
+
minlength=8,
|
249
|
+
autofocus=True
|
250
|
+
),
|
251
|
+
|
252
|
+
LabelInput(
|
253
|
+
"Confirm New Password",
|
254
|
+
id="confirm_password",
|
255
|
+
name="confirm_password",
|
256
|
+
type="password",
|
257
|
+
placeholder="Re-enter new password",
|
258
|
+
required=True,
|
259
|
+
minlength=8
|
260
|
+
),
|
261
|
+
|
262
|
+
Button("Reset Password", type="submit", cls=(ButtonT.primary, "w-full")),
|
263
|
+
|
264
|
+
method="post",
|
265
|
+
action=action
|
266
|
+
)
|
267
|
+
),
|
268
|
+
footer=DivCentered(
|
269
|
+
P("Remember your password? ",
|
270
|
+
A("Sign In", href="/auth/login", cls="font-medium hover:underline"),
|
271
|
+
cls="text-sm text-muted-foreground"),
|
272
|
+
cls="p-4"
|
273
|
+
),
|
274
|
+
cls="w-full max-w-md shadow-lg"
|
275
|
+
),
|
276
|
+
cls="min-h-screen flex items-center justify-center p-4"
|
277
|
+
)
|
278
|
+
|
279
|
+
def create_profile_form(user, success=None, error=None, action="/auth/profile"):
|
280
|
+
"""Create profile edit form for logged-in users"""
|
281
|
+
return Container(
|
282
|
+
DivFullySpaced(
|
283
|
+
H1("Profile Settings"),
|
284
|
+
A("← Back to Dashboard", href="/", cls=ButtonT.secondary)
|
285
|
+
),
|
286
|
+
|
287
|
+
Grid(
|
288
|
+
Card(
|
289
|
+
CardHeader(H3("Account Information")),
|
290
|
+
CardBody(
|
291
|
+
Alert("Profile updated successfully!", cls=AlertT.success) if success else None,
|
292
|
+
Alert(error, cls=AlertT.error) if error else None,
|
293
|
+
|
294
|
+
Form(
|
295
|
+
Grid(
|
296
|
+
LabelInput(
|
297
|
+
"Username",
|
298
|
+
value=user.username,
|
299
|
+
disabled=True,
|
300
|
+
cls="bg-muted"
|
301
|
+
),
|
302
|
+
LabelInput(
|
303
|
+
"Email",
|
304
|
+
name="email",
|
305
|
+
type="email",
|
306
|
+
value=user.email,
|
307
|
+
required=True
|
308
|
+
),
|
309
|
+
cols=1, cols_md=2
|
310
|
+
),
|
311
|
+
|
312
|
+
Hr(cls="my-6"),
|
313
|
+
|
314
|
+
H4("Change Password", cls="text-lg font-semibold mb-4"),
|
315
|
+
|
316
|
+
LabelInput(
|
317
|
+
"Current Password",
|
318
|
+
name="current_password",
|
319
|
+
type="password",
|
320
|
+
placeholder="Enter current password to change"
|
321
|
+
),
|
322
|
+
|
323
|
+
Grid(
|
324
|
+
LabelInput(
|
325
|
+
"New Password",
|
326
|
+
name="new_password",
|
327
|
+
type="password",
|
328
|
+
placeholder="Enter new password (8+ chars)",
|
329
|
+
minlength=8
|
330
|
+
),
|
331
|
+
LabelInput(
|
332
|
+
"Confirm New Password",
|
333
|
+
name="confirm_password",
|
334
|
+
type="password",
|
335
|
+
placeholder="Confirm new password",
|
336
|
+
minlength=8
|
337
|
+
),
|
338
|
+
cols=1, cols_md=2
|
339
|
+
),
|
340
|
+
|
341
|
+
DivRAligned(
|
342
|
+
Button("Save Changes", type="submit", cls=ButtonT.primary),
|
343
|
+
cls="mt-6"
|
344
|
+
),
|
345
|
+
|
346
|
+
method="post",
|
347
|
+
action=action
|
348
|
+
)
|
349
|
+
)
|
350
|
+
),
|
351
|
+
|
352
|
+
Card(
|
353
|
+
CardHeader(H3("Account Details")),
|
354
|
+
CardBody(
|
355
|
+
Div(
|
356
|
+
InfoRow("Username", user.username),
|
357
|
+
InfoRow("Role", user.role.title()),
|
358
|
+
InfoRow("Status", "Active" if user.active else "Inactive"),
|
359
|
+
InfoRow("Member Since", user.created_at[:10] if user.created_at else "Unknown"),
|
360
|
+
InfoRow("Last Login", user.last_login[:10] if user.last_login else "Never"),
|
361
|
+
cls="space-y-3"
|
362
|
+
)
|
363
|
+
)
|
364
|
+
),
|
365
|
+
|
366
|
+
cols=1, cols_lg=2
|
367
|
+
),
|
368
|
+
cls=ContainerT.xl
|
369
|
+
)
|
370
|
+
|
371
|
+
def InfoRow(label, value):
|
372
|
+
"""Helper for info display in profile"""
|
373
|
+
return DivFullySpaced(
|
374
|
+
Span(label, cls="font-medium"),
|
375
|
+
Span(str(value), cls="text-muted-foreground")
|
376
|
+
)
|
377
|
+
|
378
|
+
# Utility function for consistent error/success messaging
|
379
|
+
def create_message_alert(message, type="info"):
|
380
|
+
"""Create consistent alert messages"""
|
381
|
+
alert_class = {
|
382
|
+
"error": AlertT.error,
|
383
|
+
"success": AlertT.success,
|
384
|
+
"warning": AlertT.warning,
|
385
|
+
"info": AlertT.info
|
386
|
+
}.get(type, AlertT.info)
|
387
|
+
|
388
|
+
return Alert(message, cls=alert_class)
|
fasthtml_auth/init.py
ADDED
File without changes
|
fasthtml_auth/manager.py
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
from fasthtml.common import *
|
2
|
+
from monsterui.all import *
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from .middleware import AuthBeforeware
|
5
|
+
from .database import AuthDatabase
|
6
|
+
from .repository import UserRepository
|
7
|
+
from .routes import AuthRoutes
|
8
|
+
from .models import User
|
9
|
+
|
10
|
+
class AuthManager:
|
11
|
+
""" A class to manage user authentication and route access for fasthtml apps. Intended to be
|
12
|
+
modular to enable easy re-use
|
13
|
+
|
14
|
+
Methods:
|
15
|
+
initialize: Initialize the auth system and database
|
16
|
+
create_beforeware: Create authentication middleware
|
17
|
+
register_routes: Register authentication routes
|
18
|
+
require_role: Decorator for role-based access control
|
19
|
+
require_admin: Decorator for admin-only access
|
20
|
+
get_user: Get user by username
|
21
|
+
|
22
|
+
"""
|
23
|
+
def __init__(self, db_path="data/app.db", config=None):
|
24
|
+
self.config = config or {}
|
25
|
+
self.auth_db = AuthDatabase(db_path)
|
26
|
+
self.middleware = AuthBeforeware(self, self.config)
|
27
|
+
self.db=None
|
28
|
+
self.routes = {} # Store route references
|
29
|
+
self.user_repo = None
|
30
|
+
self.route_handler = AuthRoutes(self)
|
31
|
+
|
32
|
+
def initialize(self):
|
33
|
+
"""Initialise the auth system"""
|
34
|
+
# Create tables
|
35
|
+
self.db = self.auth_db.initialize_auth_tables()
|
36
|
+
|
37
|
+
# Create repo to manage users
|
38
|
+
self.user_repo = UserRepository(self.db)
|
39
|
+
|
40
|
+
# Create default admin
|
41
|
+
self._create_default_admin()
|
42
|
+
|
43
|
+
admin = self.get_user('admin')
|
44
|
+
(f"Admin User class: {type(admin)}")
|
45
|
+
print(f"Are they the same class? {type(admin) is User}")
|
46
|
+
|
47
|
+
return self.db
|
48
|
+
|
49
|
+
def create_beforeware(self, additional_public_paths=None):
|
50
|
+
return self.middleware.create_beforeware(additional_public_paths)
|
51
|
+
|
52
|
+
def require_role(self, *roles):
|
53
|
+
"""Get role requirement decorator"""
|
54
|
+
return self.middleware.require_role(*roles)
|
55
|
+
|
56
|
+
def require_admin(self):
|
57
|
+
"""Get admin requirement decorator"""
|
58
|
+
return self.middleware.require_admin()
|
59
|
+
|
60
|
+
def get_user(self, username: str):
|
61
|
+
return self.user_repo.get_by_username(username)
|
62
|
+
|
63
|
+
def register_routes(self, app, prefix='/auth'):
|
64
|
+
"""Delegate to route handler"""
|
65
|
+
return self.route_handler.register_all(app, prefix)
|
66
|
+
|
67
|
+
# Create default admin
|
68
|
+
def _create_default_admin(self):
|
69
|
+
"""Create default admin if needed"""
|
70
|
+
if not self.user_repo.get_by_username('admin'):
|
71
|
+
self.user_repo.create(
|
72
|
+
username='admin',
|
73
|
+
email='admin@system.local',
|
74
|
+
password='admin123', # Will be hashed by repository
|
75
|
+
role='admin'
|
76
|
+
)
|
77
|
+
|
78
|
+
|
@@ -0,0 +1,105 @@
|
|
1
|
+
from fasthtml.common import *
|
2
|
+
from typing import Optional, List
|
3
|
+
|
4
|
+
class AuthBeforeware:
|
5
|
+
"""Authentication middleware for fastHTML"""
|
6
|
+
|
7
|
+
def __init__(self, auth_manager, config=None):
|
8
|
+
self.auth_manager = auth_manager
|
9
|
+
self.config = config or {}
|
10
|
+
|
11
|
+
# Configure paths
|
12
|
+
login_path = '/auth/login'
|
13
|
+
self.login_path = self.config.get("login_path", login_path)
|
14
|
+
self.public_paths = self.config.get("public_paths", [])
|
15
|
+
# Static files patterns
|
16
|
+
self.static_patterns = [
|
17
|
+
r'/favicon\.ico',
|
18
|
+
r'/static/.*',
|
19
|
+
r'.*\.css',
|
20
|
+
r'.*\.js',
|
21
|
+
r'.*\.png',
|
22
|
+
r'.*\.jpg',
|
23
|
+
r'.*\.jpeg',
|
24
|
+
r'.*\.gif',
|
25
|
+
r'.*\.svg',
|
26
|
+
]
|
27
|
+
|
28
|
+
def create_beforeware(self, additional_public_paths=None):
|
29
|
+
"""Create beforeware for fastHTML"""
|
30
|
+
skip_patterns = self._build_skip_patterns(additional_paths=additional_public_paths)
|
31
|
+
|
32
|
+
# Create Beforeware authentication check function
|
33
|
+
def auth_check(req, sess):
|
34
|
+
"""Check authentication prior to check"""
|
35
|
+
auth_username = sess.get('auth')
|
36
|
+
|
37
|
+
if not auth_username:
|
38
|
+
# No auth, redirect to login
|
39
|
+
return RedirectResponse(self.login_path, status_code=303)
|
40
|
+
|
41
|
+
# Verify user is still valid
|
42
|
+
user = self.auth_manager.get_user(auth_username)
|
43
|
+
if not user or not user.active:
|
44
|
+
# Invalid user, clear session and redirect
|
45
|
+
sess.clear()
|
46
|
+
return RedirectResponse(self.login_path, status_code=303)
|
47
|
+
|
48
|
+
# Add user info to request scope for route handlers
|
49
|
+
req.scope['auth'] = auth_username
|
50
|
+
req.scope['user'] = user
|
51
|
+
req.scope['user.id'] = user.id
|
52
|
+
req.scope['user_role'] = user.role
|
53
|
+
req.scope['user_is_admin'] = user.role == "admin"
|
54
|
+
|
55
|
+
# Return configured Beforeware
|
56
|
+
return Beforeware(auth_check, skip=skip_patterns)
|
57
|
+
|
58
|
+
|
59
|
+
def _build_skip_patterns(self, additional_paths=None) -> list:
|
60
|
+
"""Build list of paths to skip auth check"""
|
61
|
+
|
62
|
+
skip = []
|
63
|
+
|
64
|
+
# Static files
|
65
|
+
skip.extend(self.static_patterns)
|
66
|
+
|
67
|
+
# Auth routes
|
68
|
+
skip.extend([
|
69
|
+
'/auth/login',
|
70
|
+
'/auth/register',
|
71
|
+
'/auth/forgot',
|
72
|
+
'/auth/reset'
|
73
|
+
])
|
74
|
+
|
75
|
+
# Configured public paths
|
76
|
+
skip.extend(self.public_paths)
|
77
|
+
|
78
|
+
# Additional paths from caller
|
79
|
+
if additional_paths:
|
80
|
+
skip.extend(additional_paths)
|
81
|
+
|
82
|
+
# Health check and api endpoints
|
83
|
+
skip.extend([
|
84
|
+
"/health",
|
85
|
+
"/api/public"
|
86
|
+
])
|
87
|
+
|
88
|
+
return skip
|
89
|
+
|
90
|
+
def require_admin(self):
|
91
|
+
"""Decorator for paths requiting admin role to access"""
|
92
|
+
return self.require_role('admin')
|
93
|
+
|
94
|
+
def require_role(self, *allowed_roles):
|
95
|
+
"""Decorator to require a specific role"""
|
96
|
+
def decorator(func):
|
97
|
+
def wrapper(req, *args, **kwargs):
|
98
|
+
user = req.scope.get('user')
|
99
|
+
if not user or user.role not in allowed_roles:
|
100
|
+
return Response("Forbidden", status_code=403)
|
101
|
+
return func(req, *args, **kwargs)
|
102
|
+
return wrapper
|
103
|
+
return decorator
|
104
|
+
|
105
|
+
|