Coverage for src/django_otp_webauthn/views.py: 0%

132 statements  

« prev     ^ index     » next       coverage.py v7.5.4, created at 2024-06-23 20:15 +0000

1from functools import lru_cache 

2from logging import getLogger 

3 

4from django.conf import settings 

5from django.contrib.auth import get_user_model 

6from django.contrib.auth import login as auth_login 

7from django.contrib.auth.models import AbstractUser 

8from django.shortcuts import resolve_url 

9from django.utils.decorators import method_decorator 

10from django.utils.http import url_has_allowed_host_and_scheme 

11from django.views.decorators.cache import never_cache 

12from django_otp import login as otp_login 

13from rest_framework.response import Response 

14from rest_framework.views import APIView 

15 

16from django_otp_webauthn import exceptions 

17from django_otp_webauthn.models import AbstractWebAuthnCredential 

18from django_otp_webauthn.settings import app_settings 

19from django_otp_webauthn.utils import get_credential_model, rewrite_exceptions 

20 

21WebAuthnCredential = get_credential_model() 

22User = get_user_model() 

23 

24 

25@lru_cache(maxsize=1) 

26def _get_pywebauthn_logger(): 

27 logger_name = app_settings.OTP_WEBAUTHN_EXCEPTION_LOGGER_NAME 

28 if logger_name: 

29 return getLogger(logger_name) 

30 

31 

32class RegistrationCeremonyMixin: 

33 def dispatch(self, request, *args, **kwargs): 

34 self.user = self.get_user() 

35 if not self.user: 

36 raise exceptions.UserDisabled() 

37 

38 if not self.can_register(self.user): 

39 raise exceptions.RegistrationDisabled() 

40 return super().dispatch(request, *args, **kwargs) 

41 

42 def get_user(self) -> AbstractUser: 

43 if self.request.user.is_authenticated: 

44 return self.request.user 

45 return None 

46 

47 def can_register(self, user: AbstractUser) -> bool: 

48 if not user.is_active: 

49 return False 

50 return True 

51 

52 

53class AuthenticationCeremonyMixin: 

54 def dispatch(self, request, *args, **kwargs): 

55 self.user = self.get_user() 

56 if not self.can_authenticate(self.user): 

57 raise exceptions.AuthenticationDisabled() 

58 return super().dispatch(request, *args, **kwargs) 

59 

60 def get_user(self) -> AbstractUser: 

61 if self.request.user.is_authenticated: 

62 return self.request.user 

63 return None 

64 

65 def can_authenticate(self, user: AbstractUser) -> bool: 

66 if user and not user.is_active: 

67 return False 

68 return True 

69 

70 

71@method_decorator(never_cache, name="dispatch") 

72class BeginCredentialRegistrationView(RegistrationCeremonyMixin, APIView): 

73 """View for starting webauthn credential registration. Requires the user to be logged in. 

74 

75 This view will return a JSON response with the options for the client to use to register a credential. 

76 """ 

77 

78 def post(self, *args, **kwargs): 

79 user = self.user 

80 helper = WebAuthnCredential.get_webauthn_helper(request=self.request) 

81 data, state = helper.register_begin(user=user) 

82 

83 self.request.session["otp_webauthn_register_state"] = state 

84 

85 return Response(data=data, content_type="application/json") 

86 

87 

88@method_decorator(never_cache, name="dispatch") 

89class CompleteCredentialRegistrationView(RegistrationCeremonyMixin, APIView): 

90 """View for completing webauthn credential registration. Requires the user to be logged in and to have started the registration. 

91 

92 This view accepts client data about the registered credential, validates it, and saves the credential to the database. 

93 """ 

94 

95 def get_state(self): 

96 """Retrieve the registration state.""" 

97 

98 state = self.request.session.pop("otp_webauthn_register_state", None) 

99 # Ensure to persist the session after popping the state, so even if an 

100 # exception is raised, the state is _never_ reused. 

101 self.request.session.save() 

102 if not state: 

103 raise exceptions.InvalidState() 

104 return state 

105 

106 def post(self, *args, **kwargs): 

107 user = self.user 

108 state = self.get_state() 

109 data = self.request.data 

110 

111 helper = WebAuthnCredential.get_webauthn_helper(request=self.request) 

112 

113 logger = _get_pywebauthn_logger() 

114 with rewrite_exceptions(logger=logger): 

115 device = helper.register_complete(user=user, state=state, data=data) 

116 return Response(data={"id": device.pk}, content_type="application/json") 

117 

118 

119@method_decorator(never_cache, name="dispatch") 

120class BeginCredentialAuthenticationView(AuthenticationCeremonyMixin, APIView): 

