Repository: faucetsdn/chewie

Issue:
De-authenticate successful auths after period
period since last successful auth.
Renew/Expire authentications
After X time log supplicant off (notify logoff handler).

Discussion:

Patch:
diff --git a/chewie/chewie.py b/chewie/chewie.py
index 8144daf..9fa2e46 100644
--- a/chewie/chewie.py
+++ b/chewie/chewie.py
@@ -239,7 +239,7 @@ class Chewie:
                 eap_msg = eap_msg.data_type.data()
                 state = radius.attributes.find(State.DESCRIPTION)
                 self.logger.info("radius EAP: %s", eap_msg)
-                event = EventRadiusMessageReceived(eap_msg, state)
+                event = EventRadiusMessageReceived(eap_msg, state, radius.attributes.to_dict())
                 sm.event(event)
         except Exception as e:
             self.logger.exception(e)
diff --git a/chewie/eap_state_machine.py b/chewie/eap_state_machine.py
index 4d30df8..8dd3487 100644
--- a/chewie/eap_state_machine.py
+++ b/chewie/eap_state_machine.py
@@ -3,9 +3,10 @@ import random
 
 from chewie.eap import Eap
 from chewie.event import EventMessageReceived, EventRadiusMessageReceived, EventTimerExpired, \
-    EventPortStatusChange
+    EventPortStatusChange, EventSessionTimeout
 from chewie.message_parser import SuccessMessage, FailureMessage, EapolStartMessage, \
     IdentityMessage, EapolLogoffMessage
+from chewie.radius_attributes import SessionTimeout
 from chewie.utils import get_logger, log_method
 
 
@@ -99,6 +100,9 @@ class FullEAPStateMachine:
     """
 
     # non RFC 4137 variables/CONSTANTs
+    DEFAULT_TIMEOUT = 5  # Number of Seconds
+    DEFAULT_SESSION_TIMEOUT = 3600  # Number of Seconds
+
     currentState = None
     eap_output_messages = None
     src_mac = None
@@ -106,8 +110,9 @@ class FullEAPStateMachine:
     port_id_mac = None
     radius_state_attribute = None  # the last state from radius server
     sent_count = 0
+    session_timeout_job = None
 
-    DEFAULT_TIMEOUT = 5  # Number of Seconds
+    session_timeout = DEFAULT_SESSION_TIMEOUT
 
     NO_STATE = "NO_STATE"
     DISABLED = "DISABLED"
@@ -519,7 +524,7 @@ class FullEAPStateMachine:
                 # RFC 4137 says do nothing from success(2), but we're adding a logoff state.
                 # hopefully it will work as intended.
                 # Otherwise allow transition to logoff from all states.
-                if self.eapLogoff:
+                if self.logoff:
                     self.logoff_state()
                     self.currentState = FullEAPStateMachine.LOGOFF
 
@@ -685,6 +690,8 @@ class FullEAPStateMachine:
         self.eapFail = False
         self.eapTimeout = False
 
+        self.logoff = False
+
         self.aaaEapResp = False
 
     def event(self, event):
@@ -706,6 +713,8 @@ class FullEAPStateMachine:
 
         elif isinstance(event, EventPortStatusChange):
             self.port_status_event_received(event)
+        elif isinstance(event, EventSessionTimeout):
+            self.session_timeout_event_received()
 
         self.handle_message_received()
         self.logger.info('end state: %s', self.currentState)
@@ -733,17 +742,38 @@ class FullEAPStateMachine:
             self.logger.error("aaaEapResp is true. but data is false. This should never happen")
 
         if self.eapSuccess:
-            self.logger.info('Yay authentication successful %s %s',
-                             self.src_mac, self.aaaIdentity.identity)
-            self.auth_handler(self.src_mac, str(self.port_id_mac))
+            self.handle_success()
 
         if self.eapFail:
             self.logger.info('oh authentication not successful %s', self.src_mac)
             self.failure_handler(self.src_mac, str(self.port_id_mac))
 
         if self.eapLogoff:
-            self.logger.info('client is logging off %s', self.src_mac)
-            self.logoff_handler(self.src_mac, str(self.port_id_mac))
+            self.handle_logoff()
+
+    def handle_logoff(self):
+        """Notify the logoff callback"""
+        self.logger.info('client is logging off %s', self.src_mac)
+        self.logoff_handler(self.src_mac, str(self.port_id_mac))
+        if self.session_timeout_job:
+            self.session_timeout_job.cancel()
+
+    def handle_success(self):
+        """Notify the success callback and sets a timer event to expire this session"""
+        self.logger.info('Yay authentication successful %s %s',
+                         self.src_mac, self.aaaIdentity.identity)
+        self.auth_handler(self.src_mac, str(self.port_id_mac))
+        # new authentication so cancel the old session timeout event
+        if self.session_timeout_job:
+            self.session_timeout_job.cancel()
+
+        self.session_timeout_job = self.timer_scheduler.call_later(self.session_timeout,
+                                                                   self.event,
+                                                                   EventSessionTimeout(self))
+
+    def session_timeout_event_received(self):
+        """process session timeout event"""
+        self.logoff = True
 
     def port_status_event_received(self, event):
         """Sets variables for the port status change (link up/down) being received.
