File size: 17,379 Bytes
79ef7e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
# ============================================================
# app/services/auth_service.py - COMPLETE with All Errors
# ============================================================

import logging
from datetime import datetime
from app.database import get_db
from app.models.user import User
from app.core.security import (
    hash_password,
    verify_password,
    create_login_token,
    create_reset_token,
    decode_reset_token,
)
from app.core.exceptions import (
    InvalidCredentialsException,
    UserNotFoundException,
    UserAlreadyExistsException,
    AccountInactiveException,
    InvalidOtpException,
    OtpExpiredException,
    OtpNotSentException,
    OtpSendFailedException,
    InvalidResetTokenException,
    TokenMismatchException,
    PasswordResetFailedException,
    DatabaseException,
    WeakPasswordException,
    MissingEmailPhoneException,
    OtpResendTooSoonException,
    OtpStillValidException,
    SignupFailedException,
)
from app.services.otp_service import otp_service
from app.schemas.auth import (
    SignupDto,
    LoginDto,
    ResetPasswordDto,
    ResendOtpDto,
)

logger = logging.getLogger(__name__)

class AuthService:
    """Complete Authentication Service with comprehensive error handling"""
    
    # ============================================================
    # SIGNUP
    # ============================================================
    
    @staticmethod
    async def signup(signup_dto: SignupDto) -> dict:
        """
        Step 1: Create user account and send OTP
        
        Raises:
            - UserAlreadyExistsException: Email/phone already registered
            - MissingEmailPhoneException: Neither email nor phone provided
            - WeakPasswordException: Password doesn't meet requirements
            - SignupFailedException: Database or OTP send error
        """
        try:
            db = await get_db()
            users_collection = db["users"]
            
            email = signup_dto.email.lower() if signup_dto.email else None
            phone = signup_dto.phone
            identifier = email or phone
            
            # Validate at least one contact method
            if not identifier:
                raise MissingEmailPhoneException()
            
            # Check if user already exists
            if email:
                existing = await users_collection.find_one({"email": email})
                if existing:
                    raise UserAlreadyExistsException(email)
            
            if phone:
                existing = await users_collection.find_one({"phone": phone})
                if existing:
                    raise UserAlreadyExistsException(phone)
            
            # Hash password
            hashed_password = hash_password(signup_dto.password)
            
            # Create user document
            user_doc = User.create_document(
                first_name=signup_dto.first_name.strip(),
                last_name=signup_dto.last_name.strip(),
                email=email,
                phone=phone,
                hashed_password=hashed_password,
                role=signup_dto.role,
            )
            
            # Insert user
            await users_collection.insert_one(user_doc)
            logger.info(f"User created: {identifier}")
            
            # Send OTP
            try:
                await otp_service.send_otp(identifier, "signup")
            except Exception as e:
                logger.error(f"OTP send failed: {str(e)}")
                raise OtpSendFailedException("email/SMS")
            
            logger.info(f"Signup initiated: {identifier}")
            
            return {
                "success": True,
                "message": "Account created! OTP sent to your email/phone.",
                "data": {
                    "identifier": identifier,
                    "type": "email" if email else "phone",
                },
            }
        
        except (UserAlreadyExistsException, MissingEmailPhoneException, 
                WeakPasswordException, OtpSendFailedException):
            raise
        except Exception as e:
            logger.error(f"Signup error: {str(e)}")
            raise SignupFailedException("Please try again")
    
    # ============================================================
    # VERIFY SIGNUP OTP
    # ============================================================
    
    @staticmethod
    async def verify_signup_otp(identifier: str, code: str) -> dict:
        """
        Step 2: Verify signup OTP and complete signup
        
        Raises:
            - InvalidOtpException: OTP code is incorrect
            - OtpExpiredException: OTP has expired
            - OtpNotSentException: No OTP was sent
            - UserNotFoundException: User not found
            - DatabaseException: Database error during verification
        """
        try:
            # Verify OTP
            try:
                await otp_service.verify_otp(identifier, code, "signup")
            except InvalidOtpException:
                raise
            except OtpExpiredException:
                raise
            except Exception as e:
                logger.error(f"OTP verification error: {str(e)}")
                raise OtpNotSentException()
            
            db = await get_db()
            users_collection = db["users"]
            
            # Find user
            query = (
                {"email": identifier.lower()}
                if "@" in identifier
                else {"phone": identifier}
            )
            
            user = await users_collection.find_one(query)
            
            if not user:
                raise UserNotFoundException(identifier)
            
            # Update verification status
            update_data = {}
            if "@" in identifier:
                update_data["isEmailVerified"] = True
            else:
                update_data["isPhoneVerified"] = True
            
            update_data["isActive"] = True
            update_data["lastLogin"] = datetime.utcnow()
            update_data["updatedAt"] = datetime.utcnow()
            
            await users_collection.update_one(
                {"_id": user["_id"]},
                {"$set": update_data}
            )
            
            # Refresh user
            user = await users_collection.find_one({"_id": user["_id"]})
            
            # Delete OTP
            await otp_service.delete_otp(identifier, "signup")
            
            # Generate token
            token = create_login_token(
                user_id=str(user["_id"]),
                email=user.get("email"),
                phone=user.get("phone"),
                role=user.get("role"),
            )
            
            logger.info(f"Signup verified: {identifier}")
            
            return {
                "success": True,
                "message": "Welcome! Your account is now verified.",
                "data": {
                    "user": User.format_response(user),
                    "token": token,
                },
            }
        
        except (InvalidOtpException, OtpExpiredException, OtpNotSentException, 
                UserNotFoundException):
            raise
        except Exception as e:
            logger.error(f"Signup verification error: {str(e)}")
            raise DatabaseException("signup verification")
    
    # ============================================================
    # LOGIN
    # ============================================================
    
    @staticmethod
    async def login(login_dto: LoginDto) -> dict:
        """
        Login - Authenticate user and return JWT token
        
        Raises:
            - InvalidCredentialsException: Wrong email/phone or password
            - AccountInactiveException: Account is deactivated
            - DatabaseException: Database error
        """
        identifier = login_dto.identifier
        password = login_dto.password
        
        logger.info(f"Login attempt: {identifier}")
        
        try:
            db = await get_db()
            users_collection = db["users"]
            
            # Find user
            query = (
                {"email": identifier.lower()}
                if "@" in identifier
                else {"phone": identifier}
            )
            
            user = await users_collection.find_one(query)
            
            # User not found
            if not user:
                logger.warning(f"Login failed - user not found: {identifier}")
                raise InvalidCredentialsException()
            
            # Account inactive
            if not user.get("isActive"):
                logger.warning(f"Login failed - account inactive: {identifier}")
                raise AccountInactiveException()
            
            # Password incorrect
            if not verify_password(password, user.get("password")):
                logger.warning(f"Login failed - invalid password: {identifier}")
                raise InvalidCredentialsException()
            
            # Update last login
            now = datetime.utcnow()
            await users_collection.update_one(
                {"_id": user["_id"]},
                {"$set": {"lastLogin": now, "updatedAt": now}}
            )
            
            # Generate token
            token = create_login_token(
                user_id=str(user["_id"]),
                email=user.get("email"),
                phone=user.get("phone"),
                role=user.get("role"),
            )
            
            logger.info(f"Login successful: {identifier}")
            
            return {
                "success": True,
                "message": "Login successful!",
                "data": {
                    "user": User.format_response(user),
                    "token": token,
                },
            }
        
        except (InvalidCredentialsException, AccountInactiveException):
            raise
        except Exception as e:
            logger.error(f"Login error: {str(e)}")
            raise DatabaseException("login")
    
    # ============================================================
    # SEND PASSWORD RESET OTP
    # ============================================================
    
    @staticmethod
    async def send_password_reset_otp(identifier: str) -> dict:
        """
        Step 1: Request password reset OTP
        
        Raises:
            - OtpSendFailedException: Failed to send OTP
        
        Note: Doesn't reveal if account exists (security)
        """
        try:
            db = await get_db()
            users_collection = db["users"]
            
            query = (
                {"email": identifier.lower()}
                if "@" in identifier
                else {"phone": identifier}
            )
            
            user = await users_collection.find_one(query)
            
            if user:
                try:
                    await otp_service.send_otp(identifier, "password_reset")
                    logger.info(f"Password reset OTP sent: {identifier}")
                except Exception as e:
                    logger.error(f"OTP send failed: {str(e)}")
                    raise OtpSendFailedException()
        
        except OtpSendFailedException:
            raise
        except Exception as e:
            logger.error(f"Password reset request error: {str(e)}")
        
        # Always return success for security
        return {
            "success": True,
            "message": "If an account exists, you'll receive an OTP shortly.",
            "data": {"identifier": identifier},
        }
    
    # ============================================================
    # VERIFY PASSWORD RESET OTP
    # ============================================================
    
    @staticmethod
    async def verify_password_reset_otp(identifier: str, code: str) -> dict:
        """
        Step 2: Verify password reset OTP
        
        Raises:
            - InvalidOtpException: OTP code is incorrect
            - OtpExpiredException: OTP has expired
        """
        try:
            # Verify OTP
            await otp_service.verify_otp(identifier, code, "password_reset")
            
            # Generate reset token
            reset_token = create_reset_token(identifier)
            
            logger.info(f"Password reset OTP verified: {identifier}")
            
            return {
                "success": True,
                "message": "OTP verified! You can now reset your password.",
                "data": {"resetToken": reset_token},
            }
        
        except (InvalidOtpException, OtpExpiredException):
            raise
        except Exception as e:
            logger.error(f"OTP verification error: {str(e)}")
            raise DatabaseException("OTP verification")
    
    # ============================================================
    # RESET PASSWORD
    # ============================================================
    
    @staticmethod
    async def reset_password(
        reset_password_dto: ResetPasswordDto,
        reset_token: str,
    ) -> dict:
        """
        Step 3: Reset password using reset token
        
        Raises:
            - InvalidResetTokenException: Token is invalid
            - TokenMismatchException: Token identifier doesn't match
            - UserNotFoundException: User not found
            - PasswordResetFailedException: Password reset failed
        """
        try:
            # Verify token
            payload = decode_reset_token(reset_token)
            
            if not payload:
                raise InvalidResetTokenException()
            
            identifier = reset_password_dto.identifier
            
            if payload.get("identifier") != identifier:
                raise TokenMismatchException()
            
            db = await get_db()
            users_collection = db["users"]
            
            # Find user
            query = (
                {"email": identifier.lower()}
                if "@" in identifier
                else {"phone": identifier}
            )
            
            user = await users_collection.find_one(query)
            
            if not user:
                raise UserNotFoundException(identifier)
            
            # Hash new password
            hashed_password = hash_password(reset_password_dto.new_password)
            
            # Update password
            await users_collection.update_one(
                {"_id": user["_id"]},
                {"$set": {
                    "password": hashed_password,
                    "updatedAt": datetime.utcnow(),
                }}
            )
            
            # Delete OTP
            await otp_service.delete_otp(identifier, "password_reset")
            
            logger.info(f"Password reset successful: {identifier}")
            
            return {
                "success": True,
                "message": "Password reset successfully! Please log in with your new password.",
            }
        
        except (InvalidResetTokenException, TokenMismatchException, UserNotFoundException):
            raise
        except Exception as e:
            logger.error(f"Password reset error: {str(e)}")
            raise PasswordResetFailedException()
    
    # ============================================================
    # RESEND OTP
    # ============================================================
    
    @staticmethod
    async def resend_otp(dto: ResendOtpDto) -> dict:
        """
        Resend OTP for signup or password reset
        
        Raises:
            - OtpStillValidException: Current OTP is still valid
            - OtpResendTooSoonException: Resend requested too soon
            - OtpSendFailedException: Failed to send new OTP
        """
        try:
            db = await get_db()
            
            # Check if OTP is still valid
            is_expired = await otp_service.is_otp_expired(dto.identifier, dto.purpose)
            
            if not is_expired:
                remaining = await otp_service.get_otp_remaining_time(
                    dto.identifier, 
                    dto.purpose
                )
                raise OtpStillValidException(remaining)
            
            # Send new OTP
            try:
                await otp_service.send_otp(dto.identifier, dto.purpose)
            except Exception as e:
                logger.error(f"OTP resend failed: {str(e)}")
                raise OtpSendFailedException()
            
            logger.info(f"OTP resent for {dto.purpose}: {dto.identifier}")
            
            return {
                "success": True,
                "message": f"New OTP sent to your {('email' if '@' in dto.identifier else 'phone')}.",
                "data": {"identifier": dto.identifier},
            }
        
        except (OtpStillValidException, OtpResendTooSoonException, OtpSendFailedException):
            raise
        except Exception as e:
            logger.error(f"Resend OTP error: {str(e)}")
            raise DatabaseException("OTP resend")

# Create singleton instance
auth_service = AuthService()