Skip to content

Commit 48c5c84

Browse files
authored
Merge pull request #4 from CaptainAril/feat/password-change-reset
Feat/password change reset
2 parents 65763a6 + 8cedd64 commit 48c5c84

File tree

13 files changed

+312
-19
lines changed

13 files changed

+312
-19
lines changed

src/api/constants/activity_types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ class ActivityTypes(TypedDict):
1717
LIST_WITHDRAW_ACCOUNTS: str
1818
FETCH_WITHDRAW_ACCOUNT: str
1919
DELETE_WITHDRAW_ACCOUNT: str
20+
CHANGE_PASSWORD: str
21+
REQUEST_RESET_PASSWORD: str
22+
CONFIRM_RESET_PASSWORD: str
2023
FETCH_NOK: str
2124
UPDATE_NOK: str
2225
FETCH_KYC: str
@@ -39,6 +42,9 @@ class ActivityTypes(TypedDict):
3942
"LIST_WITHDRAW_ACCOUNTS": "List withdraw accounts",
4043
"FETCH_WITHDRAW_ACCOUNT": "Get withdraw account",
4144
"DELETE_WITHDRAW_ACCOUNT": "Delete withdraw account",
45+
"CHANGE_PASSWORD": "Change user password",
46+
"REQUEST_RESET_PASSWORD": "Request for password reset",
47+
"CONFIRM_RESET_PASSWORD": "Confirm password reset",
4248
"FETCH_NOK": "Fetch Next Of Kin",
4349
"UPDATE_NOK": "Update Next Of Kin",
4450
"FETCH_KYC": "Fetch KYC Information",

src/api/constants/messages.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ class UserMessages(TypedDict):
3333
NOT_ALLOWED: str
3434
PIN_EXISTS: str
3535
PIN_SET: str
36+
INCORRECT_PASSWORD: str
37+
PASSWORD_CHANGED: str
3638

3739

3840
class AccountMessages(TypedDict):
@@ -56,6 +58,14 @@ class CommonMessages(TypedDict):
5658
VALIDATION_ERROR: str
5759

5860

61+
class PasswordResetMessages(TypedDict):
62+
EMAIL_SENT: str
63+
INVALID_TOKEN: str
64+
TOKEN_EXPIRED: str
65+
PASSWORD_RESET: str
66+
DOESNT_EXIST: str
67+
68+
5969
class Messages(TypedDict):
6070
REGISTRATION: RegistrationMessages
6171
AUTH: AuthMessages
@@ -65,6 +75,7 @@ class Messages(TypedDict):
6575
NOK: NextOfKinMessages
6676
KYC: KYCInformationMessages
6777
COMMON: CommonMessages
78+
PASSWORD_RESET: PasswordResetMessages
6879

6980

7081
class DynamicCommonMessages(TypedDict):
@@ -78,9 +89,14 @@ class DynamicAccountMessages(TypedDict):
7889
EXISTS: Callable[[str], str]
7990

8091

92+
class DynamicPasswordResetMessages(TypedDict):
93+
EMAIL_SENT: Callable[[str], str]
94+
95+
8196
class DynamicMessages(TypedDict):
8297
COMMON: DynamicCommonMessages
8398
ACCOUNT: DynamicAccountMessages
99+
PASSWORD_RESET: DynamicPasswordResetMessages
84100

85101

86102
MESSAGES: Messages = {
@@ -112,6 +128,8 @@ class DynamicMessages(TypedDict):
112128
"PIN_EXISTS": "You already have a transaction PIN on your account!",
113129
"PIN_SET": "Transaction PIN set successfully",
114130
"NOT_ALLOWED": "Unauthorized request!",
131+
"INCORRECT_PASSWORD": "Incorrect old password",
132+
"PASSWORD_CHANGED": "Password changed successfully",
115133
},
116134
"ACCOUNT": {
117135
"SAVED": "Withdrawal account details saved succesfully",
@@ -130,6 +148,13 @@ class DynamicMessages(TypedDict):
130148
"JWT_GENERATED": "JWT was generated",
131149
"VALIDATION_ERROR": "Validation errors",
132150
},
151+
"PASSWORD_RESET": {
152+
"EMAIL_SENT": "Password reset email sent successfully to your email",
153+
"PASSWORD_RESET": "Password reset successfully",
154+
"INVALID_TOKEN": "Invalid password reset token!",
155+
"DOESNT_EXIST": "You don't have an account with us yet!",
156+
"TOKEN_EXPIRED": "Password reset token has expired!",
157+
},
133158
}
134159

