Skip to content

Commit 229f295

Browse files
author
Matheus Galvao
committed
Refresh JWT tokens and logout with blacklist
1 parent f073965 commit 229f295

File tree

4 files changed

+223
-27
lines changed

4 files changed

+223
-27
lines changed

app/middleware/auth_middleware.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from flask import request, jsonify, session, Blueprint
33
import jwt
44
from config.auth_config import AuthMethod, AuthConfig
5+
from services.auth_service import blacklisted_tokens
56

67
class AuthMiddleware:
78
def __init__(self, auth_config: AuthConfig):
@@ -43,6 +44,9 @@ def _handle_jwt(self):
4344
return jsonify({"error": "JWT token is required"}), 401
4445

4546
token = auth_header.split(' ')[1]
47+
if token in blacklisted_tokens:
48+
return jsonify({"error": "You have been logged out. Please log in again."}), 401
49+
4650
try:
4751
jwt.decode(
4852
token,

app/routes/auth.py

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import jwt
33
import datetime
44
from config.auth_config import AuthMethod, AuthConfig
5-
from services.auth_service import generate_jwt_token, is_username_taken, add_user, signup_user, login_user, logout_user
5+
from services.auth_service import generate_jwt_token, is_username_taken, add_user, signup_user, login_user, logout_user, blacklist_token, validate_refresh_token, refresh_tokens
66

77
auth_bp = Blueprint("auth", __name__)
88
auth_config = None
@@ -11,17 +11,6 @@ def init_auth_routes(config: AuthConfig):
1111
global auth_config
1212
auth_config = config
1313

14-
def generate_jwt_token(username):
15-
return jwt.encode(
16-
{
17-
"sub": username,
18-
"iat": datetime.datetime.utcnow(),
19-
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=1)
20-
},
21-
auth_config.jwt_secret,
22-
algorithm="HS256"
23-
)
24-
2514
@auth_bp.route("/signup", methods=["POST"])
2615
def signup():
2716
if auth_config.auth_method == AuthMethod.API_KEY:
@@ -38,14 +27,42 @@ def login():
3827
data = request.get_json()
3928
return login_user(data)
4029

30+
@auth_bp.route("/refresh", methods=["POST"])
31+
def refresh_token():
32+
data = request.get_json()
33+
refresh_token = data.get("refresh_token")
34+
username = validate_refresh_token(refresh_token)
35+
if not username:
36+
return jsonify({"error": "Invalid refresh token"}), 401
37+
38+
access_token, new_refresh_token = generate_jwt_token(username)
39+
40+
# Remove old refresh token and store new one
41+
if refresh_token in refresh_tokens:
42+
del refresh_tokens[refresh_token]
43+
44+
return jsonify({
45+
"access_token": access_token,
46+
"refresh_token": new_refresh_token
47+
}), 200
48+
4149
@auth_bp.route("/logout", methods=["POST"])
4250
def logout():
4351
if auth_config.auth_method == AuthMethod.API_KEY:
4452
return jsonify({"error": "Logout not available with API key authentication"}), 400
4553

4654
elif auth_config.auth_method == AuthMethod.JWT:
47-
# JWT is stateless, so we can't really "logout"
48-
# In a real application, you might want to blacklist the token
55+
auth_header = request.headers.get('Authorization')
56+
if auth_header and auth_header.startswith('Bearer '):
57+
token = auth_header.split(' ')[1]
58+
blacklist_token(token)
59+
60+
# Invalidate refresh token
61+
data = request.get_json()
62+
refresh_token = data.get("refresh_token")
63+
if refresh_token in refresh_tokens:
64+
del refresh_tokens[refresh_token]
65+
4966
return jsonify({"message": "Logout successful"})
5067

5168
elif auth_config.auth_method == AuthMethod.SESSION:

app/services/auth_service.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import jwt
22
import datetime
3+
import secrets
34
from config.auth_config import AuthConfig, AuthMethod
45
from models.user import User
56
from flask import session, jsonify
@@ -10,20 +11,37 @@
1011
# List to store User objects
1112
users = []
1213

14+
# In-memory store for refresh tokens and blacklisted tokens
15+
refresh_tokens = {}
16+
blacklisted_tokens = set()
17+
1318
def init_auth_service(config: AuthConfig):
1419
global auth_config
1520
auth_config = config
1621

