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.
- fastapi_myauth/__init__.py +0 -0
- fastapi_myauth/api/__init__.py +0 -0
- fastapi_myauth/api/deps.py +153 -0
- fastapi_myauth/api/v1/__init__.py +2 -0
- fastapi_myauth/api/v1/login.py +364 -0
- fastapi_myauth/api/v1/users.py +219 -0
- fastapi_myauth/auth.py +81 -0
- fastapi_myauth/config.py +59 -0
- fastapi_myauth/crud/__init__.py +2 -0
- fastapi_myauth/crud/base.py +70 -0
- fastapi_myauth/crud/crud_token.py +41 -0
- fastapi_myauth/crud/crud_user.py +95 -0
- fastapi_myauth/email-templates/build/confirm_email.html +24 -0
- fastapi_myauth/email-templates/build/magic_login.html +25 -0
- fastapi_myauth/email-templates/build/new_account.html +194 -0
- fastapi_myauth/email-templates/build/reset_password.html +25 -0
- fastapi_myauth/email-templates/build/test_email.html +25 -0
- fastapi_myauth/email-templates/build/web_contact_email.html +24 -0
- fastapi_myauth/email-templates/src/confirm_email.mjml +52 -0
- fastapi_myauth/email-templates/src/magic_login.mjml +54 -0
- fastapi_myauth/email-templates/src/new_account.mjml +43 -0
- fastapi_myauth/email-templates/src/reset_password.mjml +57 -0
- fastapi_myauth/email-templates/src/test_email.mjml +11 -0
- fastapi_myauth/email-templates/src/web_contact_email.mjml +20 -0
- fastapi_myauth/email.py +123 -0
- fastapi_myauth/models/__init__.py +14 -0
- fastapi_myauth/models/emails.py +14 -0
- fastapi_myauth/models/msg.py +5 -0
- fastapi_myauth/models/token.py +45 -0
- fastapi_myauth/models/totp.py +18 -0
- fastapi_myauth/models/user.py +64 -0
- fastapi_myauth/security.py +127 -0
- fastapi_myauth/test_main.py +39 -0
- fastapi_myauth-0.1.0.dist-info/METADATA +15 -0
- fastapi_myauth-0.1.0.dist-info/RECORD +37 -0
- fastapi_myauth-0.1.0.dist-info/WHEEL +4 -0
- 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,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
|