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
fasthtml_auth/models.py
ADDED
@@ -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)
|
fasthtml_auth/routes.py
ADDED
@@ -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()
|