MukeshKapoor25 commited on
Commit
622d307
·
1 Parent(s): 2e4124f

feat(auth): Implement staff mobile OTP login and restructure user management routes

Browse files
Files changed (2) hide show
  1. .env.example +6 -15
  2. app/system_users/controllers/router.py +281 -18
.env.example CHANGED
@@ -6,17 +6,18 @@ APP_VERSION=1.0.0
6
  DEBUG=false
7
 
8
  # MongoDB Configuration
9
- MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/?retryWrites=true&w=majority
 
10
  MONGODB_DB_NAME=cuatrolabs
11
 
12
  # Redis Configuration (for caching and session management)
13
- REDIS_HOST=your-redis-host.com
14
  REDIS_PORT=6379
15
  REDIS_PASSWORD=your-redis-password
16
  REDIS_DB=0
17
 
18
  # JWT Configuration
19
- SECRET_KEY=your-secret-key-change-in-production
20
  ALGORITHM=HS256
21
  TOKEN_EXPIRATION_HOURS=8
22
  REFRESH_TOKEN_EXPIRE_DAYS=7
@@ -24,16 +25,6 @@ MAX_FAILED_LOGIN_ATTEMPTS=5
24
  ACCOUNT_LOCK_DURATION_MINUTES=15
25
  REMEMBER_ME_TOKEN_HOURS=24
26
 
27
- # Password Reset Configuration
28
- PASSWORD_RESET_TOKEN_EXPIRATION_MINUTES=60
29
- PASSWORD_RESET_BASE_URL=http://localhost:3000/reset-password
30
-
31
- # Password Rotation Policy Configuration
32
- PASSWORD_ROTATION_DAYS=60
33
- PASSWORD_ROTATION_WARNING_DAYS=7
34
- ENFORCE_PASSWORD_ROTATION=true
35
- ALLOW_LOGIN_WITH_EXPIRED_PASSWORD=false
36
-
37
  # API Configuration
38
  MAX_PAGE_SIZE=100
39
 
@@ -42,14 +33,14 @@ OTP_TTL_SECONDS=600
42
  OTP_RATE_LIMIT_MAX=10
43
  OTP_RATE_LIMIT_WINDOW=600
44
 
45
- # Twilio Configuration (for SMS OTP - optional fallback)
46
  TWILIO_ACCOUNT_SID=
47
  TWILIO_AUTH_TOKEN=
48
  TWILIO_PHONE_NUMBER=
49
 
50
  # WATI WhatsApp API Configuration (for WhatsApp OTP)
51
  WATI_API_ENDPOINT=https://live-mt-server.wati.io/YOUR_TENANT_ID
52
- WATI_ACCESS_TOKEN=your-wati-bearer-token
53
  WATI_OTP_TEMPLATE_NAME=cust_otp
54
  WATI_STAFF_OTP_TEMPLATE_NAME=staff_otp_login
55
 
 
6
  DEBUG=false
7
 
8
  # MongoDB Configuration
9
+ MONGODB_URI=mongodb://localhost:27017
10
+ # For MongoDB Atlas: mongodb+srv://username:password@cluster.mongodb.net/?retryWrites=true&w=majority
11
  MONGODB_DB_NAME=cuatrolabs
12
 
13
  # Redis Configuration (for caching and session management)
14
+ REDIS_HOST=localhost
15
  REDIS_PORT=6379
16
  REDIS_PASSWORD=your-redis-password
17
  REDIS_DB=0
18
 
19
  # JWT Configuration
20
+ SECRET_KEY=your-secret-key-here-change-in-production
21
  ALGORITHM=HS256
22
  TOKEN_EXPIRATION_HOURS=8
23
  REFRESH_TOKEN_EXPIRE_DAYS=7
 
25
  ACCOUNT_LOCK_DURATION_MINUTES=15
26
  REMEMBER_ME_TOKEN_HOURS=24
27
 
 
 
 
 
 
 
 
 
 
 
28
  # API Configuration