135160
DYNAMIC_MESSAGES: DynamicMessages = {
@@ -142,6 +167,9 @@ class DynamicMessages(TypedDict):
142167
"ACCOUNT": {
143168
"EXISTS": lambda x: f"You already have an account with the account number {x}!"
144169
},
170+
"PASSWORD_RESET": {
171+
"EMAIL_SENT": lambda x: f"Password reset email has been sent to {x}",
172+
},
145173
}
146174

147175
__all__ = ["DYNAMIC_MESSAGES", "MESSAGES"]

src/api/controllers/PasswordResetController.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@
55

66
from src.utils.svcs import Service
77
from src.utils.logger import Logger
8+
from src.api.utils.response_format import success_response
89
from src.api.services.PasswordResetService import PasswordResetService
9-
from src.api.models.payload.requests.PasswordResetRequest import PasswordResetRequest
10+
from src.api.models.payload.requests.PasswordResetRequest import (
11+
PasswordResetRequest,
12+
ConfirmPasswordResetRequest,
13+
)
1014

1115

1216
@Service()
@@ -19,12 +23,14 @@ def __init__(
1923
self.logger = logger
2024
self.password_reset_service = password_reset_service
2125

22-
async def request_password_reset(self, user_data: PasswordResetRequest) -> dict:
26+
async def request_password_reset(self, user_data: PasswordResetRequest) -> tuple:
2327
try:
2428
sent_request = await self.password_reset_service.request_password_reset(
2529
user_data
2630
)
27-
return {"message": sent_request["message"]}
31+
return success_response(
32+
message=sent_request["message"], status_code=HTTPStatus.OK
33+
)
2834
except Exception as exc:
2935
if isinstance(exc, HttpError):
3036
raise
@@ -36,3 +42,25 @@ async def request_password_reset(self, user_data: PasswordResetRequest) -> dict:
3642
}
3743
)
3844
raise HttpError(HTTPStatus.INTERNAL_SERVER_ERROR, "Something went wrong")
45+
46+
async def confirm_password_reset(
47+
self, user_data: ConfirmPasswordResetRequest
48+
) -> tuple:
49+
try:
50+
sent_request = await self.password_reset_service.confirm_password_reset(
51+
user_data
52+
)
53+
return success_response(
54+
message=sent_request["message"], status_code=HTTPStatus.OK
55+
)
56+
except Exception as exc:
57+
if isinstance(exc, HttpError):
58+
raise
59+
self.logger.error(
60+
{
61+
"activity_type": "Confirm password reset",
62+
"message": str(exc),
63+
"metadata": user_data.model_dump(),
64+
}
65+
)
66+
raise HttpError(HTTPStatus.INTERNAL_SERVER_ERROR, "Something went wrong")

src/api/controllers/UserController.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
from src.api.services.UserService import UserService
88
from src.api.utils.response_format import error_response, success_response
99
from src.api.models.payload.requests.Pin import Pin
10-
from src.api.models.payload.requests.UpdateUserRequest import UpdateUserRequest
10+
from src.api.models.payload.requests.UpdateUserRequest import (
11+
UpdateUserRequest,
12+
ChangeUserPasswordRequest,
13+
)
1114

1215

1316
@Service()
@@ -52,3 +55,16 @@ async def set_account_pin(self, id: str, user_pin: Pin) -> tuple:
5255
return success_response(
5356
message=updated_pin["message"], status_code=HTTPStatus.OK
5457
)
58+
59+
async def change_password(
60+
self, id: str, user_data: ChangeUserPasswordRequest
61+
) -> tuple:
62+
updated_password = await self.user_service.change_password(id, user_data)
63+
if not updated_password["is_success"]:
64+
return error_response(
65+
message=updated_password["message"], status_code=HTTPStatus.BAD_REQUEST
66+
)
67+
return success_response(
68+
message=updated_password["message"],
69+
status_code=HTTPStatus.OK,
70+
)

src/api/middlewares/AppMiddleware.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
from http import HTTPStatus
2+
13
import jwt
24
from django.http import HttpRequest
5+
from ninja.errors import HttpError
36
from ninja.security import HttpBearer
47

