fastapi-myauth 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.
Files changed (37) hide show
  1. fastapi_myauth/__init__.py +0 -0
  2. fastapi_myauth/api/__init__.py +0 -0
  3. fastapi_myauth/api/deps.py +153 -0
  4. fastapi_myauth/api/v1/__init__.py +2 -0
  5. fastapi_myauth/api/v1/login.py +364 -0
  6. fastapi_myauth/api/v1/users.py +219 -0
  7. fastapi_myauth/auth.py +81 -0
  8. fastapi_myauth/config.py +59 -0
  9. fastapi_myauth/crud/__init__.py +2 -0
  10. fastapi_myauth/crud/base.py +70 -0
  11. fastapi_myauth/crud/crud_token.py +41 -0
  12. fastapi_myauth/crud/crud_user.py +95 -0
  13. fastapi_myauth/email-templates/build/confirm_email.html +24 -0
  14. fastapi_myauth/email-templates/build/magic_login.html +25 -0
  15. fastapi_myauth/email-templates/build/new_account.html +194 -0
  16. fastapi_myauth/email-templates/build/reset_password.html +25 -0
  17. fastapi_myauth/email-templates/build/test_email.html +25 -0
  18. fastapi_myauth/email-templates/build/web_contact_email.html +24 -0
  19. fastapi_myauth/email-templates/src/confirm_email.mjml +52 -0
  20. fastapi_myauth/email-templates/src/magic_login.mjml +54 -0
  21. fastapi_myauth/email-templates/src/new_account.mjml +43 -0
  22. fastapi_myauth/email-templates/src/reset_password.mjml +57 -0
  23. fastapi_myauth/email-templates/src/test_email.mjml +11 -0
  24. fastapi_myauth/email-templates/src/web_contact_email.mjml +20 -0
  25. fastapi_myauth/email.py +123 -0
  26. fastapi_myauth/models/__init__.py +14 -0
  27. fastapi_myauth/models/emails.py +14 -0
  28. fastapi_myauth/models/msg.py +5 -0
  29. fastapi_myauth/models/token.py +45 -0
  30. fastapi_myauth/models/totp.py +18 -0
  31. fastapi_myauth/models/user.py +64 -0
  32. fastapi_myauth/security.py +127 -0
  33. fastapi_myauth/test_main.py +39 -0
  34. fastapi_myauth-0.1.0.dist-info/METADATA +15 -0
  35. fastapi_myauth-0.1.0.dist-info/RECORD +37 -0
  36. fastapi_myauth-0.1.0.dist-info/WHEEL +4 -0
  37. fastapi_myauth-0.1.0.dist-info/licenses/LICENSE +21 -0
