Update services/auth.py
Browse files- services/auth.py +104 -51
services/auth.py
CHANGED
@@ -1,94 +1,153 @@
|
|
1 |
# /home/user/app/services/auth.py
|
2 |
from typing import Optional
|
3 |
-
import bcrypt
|
4 |
-
from sqlmodel import select
|
|
|
5 |
|
6 |
-
from models import User, UserCreate, get_session_context
|
7 |
-
from services.logger import app_logger
|
8 |
|
9 |
-
# --- Password Hashing Utilities ---
|
10 |
def hash_password(password: str) -> str:
|
11 |
-
"
|
12 |
try:
|
13 |
password_bytes = password.encode('utf-8')
|
14 |
salt = bcrypt.gensalt()
|
15 |
hashed_bytes = bcrypt.hashpw(password_bytes, salt)
|
16 |
-
|
|
|
|
|
17 |
except Exception as e:
|
18 |
-
app_logger.error(f"
|
19 |
-
#
|
20 |
-
|
21 |
-
raise ValueError("Password hashing failed") from e
|
22 |
|
23 |
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
24 |
-
|
|
|
25 |
try:
|
26 |
plain_password_bytes = plain_password.encode('utf-8')
|
27 |
hashed_password_bytes = hashed_password.encode('utf-8')
|
28 |
-
|
|
|
|
|
29 |
except Exception as e:
|
30 |
-
app_logger.error(f"
|
31 |
-
# If bcrypt itself is broken, verification will fail.
|
32 |
return False
|
33 |
|
34 |
-
# --- User Creation ---
|
35 |
def create_user_in_db(user_create_data: UserCreate) -> Optional[User]:
|
36 |
-
""
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
|
|
|
|
|
|
41 |
try:
|
42 |
-
with get_session_context() as session:
|
43 |
-
|
|
|
|
|
|
|
44 |
statement_username = select(User).where(User.username == user_create_data.username)
|
45 |
existing_user_by_username = session.exec(statement_username).first()
|
46 |
if existing_user_by_username:
|
47 |
app_logger.warning(f"Signup failed: Username '{user_create_data.username}' already exists.")
|
48 |
-
|
|
|
49 |
|
50 |
-
# Check if email already exists (if provided and should be unique)
|
51 |
if user_create_data.email:
|
|
|
52 |
statement_email = select(User).where(User.email == user_create_data.email)
|
53 |
existing_user_by_email = session.exec(statement_email).first()
|
54 |
if existing_user_by_email:
|
55 |
app_logger.warning(f"Signup failed: Email '{user_create_data.email}' already exists.")
|
56 |
return None
|
57 |
|
|
|
|
|
58 |
try:
|
59 |
hashed_pw = hash_password(user_create_data.password)
|
60 |
-
except ValueError: # Catch hashing
|
61 |
-
app_logger.error(f"
|
62 |
-
return None
|
63 |
-
|
64 |
|
65 |
-
# Create the new user object
|
|
|
66 |
db_user = User(
|
67 |
username=user_create_data.username,
|
68 |
email=user_create_data.email,
|
69 |
-
|
70 |
-
|
|
|
71 |
hashed_password=hashed_pw
|
72 |
)
|
73 |
|
|
|
|
|
74 |
session.add(db_user)
|
75 |
-
|
76 |
-
#
|
77 |
-
|
78 |
-
|
79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
81 |
except Exception as e:
|
82 |
-
|
|
|
83 |
# session.rollback() is handled by get_session_context
|
84 |
return None
|
85 |
|
86 |
-
# --- User Authentication ---
|
87 |
def authenticate_user(username_in: str, password_in: str) -> Optional[User]:
|
88 |
-
|
89 |
-
Authenticates a user by username and password.
|
90 |
-
Returns the User object if authentication is successful, None otherwise.
|
91 |
-
"""
|
92 |
app_logger.info(f"Attempting to authenticate user: {username_in}")
|
93 |
try:
|
94 |
with get_session_context() as session:
|
@@ -99,7 +158,7 @@ def authenticate_user(username_in: str, password_in: str) -> Optional[User]:
|
|
99 |
app_logger.warning(f"Authentication failed: User '{username_in}' not found.")
|
100 |
return None
|
101 |
|
102 |
-
if user
|
103 |
app_logger.warning(f"Authentication failed: User '{username_in}' is disabled.")
|
104 |
return None
|
105 |
|
@@ -107,14 +166,8 @@ def authenticate_user(username_in: str, password_in: str) -> Optional[User]:
|
|
107 |
app_logger.warning(f"Authentication failed: Invalid password for user '{username_in}'.")
|
108 |
return None
|
109 |
|
110 |
-
# If login is successful, you might want to update a last_login timestamp here
|
111 |
-
# user.last_login_at = datetime.utcnow()
|
112 |
-
# session.add(user)
|
113 |
-
# session.commit() # (handled by context manager)
|
114 |
-
# session.refresh(user)
|
115 |
-
|
116 |
app_logger.info(f"User '{user.username}' (ID: {user.id}) authenticated successfully.")
|
117 |
-
return user
|
118 |
|
119 |
except Exception as e:
|
120 |
app_logger.error(f"Database or unexpected error during authentication for '{username_in}': {e}", exc_info=True)
|
|
|
1 |
# /home/user/app/services/auth.py
|
2 |
from typing import Optional
|
3 |
+
import bcrypt
|
4 |
+
from sqlmodel import select, Session # Assuming Session is also imported if used for type hints
|
5 |
+
from sqlalchemy.exc import IntegrityError # For catching DB unique constraint violations
|
6 |
|
7 |
+
from models import User, UserCreate, get_session_context
|
8 |
+
from services.logger import app_logger
|
9 |
|
10 |
+
# --- Password Hashing Utilities (ensure these are robust) ---
|
11 |
def hash_password(password: str) -> str:
|
12 |
+
app_logger.debug("Attempting to hash password.")
|
13 |
try:
|
14 |
password_bytes = password.encode('utf-8')
|
15 |
salt = bcrypt.gensalt()
|
16 |
hashed_bytes = bcrypt.hashpw(password_bytes, salt)
|
17 |
+
hashed_password_str = hashed_bytes.decode('utf-8')
|
18 |
+
app_logger.debug("Password hashed successfully.")
|
19 |
+
return hashed_password_str
|
20 |
except Exception as e:
|
21 |
+
app_logger.error(f"CRITICAL: Password hashing failed: {e}", exc_info=True)
|
22 |
+
# This exception should be caught by the caller (create_user_in_db)
|
23 |
+
raise ValueError("Password hashing process failed") from e
|
|
|
24 |
|
25 |
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
26 |
+
# (This function is for login, ensure it's also robust)
|
27 |
+
app_logger.debug("Attempting to verify password.")
|
28 |
try:
|
29 |
plain_password_bytes = plain_password.encode('utf-8')
|
30 |
hashed_password_bytes = hashed_password.encode('utf-8')
|
31 |
+
is_valid = bcrypt.checkpw(plain_password_bytes, hashed_password_bytes)
|
32 |
+
app_logger.debug(f"Password verification result: {is_valid}")
|
33 |
+
return is_valid
|
34 |
except Exception as e:
|
35 |
+
app_logger.error(f"CRITICAL: Password verification failed: {e}", exc_info=True)
|
|
|
36 |
return False
|
37 |
|
38 |
+
# --- User Creation with Enhanced Error Handling & Logging ---
|
39 |
def create_user_in_db(user_create_data: UserCreate) -> Optional[User]:
|
40 |
+
app_logger.info(f"Attempting to create user in DB: Username='{user_create_data.username}', Email='{user_create_data.email}'")
|
41 |
+
|
42 |
+
# Pre-validation (optional, but good practice)
|
43 |
+
if not user_create_data.username or not user_create_data.password:
|
44 |
+
app_logger.warning("Signup attempt with empty username or password.")
|
45 |
+
# This should ideally be caught by frontend validation, but good to have a server-side check.
|
46 |
+
return None # Or raise a specific validation error
|
47 |
+
|
48 |
try:
|
49 |
+
with get_session_context() as session: # `session` is a SQLModel Session
|
50 |
+
app_logger.debug("Database session obtained for user creation.")
|
51 |
+
|
52 |
+
# 1. Check if username already exists
|
53 |
+
app_logger.debug(f"Checking for existing username: {user_create_data.username}")
|
54 |
statement_username = select(User).where(User.username == user_create_data.username)
|
55 |
existing_user_by_username = session.exec(statement_username).first()
|
56 |
if existing_user_by_username:
|
57 |
app_logger.warning(f"Signup failed: Username '{user_create_data.username}' already exists.")
|
58 |
+
# Consider returning a more specific error indicator or raising a custom exception
|
59 |
+
return None
|
60 |
|
61 |
+
# 2. Check if email already exists (if provided and should be unique)
|
62 |
if user_create_data.email:
|
63 |
+
app_logger.debug(f"Checking for existing email: {user_create_data.email}")
|
64 |
statement_email = select(User).where(User.email == user_create_data.email)
|
65 |
existing_user_by_email = session.exec(statement_email).first()
|
66 |
if existing_user_by_email:
|
67 |
app_logger.warning(f"Signup failed: Email '{user_create_data.email}' already exists.")
|
68 |
return None
|
69 |
|
70 |
+
# 3. Hash the password
|
71 |
+
app_logger.debug("Attempting to hash password for new user.")
|
72 |
try:
|
73 |
hashed_pw = hash_password(user_create_data.password)
|
74 |
+
except ValueError as e_hash: # Catch specific hashing failure
|
75 |
+
app_logger.error(f"Password hashing failed for user '{user_create_data.username}': {e_hash}", exc_info=True)
|
76 |
+
return None # Fail signup if password cannot be hashed
|
|
|
77 |
|
78 |
+
# 4. Create the new user object
|
79 |
+
app_logger.debug("Creating User ORM object.")
|
80 |
db_user = User(
|
81 |
username=user_create_data.username,
|
82 |
email=user_create_data.email,
|
83 |
+
# Ensure UserCreate and User models have these fields if you use them
|
84 |
+
full_name=getattr(user_create_data, 'full_name', None),
|
85 |
+
disabled=getattr(user_create_data, 'disabled', False),
|
86 |
hashed_password=hashed_pw
|
87 |
)
|
88 |
|
89 |
+
# 5. Add to session and attempt commit (via context manager)
|
90 |
+
app_logger.debug(f"Adding new user ORM object to session for username: {db_user.username}")
|
91 |
session.add(db_user)
|
92 |
+
|
93 |
+
# The commit will be attempted when the 'get_session_context' exits.
|
94 |
+
# If IntegrityError (like unique constraint) occurs, it will be caught by the outer try-except.
|
95 |
+
# We need to refresh to get DB-generated values like ID.
|
96 |
+
# This refresh should happen *before* the commit if the ID is needed immediately
|
97 |
+
# by the caller, but in SQLModel, the refresh is often done after a successful commit
|
98 |
+
# to get all DB state. Since the context manager handles the commit,
|
99 |
+
# we can try to refresh here, anticipating the commit.
|
100 |
+
# However, if the commit fails, this refresh might also be problematic.
|
101 |
+
# A common pattern is: add, then let context manager commit, then if successful, re-fetch or use passed-in data.
|
102 |
+
# For now, let's assume the context manager handles commit on exit.
|
103 |
+
# If the user object is needed with its ID *immediately after this function returns successfully*,
|
104 |
+
# and before another session, then a refresh after commit is essential.
|
105 |
+
# The current design: returns User obj, app.py uses primitive data from form.
|
106 |
+
|
107 |
+
# For now, let's log before the context manager attempts commit.
|
108 |
+
app_logger.info(f"User object for '{db_user.username}' added to session. Commit will be attempted by context manager.")
|
109 |
+
# If you need the ID right away, you'd commit explicitly and refresh here:
|
110 |
+
# session.commit()
|
111 |
+
# session.refresh(db_user)
|
112 |
|
113 |
+
# The `get_session_context` will handle the commit. If it fails (e.g. IntegrityError),
|
114 |
+
# it will rollback and raise the exception, caught by the outer `except` block.
|
115 |
+
# If successful, we need to ensure the returned object has its ID.
|
116 |
+
# A common way to ensure the ID is populated is to flush and then refresh,
|
117 |
+
# or ensure the session is configured to load IDs after insert.
|
118 |
+
# SQLModel typically handles this well if primary_key=True, default=None.
|
119 |
+
# Let's rely on the session returning the object with its ID after add and successful commit.
|
120 |
+
# To be absolutely sure the ID is available if the commit in context manager works:
|
121 |
+
# One strategy is to commit here then refresh.
|
122 |
+
# Another is to let the context handle commit, then if this function must return the ID,
|
123 |
+
# it should re-fetch, or the calling code should handle that if it needs a "live" object later.
|
124 |
+
# Since `app.py` uses the input username for the success message, this is less critical *immediately*.
|
125 |
+
|
126 |
+
# The object `db_user` should have its ID populated after the context manager successfully commits.
|
127 |
+
# If `create_user_in_db` is expected to return a fully usable object with ID,
|
128 |
+
# an explicit commit and refresh *within* the `with` block (before returning) is safest.
|
129 |
+
session.flush() # Flushes to DB, assigns ID if auto-increment
|
130 |
+
session.refresh(db_user) # Refreshes from DB state, ensuring ID and other defaults are loaded
|
131 |
+
|
132 |
+
app_logger.info(f"User '{db_user.username}' (ID: {db_user.id}) prepared for commit. Returning object.")
|
133 |
+
return db_user # This object will be committed by the context manager if no errors.
|
134 |
+
|
135 |
+
except IntegrityError as ie: # Catch specific database integrity errors (like unique constraints not caught above)
|
136 |
+
app_logger.error(f"Database IntegrityError during user creation for '{user_create_data.username}': {ie}", exc_info=True)
|
137 |
+
# session.rollback() is handled by get_session_context
|
138 |
+
return None
|
139 |
+
except ValueError as ve: # Catch value errors, e.g. from hashing if not caught internally
|
140 |
+
app_logger.error(f"ValueError during user creation for '{user_create_data.username}': {ve}", exc_info=True)
|
141 |
+
return None
|
142 |
except Exception as e:
|
143 |
+
# This is a catch-all for any other unexpected errors.
|
144 |
+
app_logger.error(f"CRITICAL UNEXPECTED error during user creation for '{user_create_data.username}': {e}", exc_info=True)
|
145 |
# session.rollback() is handled by get_session_context
|
146 |
return None
|
147 |
|
148 |
+
# --- User Authentication (ensure this is robust too) ---
|
149 |
def authenticate_user(username_in: str, password_in: str) -> Optional[User]:
|
150 |
+
# (Keep the robust version of authenticate_user from previous response)
|
|
|
|
|
|
|
151 |
app_logger.info(f"Attempting to authenticate user: {username_in}")
|
152 |
try:
|
153 |
with get_session_context() as session:
|
|
|
158 |
app_logger.warning(f"Authentication failed: User '{username_in}' not found.")
|
159 |
return None
|
160 |
|
161 |
+
if hasattr(user, 'disabled') and user.disabled:
|
162 |
app_logger.warning(f"Authentication failed: User '{username_in}' is disabled.")
|
163 |
return None
|
164 |
|
|
|
166 |
app_logger.warning(f"Authentication failed: Invalid password for user '{username_in}'.")
|
167 |
return None
|
168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
169 |
app_logger.info(f"User '{user.username}' (ID: {user.id}) authenticated successfully.")
|
170 |
+
return user
|
171 |
|
172 |
except Exception as e:
|
173 |
app_logger.error(f"Database or unexpected error during authentication for '{username_in}': {e}", exc_info=True)
|