@@ -782,40 +812,49 @@ class FullEAPStateMachine:
         self.logger.info('type: %s, message %s ', type(event.message), event.message)
         if event.port_id:
             self.port_id_mac = event.port_id
-        self.logoff = False
+
         if isinstance(event.message, EapolStartMessage):
             self.eapRestart = True
         elif isinstance(event.message, EapolLogoffMessage):
             self.logoff = True
-        if not isinstance(event, EventRadiusMessageReceived):
-            self.eapRespData = event.message  # pylint: disable=invalid-name
-            self.eapResp = True
-        else:
-            self.eapRespData = None
-            self.eapResp = False
-        self.eapReq = False
-        self.eapNoReq = False
-        self.eapSuccess = False
-        self.eapFail = False
-        self.aaaEapNoReq = False
-        self.aaaSuccess = False
-        self.aaaFail = False
-        self.aaaEapKeyAvailable = False  # pylint: disable=invalid-name
-        self.aaaEapResp = False
-        self.eapLogoff = False
+
         if isinstance(event, EventRadiusMessageReceived):
-            self.radius_state_attribute = event.state
-            self.aaaEapReq = True
-            self.aaaEapReqData = event.message  # pylint: disable=invalid-name
-            self.logger.info('sm ev.msg: %s', self.aaaEapReqData)
-            if isinstance(self.aaaEapReqData, SuccessMessage):
-                self.logger.info("aaaSuccess")
-                self.aaaSuccess = True
-            if isinstance(self.aaaEapReqData, FailureMessage):
-                self.logger.info("aaaFail")
-                self.aaaFail = True
+            self.process_radius_message(event)
         else:
-            self.aaaEapReq = False
+            self.eapRespData = event.message  # pylint: disable=invalid-name
+            self.eapResp = True
+
+    def process_radius_message(self, event):
+        """Process radius message (set and extract radius specific variables)"""
+        self.eapRespData = None
+        self.eapResp = False
+        self.logger.info('radius attributes %s', event.attributes)
+        self.radius_state_attribute = event.state
+        self.aaaEapReq = True
+        self.aaaEapReqData = event.message  # pylint: disable=invalid-name
+        self.logger.info('sm ev.msg: %s', self.aaaEapReqData)
+        if isinstance(self.aaaEapReqData, SuccessMessage):
+            self.logger.info("aaaSuccess")
+            self.aaaSuccess = True
+        if isinstance(self.aaaEapReqData, FailureMessage):
+            self.logger.info("aaaFail")
+            self.aaaFail = True
+        self.logger.info('radius event %s', event.__dict__)
+        self.set_vars_from_radius(event.attributes)
+
+    def set_vars_from_radius(self, attributes):
+        """
+        Set the statemachine variables from attributes received in the radius message.
+        If variable does not exist in the radius message, it is reset to the default
+        Args:
+            attributes (dict):  attributes to be set.
+        """
+        self.session_timeout = self.DEFAULT_SESSION_TIMEOUT
+
+        if attributes:
+            self.session_timeout = attributes.get(SessionTimeout.DESCRIPTION,
+                                                  self.DEFAULT_SESSION_TIMEOUT)
+        # TODO could also set filter-id/vlans/acls here.
 
     def set_timer(self):
         """Sets a timer to trigger a retransmit if no packet received.
diff --git a/chewie/event.py b/chewie/event.py
index 3512a96..e93e1df 100644
--- a/chewie/event.py
+++ b/chewie/event.py
@@ -26,6 +26,17 @@ class EventTimerExpired(Event):
         self.sent_count = sent_count
 
 
+class EventSessionTimeout(Event):
+    """User's session should be terminated."""
+    def __init__(self, state_machine=None):
+        """
+        Args:
+            state_machine: state machine that triggered the event
+        """
+        self.type = self.TIMER_EXPIRED
+        self.state_machine = state_machine
+
+
 class EventMessageReceived(Event):
     """Message (EAP) Received. Radius Message event is a child"""
     def __init__(self, message, port_id):