22+
def generate_refresh_token(username):
23+
refresh_token = secrets.token_hex(32)
24+
refresh_tokens[refresh_token] = username
25+
return refresh_token
26+
27+
def validate_refresh_token(refresh_token):
28+
return refresh_tokens.get(refresh_token)
29+
30+
def blacklist_token(token):
31+
blacklisted_tokens.add(token)
32+
1733
def generate_jwt_token(username):
18-
return jwt.encode(
34+
access_token = jwt.encode(
1935
{
2036
"sub": username,
2137
"iat": datetime.datetime.utcnow(),
22-
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=1)
38+
"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=15) # Shorter expiry for access token
2339
},
2440
auth_config.jwt_secret,
2541
algorithm="HS256"
2642
)
43+
refresh_token = generate_refresh_token(username)
44+
return access_token, refresh_token
2745

2846
# Function to check if a username already exists
2947
def is_username_taken(username):
@@ -53,10 +71,11 @@ def login_user(data):
5371
return jsonify({"error": "Username and password are required"}), 400
5472

5573
if auth_config.auth_method == AuthMethod.JWT:
56-
token = generate_jwt_token(data["username"])
74+
access_token, refresh_token = generate_jwt_token(data["username"])
5775
return jsonify({
5876
"message": "Login successful",
59-
"token": token if isinstance(token, str) else token.decode('utf-8')
77+
"access_token": access_token,
78+
"refresh_token": refresh_token
6079
})
6180

6281
elif auth_config.auth_method == AuthMethod.SESSION:

test/auth_api_collection.json