29
  MAX_PAGE_SIZE=100
30
 
 
33
  OTP_RATE_LIMIT_MAX=10
34
  OTP_RATE_LIMIT_WINDOW=600
35
 
36
+ # Twilio Configuration (for SMS OTP)
37
  TWILIO_ACCOUNT_SID=
38
  TWILIO_AUTH_TOKEN=
39
  TWILIO_PHONE_NUMBER=
40
 
41
  # WATI WhatsApp API Configuration (for WhatsApp OTP)
42
  WATI_API_ENDPOINT=https://live-mt-server.wati.io/YOUR_TENANT_ID
43
+ WATI_ACCESS_TOKEN=your-wati-jwt-token
44
  WATI_OTP_TEMPLATE_NAME=cust_otp
45
  WATI_STAFF_OTP_TEMPLATE_NAME=staff_otp_login
46
 
app/system_users/controllers/router.py CHANGED
@@ -1,4 +1,3 @@
1
- from uuid import UUID
2
  from pydantic import BaseModel, Field
3
  from fastapi import APIRouter, Depends, HTTPException, status, Request
4
  from fastapi.security import HTTPAuthorizationCredentials
@@ -21,20 +20,204 @@ logger = logging.getLogger(__name__)
21
 
22
  # Router must be defined before any usage
23
  router = APIRouter(
24
- prefix="/users",
25
- tags=["User Management"]
26
  )
27
 
28
- # Staff mobile OTP login moved to staff_router.py
29
-
30
-
31
- # Login endpoint moved to auth router
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
 
34
- # /me endpoint moved to auth router
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
 