@@ -58,7 +69,7 @@ class EventPortStatusChange(Event):
 class EventRadiusMessageReceived(EventMessageReceived):
     """Radius Message Received."""
 
-    def __init__(self, message, state):
+    def __init__(self, message, state, attributes=None):
         """
         Args:
             message:
@@ -66,6 +77,7 @@ class EventRadiusMessageReceived(EventMessageReceived):
         """
         super().__init__(message, None)
         self.state = state
+        self.attributes = attributes
 
 
 class EventShutdown(Event):
diff --git a/chewie/radius.py b/chewie/radius.py
index 26cfba7..ce09660 100644
--- a/chewie/radius.py
+++ b/chewie/radius.py
@@ -188,7 +188,7 @@ class RadiusAccessChallenge(RadiusPacket):
     CODE = Radius.ACCESS_CHALLENGE
 
 
-class RadiusAttributesList():
+class RadiusAttributesList:
     """Container class for the Radius Attribute Value Pairs"""
 
     def __init__(self, attributes):
@@ -309,3 +309,9 @@ class RadiusAttributesList():
         for attr in self.attributes:
             packed_attributes += attr.pack()
         return packed_attributes
+
+    def to_dict(self):
+        ret = {}
+        for a in self.attributes:
+            ret[a.DESCRIPTION] = a.data_type.data()
+        return ret
diff --git a/chewie/radius_attributes.py b/chewie/radius_attributes.py
index dbbf4a9..c0020c0 100644
--- a/chewie/radius_attributes.py
+++ b/chewie/radius_attributes.py
@@ -115,6 +115,14 @@ class VendorSpecific(Attribute):
     DESCRIPTION = "Vendor-Specific"
 
 
+@register_attribute_type
+class SessionTimeout(Attribute):
+    """Vendor-Specific https://tools.ietf.org/html/rfc2865#section-5.27"""
+    TYPE = 27
+    DATA_TYPE = Integer
+    DESCRIPTION = "Session-Timeout"
+
+
 @register_attribute_type
 class CalledStationId(Attribute):
     """Called-Station-Id https://tools.ietf.org/html/rfc2865#section-5.30"""
diff --git a/chewie/timer_scheduler.py b/chewie/timer_scheduler.py
index 80a3650..3150444 100644
--- a/chewie/timer_scheduler.py
+++ b/chewie/timer_scheduler.py
@@ -5,6 +5,38 @@ import time
 import eventlet
 
 
+class TimerJob:
+    """Represents a job for TimerScheduler, same api as asyncio.TimerHandle"""
+
+    expiry_time = 0
+    is_cancelled = False
+    func = None
+    args = None
+
+    def __init__(self, expiry_time, func, args):
+        self.expiry_time = expiry_time
+        self.func = func
+        self.args = args
+
+    def cancel(self):
+        """Cancel the callback."""
+        self.is_cancelled = True
+
+    def cancelled(self):
+        """
+        Returns:
+            True if callback was cancelled
+        """
+        return self.is_cancelled
+
+    def when(self):
+        """
+        Returns:
+            scheduled callback time as float seconds
+        """
+        return self.expiry_time
+
+
 class TimerScheduler:
     """wraps a heapbased queue with a similar api to asyncio.loop"""
 