Lines changed: 165 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -277,17 +277,25 @@
277277
"script": {
278278
"exec": [
279279
"var jsonData = pm.response.json();",
280-
"if (jsonData.token) {",
281-
" pm.environment.set('jwt_token', jsonData.token);",
280+
"if (jsonData.access_token) {",
281+
" pm.environment.set('jwt_token', jsonData.access_token);",
282282
" console.log('JWT token saved to environment');",
283283
"}",
284+
"if (jsonData.refresh_token) {",
285+
" pm.environment.set('refresh_token', jsonData.refresh_token);",
286+
" console.log('Refresh token saved to environment');",
287+
"}",
284288
"",
285289
"pm.test('Status code is 200', function() {",
286290
" pm.response.to.have.status(200);",
287291
"});",
288292
"",
289-
"pm.test('Response has token', function() {",
290-
" pm.expect(jsonData.token).to.exist;",
293+
"pm.test('Response has access token', function() {",
294+
" pm.expect(jsonData.access_token).to.exist;",
295+
"});",
296+
"",
297+
"pm.test('Response has refresh token', function() {",
298+
" pm.expect(jsonData.refresh_token).to.exist;",
291299
"});"
292300
],
293301
"type": "text/javascript"
@@ -317,7 +325,51 @@
317325
}
318326
},
319327
{
320-
"name": "5. Get Todos with JWT",
328+
"name": "5. Refresh Token While Logged In",
329+
"event": [
330+
{
331+
"listen": "test",
332+
"script": {
333+
"exec": [
334+
"var jsonData = pm.response.json();",
335+
"pm.test('Status code is 200', function() {",
336+
" pm.response.to.have.status(200);",
337+
"});",
338+
"",
339+
"pm.test('Response has new access token', function() {",
340+
" pm.expect(jsonData.access_token).to.exist;",
341+
" pm.environment.set('jwt_token', jsonData.access_token);",
342+
" console.log('New access token saved to environment');",
343+
"});"
344+
],
345+
"type": "text/javascript"
346+
}
347+
}
348+
],
349+
"request": {
350+
"method": "POST",
351+
"header": [
352+
{
353+
"key": "Content-Type",
354+
"value": "application/json",
355+
"type": "text"
356+
}
357+
],
358+
"body": {
359+
"mode": "raw",
360+
"raw": "{\n \"refresh_token\": \"{{refresh_token}}\"\n}"
361+
},
362+
"url": {
363+
"raw": "http://localhost:8000/auth/refresh",
364+
"protocol": "http",
365+
"host": ["localhost"],
366+
"port": "8000",
367+
"path": ["auth", "refresh"]
368+
}
369+
}
370+
},
371+
{
372+
"name": "6. Get Todos with JWT",
321373
"event": [
322374
{
323375
"listen": "test",
@@ -350,14 +402,22 @@
350402
}
351403
},
352404
{
353-
"name": "6. Logout with JWT",
405+
"name": "7. Logout with JWT",
354406
"event": [
355407
{
356408
"listen": "test",
357409
"script": {
358410
"exec": [
411+
"// Save the current token as blacklisted_token for later tests",
412+
"pm.environment.set('blacklisted_token', pm.environment.get('jwt_token'));",
413+
"",
359414
"pm.test('Status code is 200', function() {",
360415
" pm.response.to.have.status(200);",
416+
"});",
417+
"",
418+
"pm.test('Logout successful message', function() {",
419+
" var jsonData = pm.response.json();",
420+
" pm.expect(jsonData.message).to.equal('Logout successful');",
361421
"});"
362422
],
363423
"type": "text/javascript"
@@ -366,6 +426,22 @@
366426
],
367427
"request": {
368428
"method": "POST",
429+
"header": [
430+
{
431+
"key": "Content-Type",
432+
"value": "application/json",
433+
"type": "text"
434+
},
435+
{
436+
"key": "Authorization",
437+
"value": "Bearer {{jwt_token}}",
438+
"type": "text"
439+
}
440+
],
441+
"body": {
442+
"mode": "raw",
443+
"raw": "{\n \"refresh_token\": \"{{refresh_token}}\"\n}"
444+
},
369445
"url": {
370446
"raw": "http://localhost:8000/auth/logout",
371447
"protocol": "http",
@@ -376,7 +452,7 @@
376452
}
377453
},
378454
{
379-
"name": "7. Try to Get Todos with Invalid JWT (Should Fail)",
455+
"name": "8. Try to Get Todos with Invalid JWT (Should Fail)",
380456
"event": [
381457
{
382458
"listen": "test",
@@ -388,7 +464,7 @@
388464
"",
389465
"pm.test('Error message is correct', function() {",
390466
" var jsonData = pm.response.json();",
391-
" pm.expect(jsonData.error).to.contain('Invalid JWT token');",
467+
" pm.expect(jsonData.error).to.include('Invalid JWT token');",
392468
"});"
393469
],
394470
"type": "text/javascript"
@@ -414,7 +490,87 @@
414490
}
415491
},
416492
{
417-
"name": "8. Get API Documentation with JWT",
493+
"name": "9. Try to Refresh with Invalidated Token (Should Fail)",
494+
"event": [
495+
{
496+
"listen": "test",
497+
"script": {
498+
"exec": [
499+
"pm.test('Status code is 401', function() {",
500+
" pm.response.to.have.status(401);",
501+
"});",
502+
"",
503+
"pm.test('Error message is correct', function() {",
504+
" var jsonData = pm.response.json();",
505+
" pm.expect(jsonData.error).to.equal('Invalid refresh token');",
506+
"});"
507+
],
508+
"type": "text/javascript"
509+
}
510+
}
511+
],
512+
"request": {
513+
"method": "POST",
514+
"header": [
515+
{
516+
"key": "Content-Type",
517+
"value": "application/json",
518+
"type": "text"
519+
}
520+
],
521+
"body": {
522+
"mode": "raw",
523+
"raw": "{\n \"refresh_token\": \"{{refresh_token}}\"\n}"
524+
},
525+
"url": {
526+
"raw": "http://localhost:8000/auth/refresh",
527+
"protocol": "http",
528+
"host": ["localhost"],
529+
"port": "8000",
530+
"path": ["auth", "refresh"]
531+
}
532+
}
533+
},
534+
{
535+
"name": "10. Try to Use Blacklisted Token (Should Fail)",
536+
"event": [
537+
{
538+
"listen": "test",
539+
"script": {
540+
"exec": [
541+
"pm.test('Status code is 401', function() {",
542+
" pm.response.to.have.status(401);",
543+
"});",
544+
"",
545+
"pm.test('Error message is correct', function() {",
546+
" var jsonData = pm.response.json();",
547+
" pm.expect(jsonData.error).to.equal('You have been logged out. Please log in again.');",
548+
"});"
549+
],
550+
"type": "text/javascript"
551+
}
552+
}
553+
],
554+
"request": {
555+
"method": "GET",
556+
"header": [
557+
{
558+
"key": "Authorization",
559+
"value": "Bearer {{blacklisted_token}}",
560+
"type": "text"
561+
}
562+
],
563+
"url": {
564+
"raw": "http://localhost:8000/todos",
565+
"protocol": "http",
566+
"host": ["localhost"],
567+
"port": "8000",
568+
"path": ["todos"]
569+
}
570+
}
571+
},
572+
{
573+
"name": "11. Get API Documentation with JWT",
418574
"event": [
419575
{
420576
"listen": "test",

0 commit comments

Comments
 (0)