File without changes
File without changes
@@ -0,0 +1,153 @@
1
+ from collections.abc import Callable
2
+
3
+ import jwt
4
+ from fastapi import Depends, HTTPException, status
5
+ from fastapi.security import OAuth2PasswordBearer
6
+ from jwt.exceptions import InvalidTokenError
7
+ from pydantic import ValidationError
8
+ from sqlmodel import Session
9
+
10
+ from fastapi_myauth import crud, models
11
+
12
+ from ..config import settings
13
+
14
+ reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/login/oauth")
15
+
16
+
17
+ def get_deps(
18
+ crud_user: crud.crud_user.CRUDUser,
19
+ SessionDep: Callable[[], Session],
20
+ ) -> dict[str, Callable]:
21
+ def get_token_payload(token: str) -> models.TokenPayload:
22
+ try:
23
+ payload = jwt.decode(
24
+ token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGO]
25
+ )
26
+ token_data = models.TokenPayload(**payload)
27
+ except (InvalidTokenError, ValidationError):
28
+ raise HTTPException(
29
+ status_code=status.HTTP_403_FORBIDDEN,
30
+ detail="Could not validate credentials",
31
+ )
32
+ return token_data
33
+
34
+ def get_current_user(token: str = Depends(reusable_oauth2)) -> models.User:
35
+ token_data = get_token_payload(token)
36
+ if token_data.refresh or token_data.totp:
37
+ # Refresh token is not a valid access token and TOTP True can only be used to validate TOTP
38
+ raise HTTPException(
39
+ status_code=status.HTTP_403_FORBIDDEN,
40
+ detail="Could not validate credentials",
41
+ )
42
+ user = crud_user.get(SessionDep(), id=token_data.sub)
43
+ if not user:
44
+ raise HTTPException(
45
+ status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
46
+ )
47
+ return user
48
+
49
+ def get_totp_user(token: str = Depends(reusable_oauth2)) -> models.User:
50
+ token_data = get_token_payload(token)
51
+ if token_data.refresh or not token_data.totp:
52
+ # Refresh token is not a valid access token and TOTP False cannot be used to validate TOTP
53
+ raise HTTPException(
54
+ status_code=status.HTTP_403_FORBIDDEN,
55
+ detail="Could not validate credentials",
56
+ )
57
+ user = crud_user.get(SessionDep(), id=token_data.sub)
58
+ if not user:
59
+ raise HTTPException(
60
+ status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
61
+ )
62
+ return user
63
+
64
+ def get_magic_token(
65
+ token: str = Depends(reusable_oauth2),
66
+ ) -> models.MagicTokenPayload:
67
+ try:
68
+ payload = jwt.decode(
69
+ token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGO]
70
+ )
71
+ token_data = models.MagicTokenPayload(**payload)
72
+ except (InvalidTokenError, ValidationError):
73
+ raise HTTPException(
74
+ status_code=status.HTTP_403_FORBIDDEN,
75
+ detail="Could not validate credentials",
76
+ )
77
+ return token_data
78
+
79
+ def get_refresh_user(token: str = Depends(reusable_oauth2)) -> models.User:
80
+ token_data = get_token_payload(token)
81
+ if not token_data.refresh:
82
+ # Access token is not a valid refresh token
83
+ raise HTTPException(
84
+ status_code=status.HTTP_403_FORBIDDEN,
85
+ detail="Could not validate credentials",
86
+ )
87
+ user = crud_user.get(SessionDep(), id=token_data.sub)
88
+ if not user:
89
+ raise HTTPException(
90
+ status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
91
+ )
92
+ if not crud_user.is_active(user):
93
+ raise HTTPException(
94
+ status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user"
95
+ )
96
+ # Check and revoke this refresh token
97
+ token_obj = crud.token.get(token=token, user=user)
98
+ if not token_obj:
99
+ raise HTTPException(
100
+ status_code=status.HTTP_403_FORBIDDEN,
101
+ detail="Could not validate credentials",
102
+ )
103
+ crud.token.remove(SessionDep(), db_obj=token_obj)
104
+ return user
105
+
106
+ def get_current_active_user(
107
+ current_user: models.User = Depends(get_current_user),
108
+ ) -> models.User:
109
+ if not crud_user.is_active(current_user):
110
+ raise HTTPException(
111
+ status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user"
112
+ )
113
+ return current_user
114
+
115
+ def get_current_active_superuser(
116
+ current_user: models.User = Depends(get_current_user),
117
+ ) -> models.User:
118
+ if not crud_user.is_superuser(current_user):
119
+ raise HTTPException(
120
+ status_code=status.HTTP_403_FORBIDDEN,
121
+ detail="The user doesn't have enough privileges",
122
+ )
123
+ return current_user
124
+
125
+ def get_active_websocket_user(db: Session, token: str) -> models.User:
126
+ try:
127
+ payload = jwt.decode(
128
+ token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGO]
129
+ )
130
+ token_data = models.TokenPayload(**payload)
131
+ except (InvalidTokenError, ValidationError):
132
+ raise ValidationError("Could not validate credentials")
133
+ if token_data.refresh:
134
+ # Refresh token is not a valid access token
135
+ raise ValidationError("Could not validate credentials")
136
+ user = crud_user.get(db, id=token_data.sub)
137
+ if not user:
138
+ raise ValidationError("User not found")
139
+ if not crud_user.is_active(user):
140
+ raise ValidationError("Inactive user")
141
+ return user
142
+
143
+ return {
144
+ "SessionDep": SessionDep,
145
+ "get_token_payload": get_token_payload,
146
+ "get_current_user": get_current_user,
147
+ "get_totp_user": get_totp_user,
148
+ "get_magic_token": get_magic_token,
149
+ "get_refresh_user": get_refresh_user,
150
+ "get_current_active_user": get_current_active_user,
151
+ "get_current_active_superuser": get_current_active_superuser,
152
+ "get_active_websocket_user": get_active_websocket_user,
153
+ }
@@ -0,0 +1,2 @@
1
+ from .login import get_login_router # noqa: F401
2
+ from .users import get_user_router # noqa: F401
@@ -0,0 +1,364 @@
1
+ from collections.abc import Callable
2
+ from datetime import timedelta
3
+ from typing import Annotated, Any
4
+
5
+ from fastapi import APIRouter, Body, Depends, HTTPException, status
6
+ from fastapi.security import OAuth2PasswordRequestForm
7
+ from pydantic import EmailStr
8
+ from sqlmodel import Session
9
+
10
+ from fastapi_myauth import crud, models, security
11
+ from fastapi_myauth.config import settings
12
+ from fastapi_myauth.email import (
13
+ send_magic_login_email,
14
+ send_reset_password_email,
15
+ )
16
+
17
+ router = APIRouter()
18
+
19
+ """
20
+ https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Authentication_Cheat_Sheet.md
21
+ Specifies minimum criteria:
22
+ - Change password must require current password verification to ensure that it's the legitimate user.
23
+ - Login page and all subsequent authenticated pages must be exclusively accessed over TLS or other strong transport.
24
+ - An application should respond with a generic error message regardless of whether:
25
+ - The user ID or password was incorrect.
26
+ - The account does not exist.
27
+ - The account is locked or disabled.
28
+ - Code should go through the same process, no matter what, allowing the application to return in approximately
29
+ the same response time.
30
+ - In the words of George Orwell, break these rules sooner than do something truly barbaric.
31
+
32
+ See `security.py` for other requirements.
33
+ """
34
+
35
+
36
+ def get_login_router(
37
+ user_model: type[models.User],
38
+ user_read: type[models.UserRead],
39
+ user_create: type[models.UserCreate],
40
+ user_update: type[models.UserUpdate],
41
+ crud_user: crud.crud_user.CRUDUser,
42
+ deps: dict[str, Callable],
43
+ ) -> APIRouter:
44
+ router = APIRouter()
45
+
46
+ @router.post("/signup", response_model=user_read)
47
+ def create_user_profile(
48
+ *,
49
+ db: Annotated[Session, Depends(deps["SessionDep"])],
50
+ password: Annotated[str, Body()],
51
+ email: Annotated[EmailStr, Body()],
52
+ full_name: str = Body(None),
53
+ ) -> Any:
54
+ """
55
+ Create new user without the need to be logged in.
56
+ """
57
+ if not settings.USERS_OPEN_REGISTRATION:
58
+ raise HTTPException(
59
+ status_code=status.HTTP_400_BAD_REQUEST,
60
+ detail="Registration is closed.",
61
+ )
62
+ user = crud_user.get_by_email(db, email=email)
63
+ if user:
64
+ raise HTTPException(
65
+ status_code=status.HTTP_400_BAD_REQUEST,
66
+ detail="This username is not available.",
67
+ )
68
+ # Create user auth
69
+ user_in = user_create(password=password, email=email, full_name=full_name)
70
+ user = crud_user.create(db, obj_in=user_in)
71
+ return user
72
+
73
+ @router.post("/magic/{email}")
74
+ def login_with_magic_link(
75
+ *, db: Annotated[Session, Depends(deps["SessionDep"])], email: str
76
+ ) -> models.WebToken:
77
+ """
78
+ First step of a 'magic link' login. Check if the user exists and generate a magic link. Generates two short-duration
79
+ jwt tokens, one for validation, one for email. Creates user if not exist.
80
+ """
81
+ user = crud_user.get_by_email(db, email=email)
82
+ if not user:
83
+ if not settings.USERS_OPEN_REGISTRATION:
84
+ raise HTTPException(
85
+ status_code=status.HTTP_400_BAD_REQUEST,
86
+ detail="Registration is closed.",
87
+ )
88
+ user_in = user_create(email=email)
89
+ user = crud_user.create(db, obj_in=user_in)
90
+ if not crud_user.is_active(user):
91
+ # Still permits a timed-attack, but does create ambiguity.
92
+ raise HTTPException(
93
+ status_code=400,
94
+ detail="A link to activate your account has been emailed.",
95
+ )
96
+ tokens = security.create_magic_tokens(subject=user.id)
97
+ if settings.EMAILS_ENABLED and user.email:
98
+ # Send email with user.email as subject
99
+ send_magic_login_email(email_to=user.email, token=tokens[0])
100
+ return models.WebToken(claim=tokens[1])
101
+
102
+ @router.post("/claim")
103
+ def validate_magic_link(
104
+ *,
105
+ db: Annotated[Session, Depends(deps["SessionDep"])],
106
+ obj_in: models.WebToken,
107
+ magic_in: Annotated[models.MagicTokenPayload, Depends(deps["get_magic_token"])],
108
+ ) -> models.Token:
109
+ """
110
+ Second step of a 'magic link' login.
111
+ """
112
+ claim_in = deps["get_magic_token"](token=obj_in.claim)
113
+ # Get the user
114
+ user = crud_user.get(db, id=magic_in.sub)
115
+ # Test the claims
116
+ if (
117
+ (claim_in.sub == magic_in.sub)
118
+ or (claim_in.fingerprint != magic_in.fingerprint)
119
+ or not user
120
+ or not crud_user.is_active(user)
121
+ ):
122
+ raise HTTPException(
123
+ status_code=status.HTTP_400_BAD_REQUEST,
124
+ detail="Login failed; invalid claim.",
125
+ )
126
+ # Validate that the email is the user's
127
+ if not user.email_validated:
128
+ crud_user.validate_email(db=db, db_obj=user)
129
+ # Check if totp active
130
+ refresh_token = None
131
+ force_totp = True
132
+ if not user.totp_secret:
133
+ # No TOTP, so this concludes the login validation
134
+ force_totp = False
135
+ refresh_token = security.create_refresh_token(subject=user.id)
136
+ crud.token.create(db=db, obj_in=refresh_token, user_obj=user)
137
+ return models.Token(
138
+ access_token=security.create_access_token(
139
+ subject=user.id, force_totp=force_totp
140
+ ),
141
+ refresh_token=refresh_token,
142
+ token_type="bearer",
143
+ )
144
+
145
+ @router.post("/oauth")
146
+ def login_with_oauth2(
147
+ db: Annotated[Session, Depends(deps["SessionDep"])],
148
+ form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
149
+ ) -> models.Token:
150
+ """
151
+ First step with OAuth2 compatible token login, get an access token for future requests.
152
+ """
153
+ user = crud_user.authenticate(
154
+ db, email=form_data.username, password=form_data.password
155
+ )
156
+ if not form_data.password or not user or not crud_user.is_active(user):
157
+ raise HTTPException(
158
+ status_code=status.HTTP_400_BAD_REQUEST,
159
+ detail="Login failed; incorrect email or password",
160
+ )
161
+ # Check if totp active
162
+ refresh_token = None
163
+ force_totp = True
164
+ if not user.totp_secret:
165
+ # No TOTP, so this concludes the login validation
166
+ force_totp = False
167
+ refresh_token = security.create_refresh_token(subject=user.id)
168
+ crud.token.create(db=db, obj_in=refresh_token, user_obj=user)
169
+ return models.Token(
170
+ access_token=security.create_access_token(
171
+ subject=user.id, force_totp=force_totp
172
+ ),
173
+ refresh_token=refresh_token,
174
+ token_type="bearer",
175
+ )
176
+
177
+ @router.post("/new-totp", response_model=models.NewTOTPResponse)
178
+ def request_new_totp(
179
+ *,
180
+ current_user: Annotated[
181
+ user_model, Depends(deps["get_current_active_superuser"])
182
+ ],
183
+ ) -> Any:
184
+ """
185
+ Request new keys to enable TOTP on the user account.
186
+ """
187
+ obj_in = security.create_new_totp(label=current_user.email)
188
+ # Remove the secret ...
189
+ return obj_in
190
+
191
+ @router.post("/totp")
192
+ def login_with_totp(
193
+ *,
194
+ db: Annotated[Session, Depends(deps["SessionDep"])],
195
+ totp_data: models.WebToken,
196
+ current_user: Annotated[user_model, Depends(deps["get_totp_user"])],
197
+ ) -> models.Token:
198
+ """
199
+ Final validation step, using TOTP.
200
+ """
201
+ if not current_user.totp_secret:
202
+ raise HTTPException(
203
+ status_code=400, detail="Login failed; TOTP is not enabled."
204
+ )
205
+ new_counter = security.verify_totp(
206
+ token=totp_data.claim,
207
+ secret=current_user.totp_secret,
208
+ last_counter=current_user.totp_counter,
209
+ )
210
+ if not new_counter:
211
+ raise HTTPException(
212
+ status_code=400, detail="Login failed; unable to verify TOTP."
213
+ )
214
+ # Save the new counter to prevent reuse
215
+ current_user = crud_user.update_totp_counter(
216
+ db=db, db_obj=current_user, new_counter=new_counter
217
+ )
218
+ refresh_token = security.create_refresh_token(subject=current_user.id)
219
+ crud.token.create(db=db, obj_in=refresh_token, user_obj=current_user)
220
+ return models.Token(
221
+ access_token=security.create_access_token(subject=current_user.id),
222
+ refresh_token=refresh_token,
223
+ token_type="bearer",
224
+ )
225
+
226
+ @router.put("/totp")
227
+ def enable_totp_authentication(
228
+ *,
229
+ db: Annotated[Session, Depends(deps["SessionDep"])],
230
+ data_in: models.EnableTOTP,
231
+ current_user: Annotated[user_model, Depends(deps["get_current_active_user"])],
232
+ ) -> models.Msg:
233
+ """
234
+ For validation of token before enabling TOTP.
235
+ """
236
+ if current_user.hashed_password:
237
+ user = crud_user.authenticate(
238
+ db, email=current_user.email, password=data_in.password
239
+ )
240
+ if not data_in.password or not user:
241
+ raise HTTPException(
242
+ status_code=status.HTTP_400_BAD_REQUEST,
243
+ detail="Unable to authenticate or activate TOTP.",
244
+ )
245
+ totp_in = security.create_new_totp(label=current_user.email, uri=data_in.uri)
246
+ new_counter = security.verify_totp(
247
+ token=data_in.claim,
248
+ secret=totp_in.secret,
249
+ last_counter=current_user.totp_counter,
250
+ )
251
+ if not new_counter:
252
+ raise HTTPException(
253
+ status_code=status.HTTP_400_BAD_REQUEST,
254
+ detail="Unable to authenticate or activate TOTP.",
255
+ )
256
+ # Enable TOTP and save the new counter to prevent reuse
257
+ current_user = crud_user.activate_totp(
258
+ db=db, db_obj=current_user, totp_in=totp_in
259
+ )
260
+ current_user = crud_user.update_totp_counter(
261
+ db=db, db_obj=current_user, new_counter=new_counter
262
+ )
263
+ return models.Msg(msg="TOTP enabled. Do not lose your recovery code.")
264
+
265
+ @router.delete("/totp")
266
+ def disable_totp_authentication(
267
+ *,
268
+ db: Annotated[Session, Depends(deps["SessionDep"])],
269
+ data_in: user_update,
270
+ current_user: Annotated[user_model, Depends(deps["get_current_active_user"])],
271
+ ) -> models.Msg:
272
+ """
273
+ Disable TOTP.
274
+ """
275
+ if current_user.hashed_password:
276
+ user = crud_user.authenticate(
277
+ db, email=current_user.email, password=data_in.original
278
+ )
279
+ if not data_in.original or not user:
280
+ raise HTTPException(
281
+ status_code=status.HTTP_400_BAD_REQUEST,
282
+ detail="Unable to authenticate or deactivate TOTP.",
283
+ )
284
+ crud_user.deactivate_totp(db=db, db_obj=current_user)
285
+ return models.Msg(msg="TOTP disabled. You can re-enable it at any time.")
286
+
287
+ @router.post("/refresh")
288
+ def refresh_token(
289
+ db: Annotated[Session, Depends(deps["SessionDep"])],
290
+ current_user: Annotated[user_model, Depends(deps["get_refresh_user"])],
291
+ ) -> models.Token:
292
+ """
293
+ Refresh tokens for future requests
294
+ """
295
+ refresh_token = security.create_refresh_token(subject=current_user.id)
296
+ crud.token.create(db=db, obj_in=refresh_token, user_obj=current_user)
297
+ return models.Token(
298
+ access_token=security.create_access_token(subject=current_user.id),
299
+ refresh_token=refresh_token,
300
+ token_type="bearer",
301
+ )
302
+
303
+ @router.post("/revoke", dependencies=[Depends(deps["get_refresh_user"])])
304
+ def revoke_refresh_token() -> models.Msg:
305
+ """
306
+ Revoke a refresh token
307
+ """
308
+ return models.Msg(msg="Token revoked")
309
+
310
+ @router.post("/recover/{email}")
311
+ def recover_password(
312
+ email: str, db: Annotated[Session, Depends(deps["SessionDep"])]
313
+ ) -> models.WebToken | models.Msg:
314
+ """
315
+ Password Recovery
316
+ """
317
+ user = crud_user.get_by_email(db, email=email)
318
+ if user and crud_user.is_active(user):
319
+ tokens = security.create_magic_tokens(
320
+ subject=user.id,
321
+ expires_delta=timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS),
322
+ )
323
+ if settings.EMAILS_ENABLED:
324
+ send_reset_password_email(
325
+ email_to=user.email, email=email, token=tokens[0]
326
+ )
327
+ return models.WebToken(claim=tokens[1])
328
+ return models.Msg(
329
+ msg="If that login exists, we'll send you an email to reset your password."
330
+ )
331
+
332
+ @router.post("/reset")
333
+ def reset_password(
334
+ *,
335
+ db: Annotated[Session, Depends(deps["SessionDep"])],
336
+ new_password: Annotated[str, Body()],
337
+ claim: Annotated[str, Body()],
338
+ magic_in: Annotated[models.MagicTokenPayload, Depends(deps["get_magic_token"])],
339
+ ) -> models.Msg:
340
+ """
341
+ Reset password
342
+ """
343
+ claim_in = deps["get_magic_token"](token=claim)
344
+ # Get the user
345
+ user = crud_user.get(db, id=magic_in.sub)
346
+ # Test the claims
347
+ if (
348
+ (claim_in.sub == magic_in.sub)
349
+ or (claim_in.fingerprint != magic_in.fingerprint)
350
+ or not user
351
+ or not crud_user.is_active(user)
352
+ ):
353
+ raise HTTPException(
354
+ status_code=status.HTTP_400_BAD_REQUEST,
355
+ detail="Password update failed; invalid claim.",
356
+ )
357
+ # Update the password
358
+ hashed_password = security.get_password_hash(new_password)
359
+ user.hashed_password = hashed_password
360
+ db.add(user)
361
+ db.commit()
362
+ return models.Msg(msg="Password updated successfully.")
363
+
364
+ return router