Coverage for modules\authentication\services.py: 49%
125 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-10-17 16:50 +0800
« prev ^ index » next coverage.py v7.16.0, created at 2026-10-17 16:50 +0800
1import hashlib
2import secrets
3from datetime import timedelta
4from django.core.cache import cache
5from django.core.mail import send_mail
6from django.conf import settings
7from django.db import transaction
8from django.utils import timezone
9from core.exceptions import BadRequestException, UnauthorizedException, NotFoundException
10from core.auth import create_access_token, create_refresh_token, decode_token
11from .models import AuthAuditEvent, PasswordResetToken, User, Organization, Membership
12from modules.team.models import TeamMember
14class AuthService:
15 @staticmethod
16 def audit(event_type: str, email: str = '', user: User | None = None, request=None, **metadata):
17 ip_address = None
18 if request:
19 ip_address = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or request.META.get('REMOTE_ADDR')
20 AuthAuditEvent.objects.create(user=user, email=email.lower(), event_type=event_type, ip_address=ip_address, metadata=metadata)
22 @staticmethod
23 def attach_role(user: User, org):
24 role = 'MEMBER'
25 tm = TeamMember.objects.filter(user=user).first()
26 if tm:
27 role = tm.role
28 elif org:
29 m = Membership.objects.filter(user=user, organization=org).first()
30 if m:
31 role = m.role
32 user.role = role
33 return user
34 @staticmethod
35 @transaction.atomic
36 def register(email: str, password: str, first_name: str, last_name: str, organization_name: str):
37 if User.objects.filter(email__iexact=email).exists():
38 raise BadRequestException("A user with this email address already exists.")
40 # 1. Create Organization
41 org = Organization.objects.create(
42 name=organization_name,
43 currency='PHP',
44 )
46 # 2. Create User
47 user = User.objects.create_user(
48 email=email,
49 password=password,
50 first_name=first_name,
51 last_name=last_name,
52 current_organization=org
53 )
55 # 3. Create Membership as Owner
56 Membership.objects.create(
57 user=user,
58 organization=org,
59 role='OWNER',
60 is_default=True
61 )
63 # 4. Generate Tokens
64 AuthService.attach_role(user, org)
65 access_token = create_access_token(str(user.id), str(org.id))
66 refresh_token = create_refresh_token(str(user.id))
68 return {
69 "user": user,
70 "organization": org,
71 "tokens": {
72 "access_token": access_token,
73 "refresh_token": refresh_token,
74 "token_type": "Bearer",
75 "expires_in": settings.JWT_ACCESS_TOKEN_LIFETIME_MINUTES * 60,
76 }
77 }
79 @staticmethod
80 @transaction.atomic
81 def login(email: str, password: str, request=None):
82 user = User.objects.filter(email__iexact=email).first()
83 if not user or not user.check_password(password):
84 AuthService.audit('LOGIN_FAILED', email=email, request=request)
85 raise UnauthorizedException("Invalid email or password.")
87 if not user.is_active:
88 AuthService.audit('LOGIN_FAILED', email=email, user=user, request=request, reason='inactive')
89 raise UnauthorizedException("This account is inactive.")
91 org = user.current_organization
92 if not org:
93 membership = Membership.objects.filter(user=user).order_by('created_at').first()
94 if membership:
95 org = membership.organization
96 else:
97 org_name = (user.first_name or user.email.split('@')[0]).strip() or 'Primary Organization'
98 org = Organization.objects.create(
99 name=f"{org_name} Workspace",
100 currency='PHP',
101 )
102 Membership.objects.create(
103 user=user,
104 organization=org,
105 role='OWNER',
106 is_default=True,
107 )
109 user.current_organization = org
110 user.save(update_fields=['current_organization'])
112 org_id = str(org.id) if org else None
113 AuthService.attach_role(user, org)
114 access_token = create_access_token(str(user.id), org_id)
115 refresh_token = create_refresh_token(str(user.id))
116 AuthService.audit('LOGIN_SUCCEEDED', email=user.email, user=user, request=request)
118 return {
119 "user": user,
120 "organization": org,
121 "tokens": {
122 "access_token": access_token,
123 "refresh_token": refresh_token,
124 "token_type": "Bearer",
125 "expires_in": settings.JWT_ACCESS_TOKEN_LIFETIME_MINUTES * 60,
126 }
127 }
129 @staticmethod
130 @transaction.atomic
131 def request_password_reset(email: str, request=None):
132 """Always returns success so callers cannot enumerate user accounts."""
133 normalized_email = email.lower()
134 cache_key = f'password-reset:{normalized_email}'
135 attempts = cache.get(cache_key, 0)
136 if attempts >= settings.PASSWORD_RESET_REQUEST_LIMIT:
137 return
138 cache.set(cache_key, attempts + 1, settings.PASSWORD_RESET_REQUEST_WINDOW_SECONDS)
140 user = User.objects.filter(email__iexact=normalized_email, is_active=True).first()
141 if not user:
142 AuthService.audit('PASSWORD_RESET_REQUESTED', email=normalized_email, request=request, account_found=False)
143 return
145 PasswordResetToken.objects.filter(user=user, used_at__isnull=True).update(used_at=timezone.now())
146 raw_token = secrets.token_urlsafe(32)
147 PasswordResetToken.objects.create(
148 user=user,
149 token_hash=hashlib.sha256(raw_token.encode()).hexdigest(),
150 expires_at=timezone.now() + timedelta(minutes=settings.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES),
151 )
152 reset_url = f'{settings.FRONTEND_URL}/reset-password?token={raw_token}'
153 send_mail(
154 subject='Reset your JLM ERP password',
155 message=(f'Use this link to reset your password: {reset_url}\n\n'
156 f'The link expires in {settings.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES} minutes. '
157 'If you did not request this, you can ignore this email.'),
158 from_email=settings.DEFAULT_FROM_EMAIL,
159 recipient_list=[user.email],
160 fail_silently=False,
161 )
162 AuthService.audit('PASSWORD_RESET_REQUESTED', email=user.email, user=user, request=request, account_found=True)
164 @staticmethod
165 @transaction.atomic
166 def confirm_password_reset(token: str, password: str, request=None):
167 token_hash = hashlib.sha256(token.encode()).hexdigest()
168 reset = PasswordResetToken.objects.select_for_update().select_related('user').filter(token_hash=token_hash).first()
169 if not reset or not reset.is_usable:
170 raise BadRequestException('This password reset link is invalid or has expired.')
171 reset.user.set_password(password)
172 reset.user.save(update_fields=['password'])
173 reset.used_at = timezone.now()
174 reset.save(update_fields=['used_at'])
175 PasswordResetToken.objects.filter(user=reset.user, used_at__isnull=True).exclude(id=reset.id).update(used_at=timezone.now())
176 AuthService.audit('PASSWORD_RESET_COMPLETED', email=reset.user.email, user=reset.user, request=request)
178 @staticmethod
179 def refresh(refresh_token: str):
180 payload = decode_token(refresh_token)
181 if not payload or payload.get('type') != 'refresh':
182 raise UnauthorizedException("Invalid or expired refresh token.")
184 user_id = payload.get('sub')
185 user = User.objects.filter(id=user_id, is_active=True).first()
186 if not user:
187 raise UnauthorizedException("User not found.")
189 org_id = str(user.current_organization.id) if user.current_organization else None
190 access_token = create_access_token(str(user.id), org_id)
191 new_refresh_token = create_refresh_token(str(user.id))
193 return {
194 "access_token": access_token,
195 "refresh_token": new_refresh_token,
196 "token_type": "Bearer",
197 "expires_in": settings.JWT_ACCESS_TOKEN_LIFETIME_MINUTES * 60,
198 }
200 @staticmethod
201 def switch_organization(user: User, organization_id):
202 membership = Membership.objects.filter(user=user, organization_id=organization_id).first()
203 if not membership:
204 raise NotFoundException("Organization not found or you are not a member.")
206 user.current_organization = membership.organization
207 user.save(update_fields=['current_organization'])
209 AuthService.attach_role(user, membership.organization)
210 access_token = create_access_token(str(user.id), str(membership.organization.id))
211 refresh_token = create_refresh_token(str(user.id))
213 return {
214 "user": user,
215 "organization": membership.organization,
216 "tokens": {
217 "access_token": access_token,
218 "refresh_token": refresh_token,
219 "token_type": "Bearer",
220 "expires_in": settings.JWT_ACCESS_TOKEN_LIFETIME_MINUTES * 60,
221 }
222 }