@@ -29,8 +61,10 @@ class TimerScheduler:
         """
         if not args:
             args = []
-        job = (time.time() + timeout, {'func': func, 'args': args, 'alive': True})
-        heapq.heappush(self.timer_heap, job)
+        expiry_time = time.time() + timeout
+
+        job = TimerJob(expiry_time, func, args)
+        heapq.heappush(self.timer_heap, (expiry_time, job))
         return job
 
     def run(self):
@@ -40,11 +74,11 @@ class TimerScheduler:
                 if self.timer_heap:
                     if self.timer_heap[0][0] < time.time():
                         _, job = heapq.heappop(self.timer_heap)
-                        if job['alive']:
-                            self.logger.info('running job %s', job['func'].__name__)
-                            job['func'](*job['args'])
+                        if not job.cancelled():
+                            self.logger.info('running job %s %s', job.func.__name__, job.args)
+                            job.func(*job.args)
                         else:
-                            self.logger.info('job %s has been cancelled', job['func'].__name__)
+                            self.logger.info('job %s has been cancelled', job.func.__name__)
                     else:
                         self.sleep(1)
                 else:


Test patch:
diff --git a/test/test_full_state_machine.py b/test/test_full_state_machine.py
index 294a6e7..9b65b13 100644
--- a/test/test_full_state_machine.py
+++ b/test/test_full_state_machine.py
@@ -21,6 +21,32 @@ from chewie.event import EventMessageReceived, EventRadiusMessageReceived, Event
 from chewie.utils import get_logger
 
 
+def check_counters(_func=None, *,
+                   expected_auth_counter=0, expected_failure_counter=0, expected_logoff_counter=0):
+    """Decorator to check the handlers have been called the
+     correct number of times at the end of each test"""
+    def decorator_check_counters(func):
+        def wrapper(self):
+
+            start_auth_counter = self.auth_counter
+            start_failure_counter = self.failure_counter
+            start_logoff_counter = self.logoff_counter
+            ret = func(self)
+            self.assertEqual(self.auth_counter,
+                             start_auth_counter + expected_auth_counter)
+            self.assertEqual(self.failure_counter,
+                             start_failure_counter + expected_failure_counter)
+            self.assertEqual(self.logoff_counter,
+                             start_logoff_counter + expected_logoff_counter)
+            return ret
+
+        return wrapper
+    if _func is None:
+        return decorator_check_counters
+    else:
+        return decorator_check_counters(_func)
+
+
 class FullStateMachineStartTestCase(unittest.TestCase):
     # TODO tests could be more thorough, and test that
     # the correct packet (type/content) has been put in its respective queue.
@@ -63,26 +89,27 @@ class FullStateMachineStartTestCase(unittest.TestCase):
             if len(self.timer_scheduler.timer_heap):
                 if self.timer_scheduler.timer_heap[0][0] < time.time():
                     _, job = heapq.heappop(self.timer_scheduler.timer_heap)
-                    if job['alive']:
-                        job['func'](*(job['args']))
+                    if not job.cancelled():
+                        job.func(*job.args)
                 else:
                     time.sleep(1)
             else:
                 time.sleep(1)
                 return
 
-    def auth_handler(self, client_mac, port_id_mac):
+    def auth_handler(self, client_mac, port_id_mac):  # pylint: disable=unused-argument
         self.auth_counter += 1
         print('Successful auth from MAC %s' % str(client_mac))
 
-    def failure_handler(self, client_mac, port_id_mac):
+    def failure_handler(self, client_mac, port_id_mac):  # pylint: disable=unused-argument
         self.failure_counter += 1
         print('failure from MAC %s' % str(client_mac))
 
-    def logoff_handler(self, client_mac, port_id_mac):
+    def logoff_handler(self, client_mac, port_id_mac):  # pylint: disable=unused-argument
         self.logoff_counter += 1
         print('logoff from MAC %s' % str(client_mac))
 
+    @check_counters
     def test_eap_start(self):
         # input EAPStart packet.
         # output EAPIdentityRequest on eap_output_q
@@ -95,11 +122,9 @@ class FullStateMachineStartTestCase(unittest.TestCase):
         self.assertIsInstance(output, IdentityMessage)
         self.assertEqual(self.radius_output_queue.qsize(), 0)
 
-        self.assertEqual(self.auth_counter, 0)
-        self.assertEqual(self.failure_counter, 0)
-        self.assertEqual(self.logoff_counter, 0)
         return output  # Used by test_identity_response
 
+    @check_counters
     def test_eap_restart(self):
         self.test_eap_start()
         message = EapolStartMessage(self.src_mac)
@@ -111,10 +136,7 @@ class FullStateMachineStartTestCase(unittest.TestCase):
         self.assertIsInstance(output, IdentityMessage)
         self.assertEqual(self.radius_output_queue.qsize(), 0)
 
-        self.assertEqual(self.auth_counter, 0)
-        self.assertEqual(self.failure_counter, 0)
-        self.assertEqual(self.logoff_counter, 0)
-
+    @check_counters
     def test_timeout_failure_from_max_retransmits(self):
         """Go to timeout failure from exceeding max retransmits (also tests retransmitting)"""
         output0 = self.test_eap_start()
@@ -134,10 +156,7 @@ class FullStateMachineStartTestCase(unittest.TestCase):
         self.assertEqual(self.eap_output_queue.qsize(), 0)
         self.assertEqual(old_radius_count, self.radius_output_queue.qsize())
 
-        self.assertEqual(self.auth_counter, 0)
-        self.assertEqual(self.failure_counter, 0)
-        self.assertEqual(self.logoff_counter, 0)
-
+    @check_counters
     def test_timeout_failure2_from_aaa_timeout(self):
         """no response from AAA server equals timeout_failure2"""
         self.test_md5_challenge_response()
@@ -150,10 +169,7 @@ class FullStateMachineStartTestCase(unittest.TestCase):
         self.assertEqual(old_eap_count, self.eap_output_queue.qsize())
         self.assertEqual(old_radius_count, self.radius_output_queue.qsize())
 
-        self.assertEqual(self.auth_counter, 0)
-        self.assertEqual(self.failure_counter, 0)
-        self.assertEqual(self.logoff_counter, 0)
-
+    @check_counters
     def test_timeout_failure2_from_max_retransmits(self):
         """If client does not respond when in passthrough mode,
          send again and again until max retransmit counter is reached."""
@@ -167,10 +183,7 @@ class FullStateMachineStartTestCase(unittest.TestCase):
         self.assertEqual(self.MAX_RETRANSMITS, self.eap_output_queue.qsize())
         self.assertEqual(old_radius_count, self.radius_output_queue.qsize())
 
-        self.assertEqual(self.auth_counter, 0)
-        self.assertEqual(self.failure_counter, 0)
-        self.assertEqual(self.logoff_counter, 0)
-
+    @check_counters(expected_auth_counter=1)
     def test_disabled_state(self):
         """move to disabled and then from disabled"""
         self.test_success2()
@@ -196,10 +209,7 @@ class FullStateMachineStartTestCase(unittest.TestCase):
         self.assertEqual(self.eap_output_queue.qsize(), 1)
         self.assertEqual(self.radius_output_queue.qsize(), 0)
 
-        self.assertEqual(self.auth_counter, 1)
-        self.assertEqual(self.failure_counter, 0)
-        self.assertEqual(self.logoff_counter, 0)
-
+    @check_counters
     def test_identity_response(self):
         _id = self.test_eap_start().message_id
         # input EapIdentityResponse
@@ -212,10 +222,7 @@ class FullStateMachineStartTestCase(unittest.TestCase):
         self.assertEqual(self.radius_output_queue.qsize(), 1)
         self.assertIsInstance(self.radius_output_queue.get_nowait()[0], IdentityMessage)
 
-        self.assertEqual(self.auth_counter, 0)
-        self.assertEqual(self.failure_counter, 0)
-        self.assertEqual(self.logoff_counter, 0)
-
+    @check_counters
     def test_md5_challenge_request(self):
         self.test_identity_response()
 
@@ -232,10 +239,7 @@ class FullStateMachineStartTestCase(unittest.TestCase):
         self.assertEqual(self.radius_output_queue.qsize(), 0)
         return output
 
-        self.assertEqual(self.auth_counter, 0)
-        self.assertEqual(self.failure_counter, 0)
-        self.assertEqual(self.logoff_counter, 0)
-
+    @check_counters
     def test_md5_challenge_response(self):
         self.test_md5_challenge_request()
 
@@ -248,10 +252,7 @@ class FullStateMachineStartTestCase(unittest.TestCase):
         self.assertEqual(self.radius_output_queue.qsize(), 1)
         self.assertIsInstance(self.radius_output_queue.get_nowait()[0], Md5ChallengeMessage)
 
-        self.assertEqual(self.auth_counter, 0)
-        self.assertEqual(self.failure_counter, 0)
-        self.assertEqual(self.logoff_counter, 0)
-
+    @check_counters(expected_auth_counter=1)
     def test_success2(self):
         self.test_md5_challenge_response()
 
@@ -263,31 +264,40 @@ class FullStateMachineStartTestCase(unittest.TestCase):
         self.assertIsInstance(self.eap_output_queue.get_nowait()[0], SuccessMessage)
         self.assertEqual(self.radius_output_queue.qsize(), 0)
 
-        self.assertEqual(self.auth_counter, 1)
-        self.assertEqual(self.failure_counter, 0)
-        self.assertEqual(self.logoff_counter, 0)
+    @check_counters(expected_auth_counter=2)
+    def test_two_success2(self):
+        self.test_success2()
+
+        expiry_job = self.sm.session_timeout_job
+        self.assertFalse(expiry_job.cancelled())
+
+        self.test_success2()
+        self.assertTrue(expiry_job.cancelled())
+
+        expiry_job = self.sm.session_timeout_job
+        self.assertFalse(expiry_job.cancelled())
 
+    @check_counters(expected_auth_counter=2, expected_logoff_counter=1)
     def test_logoff2(self):
         """Test logoff from success2 state."""
         self.test_success2()
 
+        # test the second logon expires the firsts session timeout event.
+        expiry_job = self.sm.session_timeout_job
+        self.assertFalse(expiry_job.cancelled())
+
         message = EapolLogoffMessage(self.src_mac)
         self.sm.event(EventRadiusMessageReceived(message, None))
 
+        self.assertTrue(expiry_job.cancelled())
+
         self.assertEqual(self.sm.currentState, self.sm.LOGOFF2)
         self.assertEqual(self.eap_output_queue.qsize(), 0)
         self.assertEqual(self.radius_output_queue.qsize(), 0)
 
-        self.assertEqual(self.auth_counter, 1)
-        self.assertEqual(self.failure_counter, 0)
-        self.assertEqual(self.logoff_counter, 1)
-
-        self.auth_counter = 0
-        self.failure_counter = 0
-        self.logoff_counter = 0
-
         self.test_success2()
 
+    @check_counters
     def test_logoff_from_idle2(self):
         """Test logoff from middle of authentication. should be ignored"""
         self.test_md5_challenge_request()
@@ -300,10 +310,7 @@ class FullStateMachineStartTestCase(unittest.TestCase):
         self.assertEqual(self.eap_output_queue.qsize(), 0)
         self.assertEqual(self.radius_output_queue.qsize(), 0)
 
-        self.assertEqual(self.auth_counter, 0)
-        self.assertEqual(self.failure_counter, 0)
-        self.assertEqual(self.logoff_counter, 0)
-
+    @check_counters(expected_failure_counter=1)
     def test_failure2(self):
         self.test_md5_challenge_response()
         message = FailureMessage(self.src_mac, 3)
@@ -314,10 +321,7 @@ class FullStateMachineStartTestCase(unittest.TestCase):
         self.assertIsInstance(self.eap_output_queue.get_nowait()[0], FailureMessage)
         self.assertEqual(self.radius_output_queue.qsize(), 0)
 
-        self.assertEqual(self.auth_counter, 0)
-        self.assertEqual(self.failure_counter, 1)
-        self.assertEqual(self.logoff_counter, 0)
-
+    @check_counters
     def test_discard2(self):
         request = self.test_md5_challenge_request()
 
@@ -329,10 +333,7 @@ class FullStateMachineStartTestCase(unittest.TestCase):
         self.assertEqual(self.eap_output_queue.qsize(), 0)
         self.assertEqual(self.radius_output_queue.qsize(), 0)
 
-        self.assertEqual(self.auth_counter, 0)
-        self.assertEqual(self.failure_counter, 0)
-        self.assertEqual(self.logoff_counter, 0)
-
+    @check_counters
     def test_discard(self):
         message = self.test_eap_start()
         # Make a message that will be discarded (id here is not sequential)
@@ -343,10 +344,7 @@ class FullStateMachineStartTestCase(unittest.TestCase):
         self.assertEqual(self.eap_output_queue.qsize(), 0)
         self.assertEqual(self.radius_output_queue.qsize(), 0)
 
-        self.assertEqual(self.auth_counter, 0)
-        self.assertEqual(self.failure_counter, 0)
-        self.assertEqual(self.logoff_counter, 0)
-
+    @check_counters
     def test_ttls_request(self):
         self.test_md5_challenge_request()
         self.assertEqual(self.eap_output_queue.qsize(), 0)
@@ -378,6 +376,11 @@ class FullStateMachineStartTestCase(unittest.TestCase):
         self.assertEqual(self.eap_output_queue.qsize(), 2)
         self.assertEqual(self.radius_output_queue.qsize(), 2)
 
-        self.assertEqual(self.auth_counter, 0)
-        self.assertEqual(self.failure_counter, 0)
-        self.assertEqual(self.logoff_counter, 0)
+    @check_counters(expected_auth_counter=1, expected_logoff_counter=1)
+    def test_deauth_timer(self):
+        self.sm.DEFAULT_SESSION_TIMEOUT = 2
+        self.test_success2()
+
+        self.run_scheduler()
+
+        self.assertEqual(self.sm.currentState, self.sm.LOGOFF2)