main
py 328 lines 11.6 KB
Raw
1 import os
2 from datetime import datetime
3 from datetime import timedelta
4
5 import bcrypt
6 import jwt
7 from fastapi import Depends
8 from fastapi import HTTPException
9 from fastapi.security import OAuth2PasswordBearer
10 from fastapi.security import SecurityScopes
11 from loguru import logger
12
13 from app.auth.services.universal import find_user
14 from app.auth.services.universal import get_role
15
16 # Known-compromised value published in prior versions of .env.example and as a
17 # hardcoded fallback. Refuse to boot if it reappears, regardless of source.
18 _KNOWN_COMPROMISED_JWT_SECRET = "bL4unrkoxtFs1MT6A7Ns2yMLkduyuqrkTxDV9CjlbNc="
19
20
21 def _load_jwt_secret() -> str:
22 secret = os.environ.get("JWT_SECRET")
23 if not secret:
24 raise RuntimeError(
25 "JWT_SECRET environment variable is not set. Generate a secure value "
26 "with `openssl rand -base64 32` and set it before starting the application.",
27 )
28 if secret == _KNOWN_COMPROMISED_JWT_SECRET:
29 raise RuntimeError(
30 "JWT_SECRET is set to the known-compromised default value disclosed in "
31 "GHSA-4gxj-hw3c-3x2x. Generate a new secret with `openssl rand -base64 32` "
32 "and update your environment.",
33 )
34 return secret
35
36
37 class AuthHandler:
38 security = OAuth2PasswordBearer(
39 tokenUrl="api/auth/token",
40 scopes={
41 "admin": "Admin users",
42 "analyst": "SOC Analysts",
43 "scheduler": "Scheduler for automated tasks",
44 "customer_user": "Customer portal users",
45 },
46 )
47 secret = _load_jwt_secret()
48
49 @staticmethod
50 def _to_bcrypt_bytes(password: str) -> bytes:
51 # bcrypt 5 raises ValueError on >72 bytes; bcrypt 4 silently truncated.
52 # We slice by bytes (not chars) for backwards compat with hashes created
53 # under bcrypt 4's silent truncation, while protecting against bcrypt 5
54 # raising for any path that bypasses the schema-layer length validation.
55 return password.encode("utf-8")[:72]
56
57 def get_password_hash(self, password: str) -> str:
58 return bcrypt.hashpw(self._to_bcrypt_bytes(password), bcrypt.gensalt()).decode("utf-8")
59
60 def verify_password(self, plain_password: str, hashed_password: str) -> bool:
61 return bcrypt.checkpw(self._to_bcrypt_bytes(plain_password), hashed_password.encode("utf-8"))
62
63 # ! TODO: HAVE LOGIC TO HANDLE PASSWORD RESET VIA A TOKEN BUT NOT IMPLEMENTED YET ! #
64 def generate_reset_token(
65 self,
66 username: str,
67 expires_delta: timedelta = timedelta(minutes=30),
68 ):
69 """
70 Generates a password reset token.
71
72 Args:
73 username (str): The username for which the token is being generated.
74 expires_delta (timedelta, optional): The expiration time for the token.
75 Defaults to 30 minutes.
76
77 Returns:
78 str: The generated reset token.
79 """
80 to_encode = {"exp": datetime.utcnow() + expires_delta, "sub": username}
81 encoded_jwt = jwt.encode(to_encode, self.secret, algorithm="HS256")
82 return encoded_jwt
83
84 # ! TODO: HAVE LOGIC TO HANDLE PASSWORD RESET VIA A TOKEN BUT NOT IMPLEMENTED YET ! #
85 # def verify_reset_token(self, token: str, username: str):
86 # """
87 # Verifies a password reset token.
88
89 # Args:
90 # token (str): The reset token to verify.
91 # username (str): The username for which the token was generated.
92
93 # Returns:
94 # bool: True if the token is valid and not expired, False otherwise.
95 # """
96 # try:
97 # payload = jwt.decode(token, self.secret, algorithms=["HS256"])
98 # return payload["sub"] == username
99 # except jwt.ExpiredSignatureError:
100 # return False
101
102 async def verify_reset_token_me(self, token: str, user):
103 """
104 Verifies a password reset token and checks that the username in the token matches the provided user's username.
105
106 Args:
107 token (str): The reset token to verify.
108 user: The user for which the token should be verified.
109
110 Returns:
111 The username from the token if the token is valid, None otherwise.
112 """
113 try:
114 payload = jwt.decode(token, self.secret, algorithms=["HS256"])
115 if payload["sub"] == user.username:
116 return payload["sub"]
117 else:
118 raise HTTPException(
119 status_code=401,
120 detail="Invalid token. Username does not match.",
121 )
122 except jwt.ExpiredSignatureError:
123 raise HTTPException(status_code=401, detail="Token expired")
124 except jwt.InvalidTokenError:
125 raise HTTPException(status_code=401, detail="Invalid token")
126
127 # ! New with Async
128 async def authenticate_user(self, username: str, password: str):
129 """
130 Authenticates a user by checking if the provided username and password match.
131
132 Args:
133 username (str): The username of the user.
134 password (str): The password of the user.
135
136 Returns:
137 Union[User, bool]: The authenticated user object if the username and password match,
138 otherwise False.
139 """
140 user = await find_user(username)
141 try:
142 if not user or not self.verify_password(password, user.password):
143 logger.info("Password is not verified")
144 return False
145 return user
146 except Exception as e:
147 logger.error(f"Error: {e}")
148 return False
149
150 # ! New with Async
151 async def encode_token(
152 self,
153 username: str,
154 access_token_expires: timedelta = timedelta(hours=24),
155 extra_claims: dict = None,
156 ):
157 role = await get_role(username)
158 payload = {
159 "exp": datetime.utcnow() + access_token_expires,
160 "iat": datetime.utcnow(),
161 "sub": username,
162 "scopes": [role],
163 }
164 if extra_claims:
165 payload.update(extra_claims)
166 return jwt.encode(payload, self.secret, algorithm="HS256")
167
168 def decode_token(self, token):
169 """
170 Decode a JWT token and extract the subject and scopes.
171
172 Args:
173 token (str): The JWT token to decode.
174
175 Returns:
176 tuple: A tuple containing the subject and scopes extracted from the token.
177
178 Raises:
179 jwt.ExpiredSignatureError: If the token has expired.
180 jwt.InvalidTokenError: If the token is invalid.
181 """
182 try:
183 payload = jwt.decode(token, self.secret, algorithms=["HS256"])
184 return payload["sub"], payload.get("scopes", [])
185 except jwt.ExpiredSignatureError:
186 return "Expired signature", []
187 except jwt.InvalidTokenError:
188 return "Invalid token", []
189
190 async def get_current_user(
191 self,
192 security_scopes: SecurityScopes,
193 token: str = Depends(security),
194 ):
195 """
196 Retrieves the current user based on the provided security scopes and token.
197
198 Args:
199 security_scopes (SecurityScopes): The security scopes required for authentication.
200 token (str): The authentication token.
201
202 Raises:
203 HTTPException: If the credentials cannot be validated or if the token is expired, invalid, or cannot be decoded.
204 HTTPException: If the username is not found in the token.
205 HTTPException: If the user is not found.
206 HTTPException: If the user does not have enough permissions.
207
208 Returns:
209 User: The current user.
210
211 """
212 if security_scopes.scopes:
213 authenticate_value = f'Bearer scope="{security_scopes.scope_str}"'
214 else:
215 authenticate_value = "Bearer"
216
217 try:
218 username, token_scopes = self.decode_token(token)
219 if username == "Expired signature":
220 raise HTTPException(
221 status_code=401,
222 detail="Expired signature",
223 headers={"WWW-Authenticate": authenticate_value},
224 )
225 if username == "Invalid token":
226 raise HTTPException(
227 status_code=401,
228 detail="Invalid token",
229 headers={"WWW-Authenticate": authenticate_value},
230 )
231 except Exception as e:
232 raise HTTPException(
233 status_code=401,
234 detail=f"Could not decode token: {e}",
235 headers={"WWW-Authenticate": authenticate_value},
236 )
237
238 if username is None:
239 raise HTTPException(
240 status_code=401,
241 detail="Username not found in token",
242 headers={"WWW-Authenticate": authenticate_value},
243 )
244 user = await find_user(username)
245
246 if user is None:
247 raise HTTPException(
248 status_code=401,
249 detail="User not found",
250 headers={"WWW-Authenticate": authenticate_value},
251 )
252
253 for scope in security_scopes.scopes:
254 if scope not in token_scopes:
255 raise HTTPException(
256 status_code=403,
257 detail=f"Insufficient permissions. Required scope: {scope}",
258 headers={"WWW-Authenticate": authenticate_value},
259 )
260
261 return user
262
263 def return_username_for_logging(self, token: str = Depends(security)):
264 """
265 Returns the username extracted from the provided token.
266
267 Parameters:
268 - token (str): The token to decode and extract the username from.
269
270 Returns:
271 - str: The username extracted from the token.
272 """
273 username, token_scopes = self.decode_token(token)
274 return username
275
276 def require_any_scope(self, *required_scopes: str):
277 """
278 Decorator that requires any of the specified scopes in the token.
279
280 Args:
281 *required_scopes (str): The required scopes.
282
283 Returns:
284 Callable: The decorated function that checks if the token has any of the required scopes.
285 """
286
287 async def _require_any_scope(token: str = Depends(self.security)):
288 if not token:
289 raise HTTPException(
290 status_code=401,
291 detail="Not authenticated",
292 headers={"WWW-Authenticate": "Bearer"},
293 )
294
295 username, token_scopes = self.decode_token(token)
296
297 if username == "Expired signature":
298 raise HTTPException(
299 status_code=401,
300 detail="Expired signature",
301 headers={"WWW-Authenticate": "Bearer"},
302 )
303 if username == "Invalid token":
304 raise HTTPException(
305 status_code=401,
306 detail="Invalid token",
307 headers={"WWW-Authenticate": "Bearer"},
308 )
309
310 # Verify user still exists in DB — prevents ghost-user token abuse
311 user = await find_user(username)
312 if user is None:
313 raise HTTPException(
314 status_code=401,
315 detail="User not found",
316 headers={"WWW-Authenticate": "Bearer"},
317 )
318
319 if not any(scope in token_scopes for scope in required_scopes):
320 raise HTTPException(
321 status_code=403,
322 detail=f"Insufficient permissions. Required one of: {', '.join(required_scopes)}",
323 headers={"WWW-Authenticate": "Bearer"},
324 )
325
326 return username
327
328 return _require_any_scope