121 """View for starting webauthn credential authentication. User does not necessarily need to be logged in. 

122 

123 If the user is logged in, this view supplies more hints to the client about registered credentials. 

124 

125 This view will return a JSON response with the options for the client to use to authenticate with a credential. 

126 """ 

127 

128 def post(self, *args, **kwargs): 

129 user = self.user 

130 

131 helper = WebAuthnCredential.get_webauthn_helper(request=self.request) 

132 require_user_verification = not bool(user) 

133 

134 data, state = helper.authenticate_begin(user=user, require_user_verification=require_user_verification) 

135 self.request.session["otp_webauthn_authentication_state"] = state 

136 

137 return Response(data=data, content_type="application/json") 

138 

139 

140@method_decorator(never_cache, name="dispatch") 

141class CompleteCredentialAuthenticationView(AuthenticationCeremonyMixin, APIView): 

142 """View for completing webauthn credential authentication. Requires the user to be 

143 logged in and to have started the authentication. 

144 

145 This view accepts client data about the registered webauthn , validates it, 

146 and logs the user in. 

147 """ 

148 

149 def get_state(self): 

150 """Retrieve the authentication state.""" 

151 # It is VITAL that we pop the state from the session before we do anything else. 

152 # We must not allow the state to be used more than once or we risk replay attacks. 

153 

154 state = self.request.session.pop("otp_webauthn_authentication_state", None) 

155 # Ensure to persist the session after popping the state, so even if an 

156 # exception is raised, the state is _never_ reused. 

157 self.request.session.save() 

158 

159 if not state: 

160 raise exceptions.InvalidState() 

161 return state 

162 

163 def check_login_allowed(self, device: AbstractWebAuthnCredential) -> None: 

164 """Check if the user is allowed to log in using the device. 

165 

166 This will raise: 

167 - ``PasswordlessLoginDisabled`` if there is no user logged in and 

168 ``OTP_WEBAUTHN_ALLOW_PASSWORDLESS_LOGIN`` is False. 

169 - ``UserDisabled`` if the user associated with the device is not 

170 active. 

171 

172 You can override this method to implement your own custom logic. If you 

173 do, you should raise an exception if the user is not allowed to log in. 

174 

175 Args: 

176 device (AbstractWebAuthnCredential): The device the user is trying to log 

177 in with. 

178 """ 

179 disallow_passwordless_login = not app_settings.OTP_WEBAUTHN_ALLOW_PASSWORDLESS_LOGIN 

180 if not device.confirmed: 

181 raise exceptions.CredentialDisabled() 

182 

183 if self.get_user() is None and disallow_passwordless_login: 

184 raise exceptions.PasswordlessLoginDisabled() 

185 

186 if not device.user.is_active: 

187 raise exceptions.UserDisabled() 

188 

189 def complete_auth(self, device: AbstractWebAuthnCredential) -> AbstractUser: 

190 """Handle the completion of the authentication procedure. 

191 

192 This method is called when a credential was successfully used and 

193 the user is allowed to log in. The user is logged in and marked as 

194 having passed verification. 

195 

196 You may override this method to implement custom logic. 

197 """ 

198 user = device.user 

199 if not self.request.user.is_authenticated: 

200 auth_login(self.request, user) 

201 

202 # Mark the user as having passed verification 

203 otp_login(self.request, device) 

204 

205 success_url_allowed_hosts = set() 

206 

207 def get_success_data(self, device: AbstractWebAuthnCredential): 

208 data = { 

209 "id": device.pk, 

210 "redirect_url": self.get_success_url(), 

211 } 

212 

213 return data 

214 

215 def get_success_url_allowed_hosts(self): 

216 return {self.request.get_host(), *self.success_url_allowed_hosts} 

217 

218 def get_redirect_url(self): 

219 """Return the user-originating redirect URL if it's safe.""" 

220 redirect_to = self.request.GET.get("next") 

221 url_is_safe = url_has_allowed_host_and_scheme( 

222 url=redirect_to, 

223 allowed_hosts=self.get_success_url_allowed_hosts(), 

224 require_https=self.request.is_secure(), 

225 ) 

226 return redirect_to if url_is_safe else "" 

227 

228 def get_success_url(self): 

229 """Where to send the user after a successful login.""" 

230 return self.get_redirect_url() or resolve_url(settings.LOGIN_REDIRECT_URL) 

231 

232 def post(self, *args, **kwargs): 

233 user = self.user 

234 state = self.get_state() 

235 data = self.request.data 

236 

237 helper = WebAuthnCredential.get_webauthn_helper(request=self.request) 

238 

239 logger = _get_pywebauthn_logger() 

240 with rewrite_exceptions(logger=logger): 

241 device = helper.authenticate_complete(user=user, state=state, data=data) 

242 

243 self.check_login_allowed(device) 

244 

245 self.complete_auth(device) 

246 

247 return Response(self.get_success_data(device))