37
- @router.post("/", response_model=UserInfoResponse)
38
  async def create_user(
39
  user_data: CreateUserRequest,
40
  current_user: SystemUserModel = Depends(require_admin_role),
@@ -88,7 +271,7 @@ async def create_user(
88
  )
89
 
90
 
91
- @router.get("/", response_model=UserListResponse)
92
  async def list_users(
93
  page: int = 1,
94
  page_size: int = 20,
@@ -151,7 +334,7 @@ async def list_users(
151
  )
152
 
153
 
154
- @router.post("/list")
155
  async def list_users_with_projection(
156
  payload: UserListRequest,
157
  current_user: SystemUserModel = Depends(require_admin_role),
@@ -251,9 +434,9 @@ async def list_users_with_projection(
251
  )
252
 
253
 
254
- @router.get("/{user_id}", response_model=UserInfoResponse)
255
  async def get_user_by_id(
256
- user_id: UUID,
257
  current_user: SystemUserModel = Depends(require_admin_role),
258
  user_service: SystemUserService = Depends(get_system_user_service)
259
  ):
@@ -295,9 +478,9 @@ async def get_user_by_id(
295
  )
296
 
297
 
298
- @router.put("/{user_id}", response_model=UserInfoResponse)
299
  async def update_user(
300
- user_id: UUID,
301
  update_data: UpdateUserRequest,
302
  current_user: SystemUserModel = Depends(require_admin_role),
303
  user_service: SystemUserService = Depends(get_system_user_service)
@@ -579,9 +762,9 @@ async def reset_password(
579
  )
580
 
581
 
582
- @router.delete("/{user_id}", response_model=StandardResponse)
583
  async def deactivate_user(
584
- user_id: UUID,
585
  current_user: SystemUserModel = Depends(require_admin_role),
586
  user_service: SystemUserService = Depends(get_system_user_service)
587
  ):
@@ -635,7 +818,87 @@ async def deactivate_user(
635
  )
636
 
637
 
638
- # Logout endpoint moved to auth router
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
639
 
640
 
641
  # Create default super admin endpoint (for initial setup)
 
 
1
  from pydantic import BaseModel, Field
2
  from fastapi import APIRouter, Depends, HTTPException, status, Request
3
  from fastapi.security import HTTPAuthorizationCredentials
 
20
 
21
  # Router must be defined before any usage
22
  router = APIRouter(
23
+ prefix="/auth",
24
+ tags=["Authentication & User Management"]
25
  )
26
 
27
+ # --- Staff Mobile OTP Login ---
28
+ class StaffMobileOTPLoginRequest(BaseModel):
29
+ phone: str = Field(..., description="Staff mobile number")
30
+ otp: str = Field(..., description="One-time password")
31
+
32
+ class StaffMobileOTPLoginResponse(BaseModel):
33
+ access_token: str
34
+ token_type: str = "bearer"
35
+ expires_in: int
36
+ user_info: 'UserInfoResponse'
37
+
38
+ @router.post("/staff/login/mobile-otp", response_model=StaffMobileOTPLoginResponse, summary="Staff login with mobile and OTP")
39
+ async def staff_login_mobile_otp(
40
+ request: Request,
41
+ login_data: StaffMobileOTPLoginRequest,
42
+ user_service: SystemUserService = Depends(get_system_user_service)
43
+ ):
44
+ """
45
+ Staff login using mobile number and OTP (OTP hardcoded as 123456).
46
+ """
47
+ if not login_data.phone or not login_data.otp:
48
+ raise HTTPException(status_code=400, detail="Phone and OTP are required")
49
+ if login_data.otp != "123456":
50
+ raise HTTPException(status_code=401, detail="Invalid OTP")
51
+ # Find user by phone
52
+ user = await user_service.get_user_by_phone(login_data.phone)
53
+ if not user:
54
+ raise HTTPException(status_code=401, detail="Staff user not found for this phone number")
55
+ # Only allow staff/employee roles (not admin/super_admin)
56
+ if user.role in ("admin", "super_admin"):
57
+ raise HTTPException(status_code=403, detail="Admin login not allowed via staff OTP login")
58
+
59
+ # Create access token for staff user
60
+ from datetime import timedelta
61
+ from app.core.config import settings
62
+
63
+ access_token_expires = timedelta(hours=settings.TOKEN_EXPIRATION_HOURS)
64
+ access_token = user_service.create_access_token(
65
+ data={
66
+ "sub": user.user_id,
67
+ "username": user.username,
68
+ "role": user.role,
69
+ "merchant_id": user.merchant_id,
70
+ "merchant_type": user.merchant_type
71
+ },
72
+ expires_delta=access_token_expires
73
+ )
74
+
75
+ user_info = user_service.convert_to_user_info_response(user)
76
+
77
+ return StaffMobileOTPLoginResponse(
78
+ access_token=access_token,
79
+ token_type="bearer",
80
+ expires_in=int(access_token_expires.total_seconds()),
81
+ user_info=user_info
82
+ )
83
+
84
+
85
+ @router.post("/login", response_model=LoginResponse)
86
+ async def login(
87
+ request: Request,
88
+ login_data: LoginRequest,
89
+ user_service: SystemUserService = Depends(get_system_user_service)
90
+ ):
91
+ """
92
+ Authenticate user and return access token.
93
+
94
+ Raises:
95
+ HTTPException: 400 - Missing required fields
96
+ HTTPException: 401 - Invalid credentials or account locked
97
+ HTTPException: 500 - Database or server error
98
+ """
99
+ try:
100
+ # Validate input
101
+ if not login_data.email_or_phone or not login_data.email_or_phone.strip():
102
+ raise HTTPException(
103
+ status_code=status.HTTP_400_BAD_REQUEST,
104
+ detail="Email, phone, or username is required"
105
+ )
106
+
107
+ if not login_data.password or not login_data.password.strip():
108
+ raise HTTPException(
109
+ status_code=status.HTTP_400_BAD_REQUEST,
110
+ detail="Password is required"
111
+ )
112
+
113
+ # Get client IP and user agent
114
+ client_ip = request.client.host if request.client else None
115
+ user_agent = request.headers.get("User-Agent")
116
+
117
+ # Authenticate user
118
+ try:
119
+ user, message = await user_service.authenticate_user(
120
+ email_or_phone=login_data.email_or_phone,
121
+ password=login_data.password,
122
+ ip_address=client_ip,
123
+ user_agent=user_agent
124
+ )
125
+ except Exception as auth_error:
126
+ logger.error(f"Authentication error: {auth_error}", exc_info=True)
127
+ raise HTTPException(
128
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
129
+ detail="Authentication service error"
130
+ )
131
+
132
+ if not user:
133
+ logger.warning(f"Login failed for {login_data.email_or_phone}: {message}")
134
+ raise HTTPException(
135
+ status_code=status.HTTP_401_UNAUTHORIZED,
136
+ detail=message,
137
+ headers={"WWW-Authenticate": "Bearer"},
138
+ )
139
+
140
+ # Create access token
141
+ try:
142
+ access_token_expires = timedelta(hours=settings.TOKEN_EXPIRATION_HOURS)
143
+ if login_data.remember_me:
144
+ access_token_expires = timedelta(hours=settings.REMEMBER_ME_TOKEN_HOURS)
145
+
146
+ access_token = user_service.create_access_token(
147
+ data={
148
+ "sub": user.user_id,
149
+ "username": user.username,
150
+ "role": user.role,
151
+ "merchant_id": user.merchant_id,
152
+ "merchant_type": user.merchant_type
153
+ },
154
+ expires_delta=access_token_expires
155
+ )
156
+ except Exception as token_error:
157
+ logger.error(f"Error creating token: {token_error}", exc_info=True)
158
+ raise HTTPException(
159
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
160
+ detail="Failed to generate authentication token"
161
+ )
162
+
163
+ # Convert user to response model
164
+ try:
165
+ user_info = user_service.convert_to_user_info_response(user)
166
+ except Exception as convert_error:
167
+ logger.error(f"Error converting user info: {convert_error}", exc_info=True)
168
+ raise HTTPException(
169
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
170
+ detail="Failed to format user information"
171
+ )
172
+
173
+ logger.info(f"User logged in successfully: {user.username}")
174
+
175
+ return LoginResponse(
176
+ access_token=access_token,
177
+ token_type="bearer",
178
+ expires_in=int(access_token_expires.total_seconds()),
179
+ user_info=user_info
180
+ )
181
+
182
+ except HTTPException:
183
+ raise
184
+ except Exception as e:
185
+ logger.error(f"Unexpected login error: {str(e)}", exc_info=True)
186
+ raise HTTPException(
187
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
188
+ detail="An unexpected error occurred during login"
189
+ )
190
 
191
 
192
+ @router.get("/me", response_model=UserInfoResponse)
193
+ async def get_current_user_info(
194
+ current_user: SystemUserModel = Depends(get_current_user),
195
+ user_service: SystemUserService = Depends(get_system_user_service)
196
+ ):
197
+ """
198
+ Get current user information.
199
+
200
+ Raises:
201
+ HTTPException: 401 - Unauthorized (invalid or missing token)
202
+ HTTPException: 500 - Server error
203
+ """
204
+ try:
205
+ return user_service.convert_to_user_info_response(current_user)
206
+ except AttributeError as e:
207
+ logger.error(f"Error accessing user attributes: {e}")
208
+ raise HTTPException(
209
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
210
+ detail="Error retrieving user information"
211
+ )
212
+ except Exception as e:
213
+ logger.error(f"Unexpected error getting current user info: {str(e)}", exc_info=True)
214
+ raise HTTPException(
215
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
216
+ detail="An unexpected error occurred"
217
+ )
218
 
219
 
220
+ @router.post("/users", response_model=UserInfoResponse)
221
  async def create_user(
222
  user_data: CreateUserRequest,
223
  current_user: SystemUserModel = Depends(require_admin_role),
 
271
  )
272
 
273
 
274
+ @router.get("/users", response_model=UserListResponse)
275
  async def list_users(
276
  page: int = 1,
277
  page_size: int = 20,
 
334
  )
335
 
336
 
337
+ @router.post("/users/list")
338
  async def list_users_with_projection(
339
  payload: UserListRequest,
340
  current_user: SystemUserModel = Depends(require_admin_role),
 
434
  )
435
 
436
 
437
+ @router.get("/users/{user_id}", response_model=UserInfoResponse)
438
  async def get_user_by_id(
439
+ user_id: str,
440
  current_user: SystemUserModel = Depends(require_admin_role),
441
  user_service: SystemUserService = Depends(get_system_user_service)
442
  ):
 
478
  )
479
 
480
 
481
+ @router.put("/users/{user_id}", response_model=UserInfoResponse)
482
  async def update_user(
483
+ user_id: str,
484
  update_data: UpdateUserRequest,
485
  current_user: SystemUserModel = Depends(require_admin_role),
486
  user_service: SystemUserService = Depends(get_system_user_service)
 
762
  )
763
 
764
 
765
+ @router.delete("/users/{user_id}", response_model=StandardResponse)
766
  async def deactivate_user(
767
+ user_id: str,
768
  current_user: SystemUserModel = Depends(require_admin_role),
769
  user_service: SystemUserService = Depends(get_system_user_service)
770
  ):
 
818
  )
819
 
820
 
821
+ @router.post("/logout", response_model=StandardResponse)
822
+ async def logout(
823
+ request: Request,
824
+ current_user: SystemUserModel = Depends(get_current_user),
825
+ user_service: SystemUserService = Depends(get_system_user_service)
826
+ ):
827
+ """
828
+ Logout current user.
829
+
830
+ Requires JWT token in Authorization header (Bearer token).
831
+ Logs out the user and records the logout event for audit purposes.
832
+
833
+ **Security:**
834
+ - Validates JWT token before logout
835
+ - Records logout event with IP address, user agent, and session duration
836
+ - Stores audit log for compliance and security tracking
837
+
838
+ **Note:** Since we're using stateless JWT tokens, the client is responsible for:
839
+ - Removing the token from local storage/cookies
840
+ - Clearing any cached user data
841
+ - Redirecting to login page
842
+
843
+ For enhanced security in production:
844
+ - Consider implementing token blacklisting
845
+ - Use short-lived access tokens with refresh tokens
846
+ - Implement server-side session management if needed
847
+
848
+ Raises:
849
+ HTTPException: 401 - Unauthorized (invalid or missing token)
850
+ HTTPException: 500 - Server error
851
+ """
852
+ try:
853
+ # Get client information for audit logging
854
+ client_ip = request.client.host if request.client else None
855
+ user_agent = request.headers.get("User-Agent")
856
+
857
+ # Record logout for audit purposes
858
+ await user_service.record_logout(
859
+ user=current_user,
860
+ ip_address=client_ip,
861
+ user_agent=user_agent
862
+ )
863
+
864
+ logger.info(
865
+ f"User logged out successfully: {current_user.username}",
866
+ extra={
867
+ "event": "logout_success",
868
+ "user_id": current_user.user_id,
869
+ "username": current_user.username,
870
+ "ip_address": client_ip
871
+ }
872
+ )
873
+
874
+ return StandardResponse(
875
+ success=True,
876
+ message="Logged out successfully"
877
+ )
878
+
879
+ except AttributeError as e:
880
+ logger.error(
881
+ f"Error accessing user during logout: {e}",
882
+ extra={"error_type": "attribute_error"},
883
+ exc_info=True
884
+ )
885
+ raise HTTPException(
886
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
887
+ detail="Error during logout"
888
+ )
889
+ except Exception as e:
890
+ logger.error(
891
+ f"Unexpected logout error: {str(e)}",
892
+ extra={
893
+ "error_type": type(e).__name__,
894
+ "user_id": current_user.user_id if current_user else None
895
+ },
896
+ exc_info=True
897
+ )
898
+ raise HTTPException(
899
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
900
+ detail="An unexpected error occurred during logout"
901
+ )
902
 
903
 
904
  # Create default super admin endpoint (for initial setup)