11from django .urls import path
22from django .contrib .auth import get_user_model
33from django .utils .translation import gettext_lazy as _
4- from rest_framework import serializers , exceptions
4+ from django .conf import settings
5+ from rest_framework import serializers , exceptions , status
6+ from rest_framework .response import Response
7+ from rest_framework .permissions import AllowAny
58from rest_framework_simplejwt import views as jwt_views , serializers as jwt
69from two_factor .utils import default_device
710
1114User = get_user_model ()
1215
1316
17+ # --- Cookie helpers (shared by all JWT views) ---
18+
19+ def get_cookie_config (include_max_age = True ):
20+ """Build cookie configuration from Django settings."""
21+ config = dict (
22+ access_cookie_name = getattr (settings , "JWT_AUTH_COOKIE" , "karrio_access_token" ),
23+ refresh_cookie_name = getattr (settings , "JWT_REFRESH_COOKIE" , "karrio_refresh_token" ),
24+ secure = getattr (settings , "JWT_AUTH_COOKIE_SECURE" , getattr (settings , "USE_HTTPS" , False )),
25+ samesite = getattr (settings , "JWT_AUTH_COOKIE_SAMESITE" , "Lax" ),
26+ path = getattr (settings , "JWT_AUTH_COOKIE_PATH" , "/" ),
27+ )
28+
29+ if include_max_age :
30+ jwt_config = getattr (settings , "SIMPLE_JWT" , {})
31+ access_lifetime = jwt_config .get ("ACCESS_TOKEN_LIFETIME" )
32+ refresh_lifetime = jwt_config .get ("REFRESH_TOKEN_LIFETIME" )
33+ config .update (
34+ access_max_age = int (access_lifetime .total_seconds ()) if access_lifetime else 1800 ,
35+ refresh_max_age = int (refresh_lifetime .total_seconds ()) if refresh_lifetime else 259200 ,
36+ )
37+
38+ return config
39+
40+
41+ def set_auth_cookies (response , access_token , refresh_token ):
42+ """Set HTTP-only cookies for access and refresh tokens on the response."""
43+ config = get_cookie_config ()
44+
45+ response .set_cookie (
46+ config ["access_cookie_name" ],
47+ access_token ,
48+ max_age = config ["access_max_age" ],
49+ httponly = True ,
50+ secure = config ["secure" ],
51+ samesite = config ["samesite" ],
52+ path = config ["path" ],
53+ )
54+ response .set_cookie (
55+ config ["refresh_cookie_name" ],
56+ refresh_token ,
57+ max_age = config ["refresh_max_age" ],
58+ httponly = True ,
59+ secure = config ["secure" ],
60+ samesite = config ["samesite" ],
61+ path = config ["path" ],
62+ )
63+
64+
65+ def clear_auth_cookies (response ):
66+ """Clear HTTP-only auth cookies by expiring them immediately."""
67+ config = get_cookie_config (include_max_age = False )
68+
69+ for cookie_name in [config ["access_cookie_name" ], config ["refresh_cookie_name" ]]:
70+ response .set_cookie (
71+ cookie_name ,
72+ "" ,
73+ max_age = 0 ,
74+ httponly = True ,
75+ secure = config ["secure" ],
76+ samesite = config ["samesite" ],
77+ path = config ["path" ],
78+ )
79+
80+
81+ def get_refresh_token (request ):
82+ """Get refresh token from cookie, falling back to request body."""
83+ cookie_name = getattr (settings , "JWT_REFRESH_COOKIE" , "karrio_refresh_token" )
84+ refresh_token = (
85+ request .COOKIES .get (cookie_name )
86+ or request .data .get ("refresh" )
87+ )
88+
89+ if not refresh_token :
90+ raise exceptions .ValidationError (
91+ {"refresh" : _ ("Refresh token is required." )}
92+ )
93+
94+ return refresh_token
95+
96+
97+ def _build_token_response (access_token , refresh_token ):
98+ """Build a 201 token pair response with no-cache headers."""
99+ response = Response (
100+ {"access" : access_token , "refresh" : refresh_token },
101+ status = status .HTTP_201_CREATED ,
102+ )
103+ response ["Cache-Control" ] = "no-store"
104+ response ["CDN-Cache-Control" ] = "no-store"
105+ return response
106+
107+
108+ # --- Serializers ---
109+
14110class AccessToken (serializers .Serializer ):
15111 access = serializers .CharField ()
16112
@@ -49,7 +145,13 @@ def validate(self, attrs):
49145
50146class TokenRefreshSerializer (jwt .TokenRefreshSerializer ):
51147 def validate (self , attrs : dict ):
52- refresh = jwt .RefreshToken (attrs ["refresh" ])
148+ refresh_token = attrs .get ("refresh" )
149+ if not refresh_token :
150+ raise exceptions .ValidationError (
151+ {"refresh" : _ ("Refresh token is required." )}
152+ )
153+
154+ refresh = jwt .RefreshToken (refresh_token )
53155
54156 if not refresh ["is_verified" ]:
55157 raise exceptions .AuthenticationFailed (
@@ -86,7 +188,13 @@ class VerifiedTokenObtainPairSerializer(jwt.TokenRefreshSerializer):
86188 )
87189
88190 def validate (self , attrs ):
89- refresh = self .token_class (attrs ["refresh" ])
191+ refresh_token = attrs .get ("refresh" )
192+ if not refresh_token :
193+ raise exceptions .ValidationError (
194+ {"refresh" : _ ("Refresh token is required." )}
195+ )
196+
197+ refresh = self .token_class (refresh_token )
90198 user = User .objects .get (id = refresh ["user_id" ])
91199 refresh ["is_verified" ] = self ._validate_otp (attrs ["otp_token" ], user )
92200
@@ -126,6 +234,8 @@ def _validate_otp(self, otp_token, user) -> bool:
126234 )
127235
128236
237+ # --- Views ---
238+
129239class TokenObtainPair (jwt_views .TokenObtainPairView ):
130240 serializer_class = TokenObtainPairSerializer
131241
@@ -134,13 +244,17 @@ class TokenObtainPair(jwt_views.TokenObtainPairView):
134244 tags = ["Auth" ],
135245 operation_id = f"{ ENDPOINT_ID } authenticate" ,
136246 summary = "Obtain auth token pair" ,
137- description = "Authenticate the user and return a token pair" ,
247+ description = "Authenticate the user and return a token pair. Tokens are stored in HTTP-only cookies. " ,
138248 responses = {201 : TokenPair ()},
139249 )
140250 def post (self , * args , ** kwargs ):
141- response = super ().post (* args , ** kwargs )
142- response ["Cache-Control" ] = "no-store"
143- response ["CDN-Cache-Control" ] = "no-store"
251+ serializer = self .get_serializer (data = self .request .data )
252+ serializer .is_valid (raise_exception = True )
253+
254+ data = serializer .validated_data
255+ response = _build_token_response (data ["access" ], data ["refresh" ])
256+ set_auth_cookies (response , data ["access" ], data ["refresh" ])
257+
144258 return response
145259
146260
@@ -152,13 +266,23 @@ class TokenRefresh(jwt_views.TokenRefreshView):
152266 tags = ["Auth" ],
153267 operation_id = f"{ ENDPOINT_ID } refresh_token" ,
154268 summary = "Refresh auth token" ,
155- description = "Authenticate the user and return a token pair " ,
269+ description = "Refresh the authentication token. Tokens are stored in HTTP-only cookies. " ,
156270 responses = {201 : TokenPair ()},
157271 )
158272 def post (self , * args , ** kwargs ):
159- response = super ().post (* args , ** kwargs )
160- response ["Cache-Control" ] = "no-store"
161- response ["CDN-Cache-Control" ] = "no-store"
273+ refresh_token = get_refresh_token (self .request )
274+
275+ serializer = self .get_serializer (data = {"refresh" : refresh_token })
276+ serializer .is_valid (raise_exception = True )
277+
278+ data = serializer .validated_data
279+ access = data ["access" ]
280+ refresh = data .get ("refresh" )
281+
282+ response = _build_token_response (access , refresh or refresh_token )
283+ if refresh is not None :
284+ set_auth_cookies (response , access , refresh )
285+
162286 return response
163287
164288
@@ -187,13 +311,52 @@ class VerifiedTokenPair(jwt_views.TokenVerifyView):
187311 tags = ["Auth" ],
188312 operation_id = f"{ ENDPOINT_ID } get_verified_token" ,
189313 summary = "Get verified JWT token" ,
190- description = "Get a verified JWT token pair by submitting a Two-Factor authentication code." ,
314+ description = "Get a verified JWT token pair by submitting a Two-Factor authentication code. Tokens are stored in HTTP-only cookies. " ,
191315 responses = {201 : TokenPair ()},
192316 )
193317 def post (self , * args , ** kwargs ):
194- response = super ().post (* args , ** kwargs )
318+ refresh_token = get_refresh_token (self .request )
319+
320+ serializer = self .get_serializer (data = {
321+ "refresh" : refresh_token ,
322+ "otp_token" : self .request .data .get ("otp_token" ),
323+ })
324+ serializer .is_valid (raise_exception = True )
325+
326+ data = serializer .validated_data
327+ access = data ["access" ]
328+ refresh = data .get ("refresh" )
329+
330+ response = _build_token_response (access , refresh or refresh_token )
331+ if refresh is not None :
332+ set_auth_cookies (response , access , refresh )
333+
334+ return response
335+
336+
337+ class LogoutView (jwt_views .TokenVerifyView ):
338+ """Logout view that clears HTTP-only auth cookies."""
339+ permission_classes = [AllowAny ]
340+
341+ @openapi .extend_schema (
342+ auth = [],
343+ tags = ["Auth" ],
344+ operation_id = f"{ ENDPOINT_ID } logout" ,
345+ summary = "Logout" ,
346+ description = "Clear authentication cookies and logout the user. Accessible without authentication." ,
347+ responses = {200 : openapi .OpenApiTypes .OBJECT },
348+ )
349+ def post (self , * args , ** kwargs ):
350+ response = Response (
351+ {"detail" : "Successfully logged out." },
352+ status = status .HTTP_200_OK ,
353+ )
354+
355+ clear_auth_cookies (response )
356+
195357 response ["Cache-Control" ] = "no-store"
196358 response ["CDN-Cache-Control" ] = "no-store"
359+
197360 return response
198361
199362
@@ -202,4 +365,5 @@ def post(self, *args, **kwargs):
202365 path ("api/token/refresh" , TokenRefresh .as_view (), name = "jwt-refresh" ),
203366 path ("api/token/verify" , TokenVerify .as_view (), name = "jwt-verify" ),
204367 path ("api/token/verified" , VerifiedTokenPair .as_view (), name = "verified-jwt-pair" ),
368+ path ("api/logout" , LogoutView .as_view (), name = "jwt-logout" ),
205369]
0 commit comments