58
from src.env import jwt_config
@@ -18,6 +21,20 @@ def authenticate(self, request: HttpRequest, token: str) -> str:
1821
algorithms=["HS256"],
1922
options={"verify_signature": False},
2023
)
24+
25+
email = jwt_data.get("email")
26+
user_id = jwt_data.get("user_id")
27+
if not email or not user_id:
28+
message = "Invalid authentication token"
29+
self.logger.error(
30+
{
31+
"activity_type": "Authenticate User",
32+
"message": message,
33+
"metadata": {"token": token},
34+
}
35+
)
36+
raise HttpError(HTTPStatus.UNAUTHORIZED, message)
37+
2138
self.logger.debug(
2239
{
2340
"activity_type": "Authenticate User",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
from pydantic import EmailStr, BaseModel
22

3+
from src.api.typing.PasswordValidator import IsStrongPassword
4+
35

46
class PasswordResetRequest(BaseModel):
57
email: EmailStr
8+
9+
10+
class ConfirmPasswordResetRequest(BaseModel):
11+
reset_token: str
12+
new_password: IsStrongPassword

src/api/models/payload/requests/UpdateUserRequest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@
22

33
from pydantic import Field, BaseModel
44

5+
from src.api.typing.PasswordValidator import IsStrongPassword
6+
57

68
class UpdateUserRequest(BaseModel):
79
first_name: str | None = None
810
last_name: str | None = None
911
address: str | None = None
1012
phone_number: str | None = None
1113
state_lga_id: Annotated[int, Field(default=None, ge=1)]
14+
15+
16+
class ChangeUserPasswordRequest(BaseModel):
17+
old_password: str
18+
new_password: IsStrongPassword

src/api/repositories/UserRepository.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,7 @@ async def update_by_id(cls, id: str, updates: dict | None = None) -> User | None
3939
setattr(user, key, value)
4040
await user.asave()
4141
return user
42+
43+
@classmethod
44+
async def find_by_reset_token(cls, reset_token: str) -> User | None:
45+
return await cls.manager.filter(password_reset_token=reset_token).afirst()

src/api/routes/PasswordReset.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,51 @@
1+
from http import HTTPStatus
2+
13
from ninja import Router
24
from django.http import HttpRequest
35

46
from src.utils.svcs import ADepends
57
from src.api.controllers.PasswordResetController import PasswordResetController
8+
from src.api.models.payload.responses.ErrorResponse import (
9+
ErrorResponse,
10+
ServerErrorResponse,
11+
)
612
from src.api.models.payload.responses.SuccessResponse import SuccessResponse
7-
from src.api.models.payload.requests.PasswordResetRequest import PasswordResetRequest
13+
from src.api.models.payload.requests.PasswordResetRequest import (
14+
PasswordResetRequest,
15+
ConfirmPasswordResetRequest,
16+
)
817

918
router = Router()
1019

1120

12-
@router.post("/", response=SuccessResponse)
21+
@router.post(
22+
"/",
23+
response={
24+
HTTPStatus.OK: SuccessResponse,
25+
HTTPStatus.BAD_REQUEST: ErrorResponse,
26+
HTTPStatus.INTERNAL_SERVER_ERROR: ServerErrorResponse,
27+
},
28+
)
1329
async def reset_password(
1430
request: HttpRequest, credentials: PasswordResetRequest
15-
) -> dict:
31+
) -> tuple:
1632
reset_controller = await ADepends(PasswordResetController)
1733
return await reset_controller.request_password_reset(credentials)
34+
35+
36+
# TODO: Implement router for password reset confirm and change password
37+
38+
39+
@router.post(
40+
"/confirm",
41+
response={
42+
HTTPStatus.OK: SuccessResponse,
43+
HTTPStatus.BAD_REQUEST: ErrorResponse,
44+
HTTPStatus.INTERNAL_SERVER_ERROR: ServerErrorResponse,
45+
},
46+
)
47+
async def confirm_password_reset(
48+
request: HttpRequest, credentials: ConfirmPasswordResetRequest
49+
) -> tuple:
50+
reset_controller = await ADepends(PasswordResetController)
51+
return await reset_controller.confirm_password_reset(credentials)

src/api/routes/User.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
ServerErrorResponse,
1313
)
1414
from src.api.models.payload.responses.SuccessResponse import SuccessResponse
15-
from src.api.models.payload.requests.UpdateUserRequest import UpdateUserRequest
15+
from src.api.models.payload.requests.UpdateUserRequest import (
16+
UpdateUserRequest,
17+
ChangeUserPasswordRequest,
18+
)
1619

1720
router = Router()
1821

@@ -57,3 +60,19 @@ async def update_user(request: HttpRequest, user_data: UpdateUserRequest) -> tup
5760
user_id = getattr(request, "auth_id", "")
5861
user_controller = await ADepends(UserController)
5962
return await user_controller.update_user(user_id, user_data)
63+
64+
65+
@router.put(
66+
"/change-password",
67+
response={
68+
HTTPStatus.OK: SuccessResponse,
69+
HTTPStatus.BAD_REQUEST: ErrorResponse,
70+
HTTPStatus.INTERNAL_SERVER_ERROR: ServerErrorResponse,
71+
},
72+
)
73+
async def update_password(
74+
request: HttpRequest, user_data: ChangeUserPasswordRequest
75+
) -> tuple:
76+
user_id = getattr(request, "auth_id", "")
77+
user_controller = await ADepends(UserController)
78+
return await user_controller.change_password(user_id, user_data)

0 commit comments

Comments
 (0)