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,76 @@
1
+ # auth/models.py
2
+ from dataclasses import dataclass
3
+ import bcrypt
4
+ from typing import Optional
5
+ from datetime import datetime
6
+
7
+ @dataclass
8
+ class User:
9
+ id: int|None = None
10
+ username: str|None = None
11
+ email: str|None = None
12
+ password: str|None = None
13
+ role: str = "user"
14
+ created_at: str = ""
15
+ last_login: str = ""
16
+ active: bool = True
17
+
18
+ # Define primary key for fastlite
19
+ pk = "id"
20
+
21
+ @classmethod
22
+ def get_hashed_password(cls, password: str) -> str:
23
+ """Hash a password using bcrypt"""
24
+ try:
25
+ if not password:
26
+ raise ValueError("Password cannot be empty")
27
+ # Use bcrypt directly instead of passlib to avoid version issues
28
+ salt = bcrypt.gensalt()
29
+ hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
30
+ return hashed.decode('utf-8')
31
+ except Exception as e:
32
+ print(f"Error hashing password: {e}")
33
+ raise
34
+
35
+ @classmethod
36
+ def is_hashed(cls, pwd: str) -> bool:
37
+ """Check if password is already hashed (bcrypt hashes start with $2b$)"""
38
+ return pwd and pwd.startswith('$2b$') and len(pwd) == 60
39
+
40
+ @classmethod
41
+ def verify_password(cls, pwd: str, hashed: str) -> bool:
42
+ """Verify password against hash"""
43
+ try:
44
+ if not pwd or not hashed:
45
+ return False
46
+ return bcrypt.checkpw(pwd.encode('utf-8'), hashed.encode('utf-8'))
47
+ except Exception as e:
48
+ print(f"Error verifying password: {e}")
49
+ return False
50
+
51
+ def __post_init__(self):
52
+ """Initialize timestamps and hash password if needed"""
53
+ # Hash password if not already hashed
54
+ if self.password and not self.is_hashed(self.password):
55
+ print(f"Hashing password for user: {self.username}")
56
+ self.password = self.get_hashed_password(self.password)
57
+
58
+ # Initialize timestamps if not set
59
+ now = datetime.now().isoformat()
60
+ if not self.created_at:
61
+ self.created_at = now
62
+ if not self.last_login:
63
+ self.last_login = now
64
+
65
+
66
+ @dataclass
67
+ class Session:
68
+ """Pure Session model"""
69
+ id: str
70
+ user_id: int
71
+ data: dict
72
+ expires_at: str
73
+ created_at: str
74
+
75
+ # Define primary key for fastlite
76
+ pk = 'id'
@@ -0,0 +1,113 @@
1
+ from typing import Optional
2
+ from datetime import datetime
3
+ from fasthtml_auth.models import User
4
+
5
+ class UserRepository:
6
+ """Handles all database operations for users"""
7
+ def __init__(self, db):
8
+ self.db = db
9
+ self.users = db.t.user
10
+
11
+ def _dict_to_user(self, user_dict) -> User:
12
+ """Convert dictionary from database to User object"""
13
+ if isinstance(user_dict, User):
14
+ return user_dict
15
+
16
+ return User(
17
+ id=user_dict.get('id'),
18
+ username=user_dict['username'],
19
+ email=user_dict['email'],
20
+ password=user_dict['password'],
21
+ role=user_dict.get('role', 'user'),
22
+ created_at=user_dict.get('created_at', ''),
23
+ last_login=user_dict.get('last_login', ''),
24
+ active=user_dict.get('active', True)
25
+ )
26
+
27
+ def get_by_username(self, username: str) -> Optional[User]:
28
+ """Get user by username using parameterized query"""
29
+ try:
30
+ user_found = self.users("username=?", (username,))
31
+ if len(user_found) == 1:
32
+ if isinstance(user_found[0], User):
33
+ return user_found[0]
34
+ else:
35
+ return self._dict_to_user(user_found[0])
36
+ return user_found[0] # Return the single user object
37
+ elif len(user_found) == 0:
38
+ return None
39
+ else:
40
+ raise Exception(f"Multiple users found with username: {username}")
41
+ except Exception as e:
42
+ print(f"Error in get_by_username: {e}")
43
+ return None
44
+
45
+ def get_by_id(self, user_id: int) -> Optional[User]:
46
+ """Get user by ID"""
47
+ try:
48
+ user_dict = self.users[user_id]
49
+ if user_dict:
50
+ return self._dict_to_user(user_dict)
51
+ return None
52
+ except Exception as e:
53
+ print(f"Error in get_by_id: {e}")
54
+ return None
55
+
56
+ def create(self, username: str, email: str, password: str, role: str = "user") -> User:
57
+ """Create new user. Note that the dates will be updated by the class -_post_init__ method """
58
+ user = User(
59
+ username=username,
60
+ email=email,
61
+ password=password, # Use static method
62
+ role=role,
63
+ active=True,
64
+ created_at="",
65
+ last_login=""
66
+ )
67
+ inserted_user = self.users.insert(user)
68
+ if isinstance(inserted_user, dict):
69
+ return self._dict_to_user(inserted_user)
70
+ else:
71
+ return inserted_user
72
+
73
+ def authenticate(self, username: str, password: str) -> Optional[User]:
74
+ """Authenticate user and update last_login"""
75
+ user = self.get_by_username(username)
76
+ print(f"User: {user}")
77
+ if user and user.active and User.verify_password(password, user.password):
78
+ # Update last_login using the new fastlite approach
79
+ from datetime import datetime
80
+ now = datetime.now().isoformat()
81
+ self.users.update(last_login=now, id=user.id)
82
+ return user
83
+ return None
84
+
85
+ def update(self, user_id: int, **kwargs) -> bool:
86
+ """Update user fields using fastlite kwargs approach"""
87
+ try:
88
+ # Hash password if being updated # <- NEW
89
+ if 'password' in kwargs and kwargs['password']: # <- NEW
90
+ if not User.is_hashed(kwargs['password']): # <- NEW
91
+ kwargs['password'] = User.get_hashed_password(kwargs['password']) # <- NEW
92
+
93
+ # Include the primary key in the update kwargs
94
+ self.users.update(id=user_id, **kwargs)
95
+ return True
96
+ except Exception as e:
97
+ print(f"Error updating user {user_id}: {e}")
98
+ return False
99
+
100
+ def list_all(self) -> list[User]:
101
+ """Get all users"""
102
+ try:
103
+ users = []
104
+ for user_dict in self.users():
105
+ users.append(self._dict_to_user(user_dict))
106
+ return users
107
+ except Exception as e:
108
+ print(f"Error listing users: {e}")
109
+ return []
110
+
111
+ def verify_password(self, password: str, hashed: str) -> bool:
112
+ """Verify password against hash - delegates to User class"""
113
+ return User.verify_password(password, hashed)
@@ -0,0 +1,203 @@
1
+ # auth/routes.py
2
+ from fasthtml.common import *
3
+ from .forms import create_login_form, create_register_form, create_forgot_password_form, create_profile_form
4
+
5
+
6
+ class AuthRoutes:
7
+ """Handles route registration for auth system"""
8
+
9
+ def __init__(self, auth_manager):
10
+ self.auth = auth_manager
11
+ self.routes = {}
12
+
13
+ def register_all(self, app, prefix="/auth"):
14
+ """Register all auth routes"""
15
+ rt = app.route
16
+
17
+ # Register each route group
18
+ self._register_login_routes(rt, prefix)
19
+ self._register_logout_route(rt, prefix)
20
+ self._register_profile_route(rt, prefix)
21
+
22
+ if self.auth.config.get('allow_registration'):
23
+ self._register_registration_routes(rt, prefix)
24
+
25
+ if self.auth.config.get('allow_password_reset'):
26
+ self._register_password_reset_routes(rt, prefix)
27
+
28
+ return self.routes
29
+
30
+ def _register_login_routes(self, rt, prefix):
31
+ """Register login routes"""
32
+
33
+ # Login routes
34
+ @rt(f"{prefix}/login", methods=["GET"])
35
+ def login_page(req):
36
+ error = req.query_params.get('error')
37
+ # Get redirect destination from query params
38
+ redirect_to = req.query_params.get('redirect_to', '/')
39
+ return Title("Login"), Container(
40
+ create_login_form(error=error, action=f"{prefix}/login", redirect_to=redirect_to)
41
+ )
42
+ self.routes['login_page'] = login_page
43
+
44
+ @rt(f"{prefix}/login", methods=["POST"])
45
+ async def login_submit(req, sess):
46
+ form = await req.form()
47
+ username = form.get('username', '').strip()
48
+ password = form.get('password', '')
49
+
50
+ # Authenticate
51
+ user = self.auth.user_repo.authenticate(username, password)
52
+ if user:
53
+ # Set session
54
+ sess['auth'] = user.username
55
+ sess['user_id'] = user.id
56
+ sess['role'] = user.role
57
+
58
+ # Redirect to next URL or default
59
+ redirect_url = form.get('redirect_to', '/')
60
+ return RedirectResponse(redirect_url, status_code=303)
61
+ # On failure, preserve the redirect_to parameter
62
+ redirect_to = form.get('redirect_to', '/')
63
+ error_url = f"{prefix}/login?error=invalid"
64
+ if redirect_to != '/':
65
+ error_url += f"&redirect_to={redirect_to}"
66
+ return RedirectResponse(error_url, status_code=303)
67
+
68
+ self.routes['login_submit'] = login_submit
69
+
70
+ def _register_logout_route(self, rt, prefix):
71
+ @rt(f"{prefix}/logout")
72
+ def logout(sess):
73
+ sess.clear()
74
+ return RedirectResponse(f"{prefix}/login", status_code=303)
75
+ self.routes['logout'] = logout
76
+
77
+ def _register_registration_routes(self, rt, prefix):
78
+
79
+ # Optional: Register route
80
+ if self.auth.config.get('allow_registration', False):
81
+ @rt(f"{prefix}/register", methods=["GET"])
82
+ def register_page(req):
83
+ error = req.query_params.get("error")
84
+ return Title("Register"), Container(
85
+ create_register_form(error=error, action=f"{prefix}/register")
86
+ )
87
+
88
+ @rt(f"{prefix}/register", methods=["POST"])
89
+ async def register_submit(req, sess):
90
+ form = await req.form()
91
+ username = form.get('username', '').strip()
92
+ email = form.get('email', '').strip()
93
+ password = form.get('password', '')
94
+ confirm = form.get('confirm_password', '')
95
+
96
+ # Validation
97
+ if password != confirm:
98
+ return RedirectResponse(f"{prefix}/register?error=password_mismatch", status_code=303)
99
+
100
+ if password != confirm:
101
+ return RedirectResponse(f"{prefix}/register?error=password_mismatch", status_code=303)
102
+
103
+ # Check if user exists
104
+ if self.auth.user_repo.get_by_username(username):
105
+ return RedirectResponse(f"{prefix}/register?error=username_taken", status_code=303)
106
+
107
+ # Create user
108
+ try:
109
+ user = self.auth.user_repo.create(username, email, password)
110
+ if user:
111
+ # Auto-login after registration
112
+ sess['auth'] = user.username
113
+ sess['user_id'] = user.id
114
+ sess['role'] = user.role
115
+ return RedirectResponse('/', status_code=303)
116
+ except Exception as e:
117
+ print(f"Registration error: {e}")
118
+ return RedirectResponse(f"{prefix}/register?error=creation_failed", status_code=303)
119
+
120
+ return RedirectResponse(f"{prefix}/register?error=creation_failed", status_code=303)
121
+
122
+ self.routes['register_page'] = register_page
123
+ self.routes['register_submit'] = register_submit
124
+
125
+ def _register_password_reset_routes(self, rt, prefix):
126
+ # Optional: Password reset route
127
+ if self.auth.config.get('allow_password_reset', False):
128
+ @rt(f"{prefix}/forgot", methods=["GET"])
129
+ def forgot_page(req):
130
+ error = req.query_params.get('error')
131
+ success = req.query_params.get('success')
132
+ return Title("Forgot Password"), create_forgot_password_form(
133
+ error=error,
134
+ success=success,
135
+ action=f"{prefix}/forgot"
136
+ )
137
+
138
+ @rt(f"{prefix}/forgot", methods=["POST"])
139
+ async def forgot_submit(req):
140
+ form = await req.form()
141
+ email = form.get('email', '').strip()
142
+
143
+ # TODO: Implement actual password reset logic
144
+ # For now, just show success message
145
+ return RedirectResponse(f"{prefix}/forgot?success=sent", status_code=303)
146
+
147
+ self.routes['forgot_password'] = forgot_page
148
+ self.routes['forgot_submit'] = forgot_submit
149
+
150
+ def _register_profile_route(self, rt, prefix):
151
+ # Register route to a profile form
152
+ @rt(f"{prefix}/profile", methods=["GET"])
153
+ def profile_page(req):
154
+ user = req.scope['user'] # Added by beforeware
155
+ success = req.query_params.get('success')
156
+ error = req.query_params.get('error')
157
+ return Title("Profile"), create_profile_form(
158
+ user=user,
159
+ success=success,
160
+ error=error,
161
+ action=f"{prefix}/profile"
162
+ )
163
+
164
+ @rt(f"{prefix}/profile", methods=["POST"])
165
+ async def profile_submit(req):
166
+ user = req.scope['user']
167
+ form = await req.form()
168
+
169
+ try:
170
+ # Update email if changed
171
+ new_email = form.get('email', '').strip()
172
+ if new_email and new_email != user.email:
173
+ self.auth.user_repo.update(user.id, email=new_email)
174
+
175
+ # Handle password change
176
+ current_password = form.get('current_password', '')
177
+ new_password = form.get('new_password', '')
178
+ confirm_password = form.get('confirm_password', '')
179
+
180
+ if current_password or new_password:
181
+ if not current_password:
182
+ return RedirectResponse(f"{prefix}/profile?error=Current password required", status_code=303)
183
+
184
+ if not self.auth.user_repo.verify_password(current_password, user.password):
185
+ return RedirectResponse(f"{prefix}/profile?error=Invalid current password", status_code=303)
186
+
187
+ if new_password != confirm_password:
188
+ return RedirectResponse(f"{prefix}/profile?error=New passwords do not match", status_code=303)
189
+
190
+ if len(new_password) < 8:
191
+ return RedirectResponse(f"{prefix}/profile?error=Password must be at least 8 characters", status_code=303)
192
+
193
+ # Update password (repository will handle hashing)
194
+ self.auth.user_repo.update(user.id, password=new_password)
195
+
196
+ return RedirectResponse(f"{prefix}/profile?success=1", status_code=303)
197
+
198
+ except Exception as e:
199
+ print(f"Profile update error: {e}")
200
+ return RedirectResponse(f"{prefix}/profile?error=Update failed", status_code=303)
201
+
202
+ self.routes['profile_page'] = profile_page
203
+ self.routes['profile_submit'] = profile_submit
fasthtml_auth/utils.py ADDED
@@ -0,0 +1,45 @@
1
+ """Utility functions for FastHTML-Auth"""
2
+
3
+ from typing import Optional
4
+ import secrets
5
+ import string
6
+ import re
7
+
8
+ def generate_token(length: int = 32) -> str:
9
+ """Generate a secure random token for password resets, etc."""
10
+ alphabet = string.ascii_letters + string.digits
11
+ return ''.join(secrets.choice(alphabet) for _ in range(length))
12
+
13
+ def validate_email(email: str) -> bool:
14
+ """Basic email validation"""
15
+ if not email or '@' not in email:
16
+ return False
17
+
18
+ pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
19
+ return bool(re.match(pattern, email))
20
+
21
+ def validate_password(password: str) -> tuple[bool, str]:
22
+ """
23
+ Validate password strength
24
+ Returns: (is_valid, error_message)
25
+ """
26
+ if not password:
27
+ return False, "Password is required"
28
+
29
+ if len(password) < 8:
30
+ return False, "Password must be at least 8 characters long"
31
+
32
+ # Optional: Add more complexity requirements
33
+ if not any(c.isdigit() for c in password):
34
+ return False, "Password must contain at least one number"
35
+
36
+ if not any(c.isupper() for c in password):
37
+ return False, "Password must contain at least one uppercase letter"
38
+
39
+ return True, ""
40
+
41
+ def sanitize_username(username: str) -> str:
42
+ """Sanitize username to only allow safe characters"""
43
+ # Only allow alphanumeric and underscore
44
+ sanitized = re.sub(r'[^a-zA-Z0-9_]', '', username)
45
+ return sanitized.lower()