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

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 

13 

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) 

21 

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.") 

39 

40 # 1. Create Organization 

41 org = Organization.objects.create( 

42 name=organization_name, 

43 currency='PHP', 

44 ) 

45 

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 ) 

54 

55 # 3. Create Membership as Owner 

56 Membership.objects.create( 

57 user=user, 

58 organization=org, 

59 role='OWNER', 

60 is_default=True 

61 ) 

62 

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)) 

67 

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 } 

78 

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.") 

86 

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.") 

90 

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 ) 

108 

109 user.current_organization = org 

110 user.save(update_fields=['current_organization']) 

111 

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) 

117 

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 } 

128 

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) 

139 

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 

144 

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) 

163 

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) 

177 

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.") 

183 

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.") 

188 

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)) 

192 

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 } 

199 

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.") 

205 

206 user.current_organization = membership.organization 

207 user.save(update_fields=['current_organization']) 

208 

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)) 

212 

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 }