From 5794ddfa0c0dbf161ada7c9a2270a0509f641455 Mon Sep 17 00:00:00 2001 From: Chinenye Nmoh Date: Wed, 29 Oct 2025 17:39:31 +0100 Subject: [PATCH] added bearer token authentication --- app/__init__.py | 33 ++++++- app/api/middlewares/__init__.py | 2 +- app/api/middlewares/cors.py | 1 + app/api/routes/__init__.py | 1 + app/api/routes/authentication.py | 24 +++++ app/api/routes/routes.py | 37 ++++---- app/api/schemas/generate_token.py | 18 ++++ app/api/services/__init__.py | 1 + app/api/services/generate_token.py | 89 +++++++++++++++++++ app/config.py | 6 ++ app/swagger/digifi_swagger.json | 50 +++++++---- app/swagger/paths/GenerateToken.json | 56 ++++++++++++ app/swagger/schemas/GenerateTokenRequest.json | 21 +++++ .../schemas/GenerateTokenResponse.json | 41 +++++++++ requirements.txt | 2 + 15 files changed, 339 insertions(+), 43 deletions(-) create mode 100644 app/api/routes/authentication.py create mode 100644 app/api/schemas/generate_token.py create mode 100644 app/api/services/generate_token.py create mode 100644 app/swagger/paths/GenerateToken.json create mode 100644 app/swagger/schemas/GenerateTokenRequest.json create mode 100644 app/swagger/schemas/GenerateTokenResponse.json diff --git a/app/__init__.py b/app/__init__.py index 7d6c4f2..649400b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,17 +1,21 @@ -from flask import Flask +from flask import Flask, jsonify import os from flask_swagger_ui import get_swaggerui_blueprint from flask_cors import CORS from app.config import Config -from app.api.routes import api +from app.api.routes import api, auth_bp from app.errors import register_error_handlers +from flask_jwt_extended import JWTManager def create_app(): """ Factory function to create a Flask app instance """ app = Flask(__name__) + + # Load configuration app.config.from_object(Config) + jwt = JWTManager(app) CORS(app) @@ -21,11 +25,36 @@ def create_app(): # Register blueprints with /api prefix for the main API routes app.register_blueprint(api, url_prefix='/api') + # Register blueprints with /auth prefix for the authentication routes + app.register_blueprint(auth_bp, url_prefix='/api/Auth') swagger_ui_blueprint = get_swaggerui_blueprint(SWAGGER_URL, API_URL) app.register_blueprint(swagger_ui_blueprint, url_prefix=SWAGGER_URL) + + def jwt_error(message, code=401): + return jsonify({ + "status": "error", + "message": message + }), code + @jwt.unauthorized_loader + def unauthorized_response(callback): + return jwt_error("Unauthorized access") + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return jwt_error("Expired token") + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return jwt_error("Invalid authentication token.", 422) + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return jwt_error("This token has been revoked. Please log in again.") + + # Error Handlers register_error_handlers(app) diff --git a/app/api/middlewares/__init__.py b/app/api/middlewares/__init__.py index c439164..998b960 100644 --- a/app/api/middlewares/__init__.py +++ b/app/api/middlewares/__init__.py @@ -1,3 +1,3 @@ from .verify_api_key import require_api_key from .app_id_checker import require_app_id -from .cors import enforce_json \ No newline at end of file +from .cors import enforce_json diff --git a/app/api/middlewares/cors.py b/app/api/middlewares/cors.py index 7df0844..631aaa8 100644 --- a/app/api/middlewares/cors.py +++ b/app/api/middlewares/cors.py @@ -1,4 +1,5 @@ from flask import request, jsonify +from app.utils.logger import logger def enforce_json(): diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py index 4ebb14a..e039cae 100644 --- a/app/api/routes/__init__.py +++ b/app/api/routes/__init__.py @@ -1 +1,2 @@ from .routes import api +from .authentication import auth_bp diff --git a/app/api/routes/authentication.py b/app/api/routes/authentication.py new file mode 100644 index 0000000..9b0d9e8 --- /dev/null +++ b/app/api/routes/authentication.py @@ -0,0 +1,24 @@ +from flask import Blueprint, request, jsonify +from app.utils.logger import logger +from app.api.middlewares import enforce_json +from app.api.services.generate_token import GenerateTokenService + + +auth_bp = Blueprint("api/Auth", __name__) + +# Enforce json +@auth_bp.before_request +def cors_middleware(): + """Middleware applied globally to all API routes in this blueprint""" + return enforce_json() + +@auth_bp.route('/generate-token', methods=['POST']) +def get_token(): + try: + data = request.get_json() + logger.info(f"GenerateToken request received: {data}") + response = GenerateTokenService.process_request(data) + return response + except Exception as e: + logger.exception("Unhandled exception in /GenerateToken route", exc_info=e) + return jsonify({"message": "Unhandled server error"}), 500 \ No newline at end of file diff --git a/app/api/routes/routes.py b/app/api/routes/routes.py index 1cd4120..6e2a34a 100644 --- a/app/api/routes/routes.py +++ b/app/api/routes/routes.py @@ -14,7 +14,9 @@ from app.api.services import ( CompleteRACcheckService ) from app.utils.logger import logger -from app.api.middlewares import require_api_key, require_app_id, enforce_json +from app.api.middlewares import enforce_json +from flask_jwt_extended import (jwt_required) + @@ -43,10 +45,11 @@ def serve_paths(filename): # RACCheck Endpoint @api.route('/rac-check', methods=['POST']) -@require_api_key -@require_app_id +@jwt_required() def rac_check(): + logger.info("RACCheck request received") try: + logger.info("RACCheck inside try request received") data = request.get_json() response = RACCheckService.process_request(data) return response @@ -56,8 +59,7 @@ def rac_check(): # CompleteRACcheck Endpoint @api.route('/CompleteRACcheck', methods=['POST']) -@require_api_key -@require_app_id +@jwt_required() def complete_rac_check(): try: data = request.get_json() @@ -69,8 +71,7 @@ def complete_rac_check(): # Disbursement Endpoint @api.route('/DisburseLoan', methods=['POST']) -@require_api_key -@require_app_id +@jwt_required() def disbursement(): try: data = request.get_json() @@ -83,8 +84,7 @@ def disbursement(): # CollectLoan Endpoint @api.route('/CollectLoan', methods=['POST']) -@require_api_key -@require_app_id +@jwt_required() def collect_loan(): try: data = request.get_json() @@ -97,8 +97,7 @@ def collect_loan(): # TransactionVerify Endpoint @api.route('/TransactionVerify', methods=['POST']) -@require_api_key -@require_app_id +@jwt_required() def transaction_verify(): try: data = request.get_json() @@ -111,8 +110,7 @@ def transaction_verify(): # PenalCharge Endpoint @api.route('/CollectPenalFee', methods=['POST']) -@require_api_key -@require_app_id +@jwt_required() def penal_charge(): try: data = request.get_json() @@ -126,8 +124,7 @@ def penal_charge(): # RevokeEnableConsent Endpoint @api.route('/RevokeEnableConsent', methods=['POST']) -@require_api_key -@require_app_id +@jwt_required() def revoke_enable_consent(): data = request.get_json() # logger.info(f"RevokeEnableConsent request received: {data}") @@ -136,8 +133,7 @@ def revoke_enable_consent(): # TokenValidation Endpoint @api.route('/TokenValidation', methods=['POST']) -@require_api_key -@require_app_id +@jwt_required() def token_validation(): data = request.get_json() # logger.info(f"TokenValidation request received: {data}") @@ -146,8 +142,7 @@ def token_validation(): # LienCheck Endpoint @api.route('/LienCheck', methods=['POST']) -@require_api_key -@require_app_id +@jwt_required() def lien_check(): data = request.get_json() # logger.info(f"LienCheck request received: {data}") @@ -156,8 +151,7 @@ def lien_check(): # NewTransactionCheck Endpoint @api.route('/NewTransactionCheck', methods=['POST']) -@require_api_key -@require_app_id +@jwt_required() def new_transaction_check(): data = request.get_json() # logger.info(f"NewTransactionCheck request received: {data}") @@ -167,6 +161,7 @@ def new_transaction_check(): # Health Check Endpoint @api.route('/system-health-check', methods=['GET']) +@jwt_required() def health_check(): """Basic system health check""" try: diff --git a/app/api/schemas/generate_token.py b/app/api/schemas/generate_token.py new file mode 100644 index 0000000..336aefb --- /dev/null +++ b/app/api/schemas/generate_token.py @@ -0,0 +1,18 @@ +from marshmallow import Schema, fields + + +class GenerateTokenRequestSchema(Schema): + username = fields.Str(required=True) + password = fields.Str(required=True) + grant_type = fields.Str(required=True) + + +class GenerateTokenResponseSchema(Schema): + access_token = fields.Str(required=True) + token_type = fields.Str(required=True) + expires_in = fields.Int(required=True) + userName = fields.Str(required=False, allow_none=True) + ipaddress = fields.Str(required=False, allow_none=True) + errorMessage = fields.Str(required=False, allow_none=True) + issued = fields.DateTime(required=False, allow_none=True) + expires = fields.DateTime(required=False, allow_none=True) diff --git a/app/api/services/__init__.py b/app/api/services/__init__.py index 93fd3da..d98163f 100644 --- a/app/api/services/__init__.py +++ b/app/api/services/__init__.py @@ -8,3 +8,4 @@ from app.api.services.token_validation import TokenValidationService from app.api.services.lien_check import LienCheckService from app.api.services.new_transaction_check import NewTransactionCheckService from app.api.services.complete_rac_check_service import CompleteRACcheckService +from app.api.services.generate_token import GenerateTokenService diff --git a/app/api/services/generate_token.py b/app/api/services/generate_token.py new file mode 100644 index 0000000..b4ba6dd --- /dev/null +++ b/app/api/services/generate_token.py @@ -0,0 +1,89 @@ +import datetime +from datetime import timedelta +from flask import request, jsonify +from marshmallow import ValidationError +from app.utils.logger import logger +from app.api.helpers.response_helper import ResponseHelper +from app.api.schemas.generate_token import GenerateTokenRequestSchema, GenerateTokenResponseSchema +from app.config import Config +from flask_jwt_extended import ( + create_access_token, +) + + +class GenerateTokenService: + USERNAME = Config.BANK_CALL_BASIC_AUTH_USERNAME + PASSWORD = Config.BANK_CALL_BASIC_AUTH_PASSWORD + TYPE = Config.BANK_GRANT_TYPE + @staticmethod + def process_request(data): + """ + Process the GenerateToken request. + + Args: + data (dict): The request JSON payload. + + Returns: + tuple: (JSON response, status code) + """ + try: + logger.info("Processing GenerateToken request") + + # Step 1: Validate input using schema + schema = GenerateTokenRequestSchema() + validated_data = schema.load(data) + + logger.info(f"Validated data: {validated_data}") + + username = validated_data.get("username") + password = validated_data.get("password") + grant_type = validated_data.get("grant_type") + + if password != GenerateTokenService.PASSWORD or username != GenerateTokenService.USERNAME or grant_type != GenerateTokenService.TYPE: + return { + "message": "Invalid credentials", + "status": 401 + } + + expires_in = 1800 + identity = username + # Step 2: Generate JWT token + access_token = create_access_token(identity=identity, expires_delta=timedelta(seconds=expires_in)) + + # Step 3: Get client IP address + ipaddress = request.remote_addr or "127.0.0.1" + + # Step 4: Build response timestamps + issued_time = datetime.datetime.utcnow() + expires_time = issued_time + datetime.timedelta(seconds=expires_in) + + # Step 5: Construct response payload + response_data = { + "access_token": access_token, + "token_type": "bearer", + "expires_in": expires_in, + "userName": username, + "ipaddress": ipaddress, + "errorMessage": "", + "issued": issued_time, + "expires": expires_time + } + + # Serialize with response schema + response_schema = GenerateTokenResponseSchema() + response_json = response_schema.dump(response_data) + + return jsonify(response_json), 200 + + except ValidationError as err: + logger.error(f"Validation Error: {err.messages}") + return jsonify({ + "message": "Validation exception", + "errors": err.messages + }), 422 + + except Exception as e: + logger.error(f"An error occurred while generating token: {str(e)}", exc_info=True) + return jsonify({ + "message": "Internal Server Error" + }), 500 diff --git a/app/config.py b/app/config.py index 2f91bf7..3f8d12d 100644 --- a/app/config.py +++ b/app/config.py @@ -1,6 +1,7 @@ import os from re import M from dotenv import load_dotenv +from datetime import timedelta class Config: """Base configuration for Flask app""" @@ -9,10 +10,15 @@ class Config: API_URL = '/api/swagger.json' DEBUG = True + JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "753fc155-6a63-4314-bd97-ae91d61dbafe") + JWT_ACCESS_TOKEN_EXPIRES = timedelta(seconds=int(os.getenv("JWT_ACCESS_TOKEN_EXPIRES", 1800))) VALID_APP_ID = os.getenv("VALID_APP_ID", "app1") VALID_API_KEY = os.getenv("VALID_API_KEY", "test-api-key-12345") MIN_AMOUNT_FOR_COLLECTION = int(os.getenv("MIN_AMOUNT_FOR_COLLECTION", 10000)) MAX_AMOUNT_FOR_COLLECTION = int(os.getenv("MAX_AMOUNT_FOR_COLLECTION", 25000)) + BANK_CALL_BASIC_AUTH_USERNAME = os.environ.get("BANK_CALL_BASIC_AUTH_USERNAME", "simbrella") + BANK_CALL_BASIC_AUTH_PASSWORD = os.environ.get("BANK_CALL_BASIC_AUTH_PASSWORD", "G7$k9@pL2!qR") + BANK_GRANT_TYPE = os.getenv("BANK_GRANT_TYPE", "password") # SQLALCHEMY_DATABASE_URI =os.environ.get("DATABASE_URL", "database_url") # SQLALCHEMY_TRACK_MODIFICATIONS = False diff --git a/app/swagger/digifi_swagger.json b/app/swagger/digifi_swagger.json index c9f9524..524d78e 100644 --- a/app/swagger/digifi_swagger.json +++ b/app/swagger/digifi_swagger.json @@ -28,6 +28,15 @@ } ], "tags": [ + { + "name": "Auth", + "description": "Get access token for verification", + "externalDocs": { + "description": "Find out more", + "url": "https://www.simbrellang.net" + } + + }, { "name": "RACCheck", "description": "Risk Acceptance Criteria Request", @@ -110,6 +119,9 @@ } ], "paths": { + "/api/Auth/generate-token": { + "$ref": "swagger/paths/GenerateToken.json" + }, "/api/system-health-check": { "$ref": "swagger/paths/HealthCheck.json" }, @@ -146,6 +158,12 @@ }, "components": { "schemas": { + "GenerateTokenRequest": { + "$ref": "./schemas/GenerateTokenRequest.json" + }, + "GenerateTokenResponse": { + "$ref": "./schemas/GenerateTokenResponse.json" + }, "RACCheckRequest": { "$ref": "./schemas/RACCheckRequest.json" }, @@ -217,24 +235,18 @@ } }, "securitySchemes": { - "api_key": { - "type": "apiKey", - "name": "x-api-key", - "in": "header" - }, - "app_id": { - "type": "apiKey", - "name": "App-Id", - "in": "header" - } - } + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Standard Authorization header using the Bearer scheme. Example: 'Bearer {token}'" + } +} }, - "security": [ - { - "api_key": [] - }, - { - "app_id": [] - } - ] + +"security": [ + { + "BearerAuth": [] + } +] } \ No newline at end of file diff --git a/app/swagger/paths/GenerateToken.json b/app/swagger/paths/GenerateToken.json new file mode 100644 index 0000000..b453fd6 --- /dev/null +++ b/app/swagger/paths/GenerateToken.json @@ -0,0 +1,56 @@ +{ + "post": { + "tags": [ + "GenerateToken" + ], + "summary": "Generate Access Token Request", + "description": "Generate Access Token Request", + "operationId": "GenerateToken", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/GenerateTokenRequest.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/GenerateTokenRequest.json" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "../schemas/GenerateTokenRequest.json" + } + } + } + }, + "responses": { + "200": { + "description": "GenerateToken Successful", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/GenerateTokenResponse.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/GenerateTokenResponse.json" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "422": { + "description": "Validation exception" + }, + "500": { + "description": "Internal server error" + } + } + } + } \ No newline at end of file diff --git a/app/swagger/schemas/GenerateTokenRequest.json b/app/swagger/schemas/GenerateTokenRequest.json new file mode 100644 index 0000000..43f508e --- /dev/null +++ b/app/swagger/schemas/GenerateTokenRequest.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "grant_type":{ + "type":"string" + + } + + }, + "required": [ + "username", + "password", + "grant_type" + ] +} \ No newline at end of file diff --git a/app/swagger/schemas/GenerateTokenResponse.json b/app/swagger/schemas/GenerateTokenResponse.json new file mode 100644 index 0000000..ea220f8 --- /dev/null +++ b/app/swagger/schemas/GenerateTokenResponse.json @@ -0,0 +1,41 @@ +{ + "type": "object", + "properties": { + "access_token": { + "type": "string", + "format": "eyjhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwic3ViIjoiYWRtaW4ifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + }, + "token_type": { + "type": "string", + "format": "bearer" + }, + "expires_in": { + "type": "integer", + "format": "1800" + }, + "userName": { + "type": "string", + "format": "sinbrella" + }, + "ipaddress": { + "type": "string", + "format": "127.0.0.1" + }, + "errorMessage":{ + "type":"string" + + }, + "issued": { + "type": "string", + "format":"date-time" + + }, + "expires": { + "type": "string", + "format": "date-time" + } + }, + "xml": { + "name": "##default" + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index bb8f998..4766f6e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,8 @@ Flask-Cors==3.0.10 gunicorn flask-swagger-ui python-dotenv +flask-jwt-extended==4.7.1 +