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.
@@ -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
@@ -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
+