flask-app-class 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ from flask_app_class.flask_app import FlaskApp, render_template
@@ -0,0 +1,12 @@
1
+ import sys
2
+ import argparse
3
+ from logging_handler import DEBUG, INFO, WARNING, ERROR, CRITICAL
4
+ from flask_app_class import FlaskApp
5
+
6
+ parser = argparse.ArgumentParser(description="Flask Class Based application framework.")
7
+ parser.add_argument("--config", type=str, default=None, help='Enter a JSON configuration file to load.')
8
+ parser.add_argument("--log_level", type=str, default='DEBUG', help='Enter a logging level ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")')
9
+ args = parser.parse_args()
10
+
11
+ app = FlaskApp(config_file=args.config, web_log_level=args.log_level, app_log_level=args.log_level)
12
+ app.start()
@@ -0,0 +1,655 @@
1
+ #!/usr/bin/env python
2
+ '''
3
+ Basic RADIUS authentication. Minimum necessary to be able to authenticate a
4
+ user with or without challenge/response, yet remain RFC2865 compliant (I hope).
5
+
6
+ Homepage at http://github.com/btimby/py-radius/
7
+ '''
8
+
9
+ # Copyright (c) 1999, Stuart Bishop <zen@shangri-la.dropbear.id.au>
10
+ # All rights reserved.
11
+ #
12
+ # Redistribution and use in source and binary forms, with or without
13
+ # modification, are permitted provided that the following conditions are
14
+ # met:
15
+ #
16
+ # Redistributions of source code must retain the above copyright
17
+ # notice, this list of conditions and the following disclaimer.
18
+ #
19
+ # Redistributions in binary form must reproduce the above copyright
20
+ # notice, this list of conditions and the following disclaimer in the
21
+ # documentation and/or other materials provided with the
22
+ # distribution.
23
+ #
24
+ # The name of Stuart Bishop may not be used to endorse or promote
25
+ # products derived from this software without specific prior written
26
+ # permission.
27
+ #
28
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
29
+ # ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
30
+ # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
31
+ # PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR
32
+ # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
33
+ # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
34
+ # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
35
+ # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
36
+ # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
37
+ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
38
+ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
39
+
40
+ import os
41
+ import socket
42
+ import logging
43
+ import struct
44
+
45
+ from select import select
46
+ from random import randint
47
+ from contextlib import closing, contextmanager
48
+
49
+ try:
50
+ from collections import UserDict
51
+ except ImportError:
52
+ from UserDict import UserDict
53
+
54
+ try:
55
+ from hashlib import md5
56
+ except ImportError:
57
+ from md5 import new as md5
58
+
59
+ from six import PY3
60
+
61
+
62
+ __version__ = '2.0.2'
63
+
64
+ LOGGER = logging.getLogger(__name__)
65
+ LOGGER.addHandler(logging.NullHandler())
66
+
67
+ # Networking constants.
68
+ # -------------------------------
69
+ PACKET_MAX = 4096
70
+ DEFAULT_PORT = 1812
71
+ DEFAULT_RETRIES = 3
72
+ DEFAULT_TIMEOUT = 5
73
+ # -------------------------------
74
+
75
+ # Protocol specific constants.
76
+ # -------------------------------
77
+ # Codes indicating packet type.
78
+ CODE_ACCESS_REQUEST = 1
79
+ CODE_ACCESS_ACCEPT = 2
80
+ CODE_ACCESS_REJECT = 3
81
+ CODE_ACCOUNTING_REQUEST = 4
82
+ CODE_ACCOUNTING_RESPONSE = 5
83
+ CODE_ACCESS_CHALLENGE = 11
84
+ CODE_STATUS_SERVER = 12
85
+ CODE_STATUS_CLIENT = 13
86
+ # CODE_RESERVED = 255
87
+
88
+ # Map from name to id.
89
+ CODES = {
90
+ CODE_ACCESS_REQUEST: 'Access-Request',
91
+ CODE_ACCESS_ACCEPT: 'Access-Accept',
92
+ CODE_ACCESS_REJECT: 'Access-Reject',
93
+ CODE_ACCOUNTING_REQUEST: 'Accounting-Request',
94
+ CODE_ACCOUNTING_RESPONSE: 'Accounting-Response',
95
+ CODE_ACCESS_CHALLENGE: 'Access-Challenge',
96
+ CODE_STATUS_SERVER: 'Status-Server',
97
+ CODE_STATUS_CLIENT: 'Status-Client',
98
+ }
99
+
100
+ CODE_NAMES = {v.lower(): k for k, v in CODES.items()}
101
+
102
+ # Attributes that can be part of the RADIUS payload.
103
+ ATTR_USER_NAME = 1
104
+ ATTR_USER_PASSWORD = 2
105
+ ATTR_CHAP_PASSWORD = 4
106
+ ATTR_NAS_IP_ADDRESS = 4
107
+ ATTR_NAS_PORT = 5
108
+ ATTR_SERVICE_TYPE = 6
109
+ ATTR_FRAMED_PROTOCOL = 7
110
+ ATTR_FRAMED_IP_ADDRESS = 8
111
+ ATTR_FRAMED_IP_NETMASK = 9
112
+ ATTR_FRAMED_ROUTING = 10
113
+ ATTR_FILTER_ID = 11
114
+ ATTR_FRAMED_MTU = 12
115
+ ATTR_FRAMED_COMPRESSION = 13
116
+ ATTR_LOGIN_IP_HOST = 14
117
+ ATTR_LOGIN_SERVICE = 15
118
+ ATTR_LOGIN_TCP_PORT = 16
119
+ # ATTR_UNASSIGNED = 17
120
+ ATTR_REPLY_MESSAGE = 18
121
+ ATTR_CALLBACK_NUMBER = 19
122
+ ATTR_CALLBACK_ID = 20
123
+ # ATTR_UNASSIGNED = 21
124
+ ATTR_FRAMED_ROUTE = 22
125
+ ATTR_FRAMED_IPX_NETWORK = 23
126
+ ATTR_STATE = 24
127
+ ATTR_CLASS = 25
128
+ ATTR_VENDOR_SPECIFIC = 26
129
+ ATTR_SESSION_TIMEOUT = 27
130
+ ATTR_IDLE_TIMEOUT = 28
131
+ ATTR_TERMINATION_ACTION = 29
132
+ ATTR_CALLED_STATION_ID = 30
133
+ ATTR_CALLING_STATION_ID = 31
134
+ ATTR_NAS_IDENTIFIER = 32
135
+ ATTR_PROXY_STATE = 33
136
+ ATTR_LOGIN_LAT_SERVICE = 34
137
+ ATTR_LOGIN_LAT_NODE = 35
138
+ ATTR_LOGIN_LAT_GROUP = 36
139
+ ATTR_FRAMED_APPLETALK_LINK = 37
140
+ ATTR_FRAMED_APPLETALK_NETWORK = 38
141
+ ATTR_FRAMED_APPLETALK_ZONE = 39
142
+ # ATTR_RESERVED = 40-59
143
+ ATTR_CHAP_CHALLENGE = 60
144
+ ATTR_NAS_PORT_TYPE = 61
145
+ ATTR_PORT_LIMIT = 62
146
+ ATTR_LOGIN_LAT_PORT = 63
147
+
148
+ # ADDED - tdunteman
149
+ ATTR_TUNNEL_TYPE = 64
150
+ ATTR_TUNNEL_MEDIUM_TYPE = 65
151
+ ATTR_TUNNEL_PRIVATE_GROUP_ID = 81
152
+ # END ADDED
153
+
154
+ ATTRS = {
155
+ ATTR_USER_NAME: 'User-Name',
156
+ ATTR_USER_PASSWORD: 'User-Password',
157
+ ATTR_CHAP_PASSWORD: 'CHAP-Password',
158
+ ATTR_NAS_IP_ADDRESS: 'NAS-IP-Address',
159
+ ATTR_NAS_PORT: 'NAS-Port',
160
+ ATTR_SERVICE_TYPE: 'Service-Type',
161
+ ATTR_FRAMED_PROTOCOL: 'Framed-Protocol',
162
+ ATTR_FRAMED_IP_ADDRESS: 'Framed-IP-Address',
163
+ ATTR_FRAMED_IP_NETMASK: 'Framed-IP-NetMask',
164
+ ATTR_FRAMED_ROUTING: 'Framed-Routing',
165
+ ATTR_FILTER_ID: 'Filter-Id',
166
+ ATTR_FRAMED_MTU: 'Framed-MTU',
167
+ ATTR_FRAMED_COMPRESSION: 'Framed-Compression',
168
+ ATTR_LOGIN_IP_HOST: 'Login-IP-Host',
169
+ ATTR_LOGIN_SERVICE: 'Login-Service',
170
+ ATTR_LOGIN_TCP_PORT: 'Login-TCP-Port',
171
+ ATTR_REPLY_MESSAGE: 'Reply-Message',
172
+ ATTR_CALLBACK_NUMBER: 'Callback-Number',
173
+ ATTR_CALLBACK_ID: 'Callback-Id',
174
+ ATTR_FRAMED_ROUTE: 'Framed-Route',
175
+ ATTR_FRAMED_IPX_NETWORK: 'Framed-IPX-Network',
176
+ ATTR_STATE: 'State',
177
+ ATTR_CLASS: 'Class',
178
+ ATTR_VENDOR_SPECIFIC: 'Vendor-Specific',
179
+ ATTR_SESSION_TIMEOUT: 'Session-Timeout',
180
+ ATTR_IDLE_TIMEOUT: 'Idle-Timeout',
181
+ ATTR_TERMINATION_ACTION: 'Termination-Action',
182
+ ATTR_CALLED_STATION_ID: 'Called-Station-Id',
183
+ ATTR_CALLING_STATION_ID: 'Calling-Station-Id',
184
+ ATTR_NAS_IDENTIFIER: 'NAS-Identifier',
185
+ ATTR_PROXY_STATE: 'Proxy-State',
186
+ ATTR_LOGIN_LAT_SERVICE: 'Login-LAT-Service',
187
+ ATTR_LOGIN_LAT_NODE: 'Login-LAT-Node',
188
+ ATTR_LOGIN_LAT_GROUP: 'Login-LAT-Group',
189
+ ATTR_FRAMED_APPLETALK_LINK: 'Framed-AppleTalk-Link',
190
+ ATTR_FRAMED_APPLETALK_NETWORK: 'Framed-AppleTalk-Network',
191
+ ATTR_FRAMED_APPLETALK_ZONE: 'Framed-AppleTalk-Zone',
192
+ ATTR_CHAP_CHALLENGE: 'CHAP-Challenge',
193
+ ATTR_NAS_PORT_TYPE: 'NAS-Port-Type',
194
+ ATTR_PORT_LIMIT: 'Port-Limit',
195
+ ATTR_LOGIN_LAT_PORT: 'Login-LAT-Port',
196
+
197
+ # ADDED - tdunteman
198
+ ATTR_TUNNEL_TYPE: 'Tunnel-Type',
199
+ ATTR_TUNNEL_MEDIUM_TYPE: 'Tunnel-Medium-Type',
200
+ ATTR_TUNNEL_PRIVATE_GROUP_ID: 'Tunnel-Private-Group-ID'
201
+ # END ADDED
202
+ }
203
+
204
+ # Map from name to id.
205
+ ATTR_NAMES = {v.lower(): k for k, v in ATTRS.items()}
206
+ # -------------------------------
207
+
208
+
209
+ class Error(Exception):
210
+ """
211
+ Base Error class.
212
+ """
213
+
214
+ pass
215
+
216
+
217
+ class NoResponse(Error):
218
+ """
219
+ Indicates no valid response received.
220
+ """
221
+
222
+ pass
223
+
224
+
225
+ class ChallengeResponse(Error):
226
+ """
227
+ Raised when radius replies with a challenge.
228
+
229
+ Provides the message(s) if any, as well as the state (if provided).
230
+
231
+ There can be 0+ messages. State is either defined or not.
232
+ """
233
+ def __init__(self, msg=None, state=None):
234
+ if msg is None:
235
+ self.messages = []
236
+ elif isinstance(msg, list):
237
+ self.messages = msg
238
+ else:
239
+ self.messages = [msg]
240
+ self.state = state
241
+
242
+
243
+ class SocketError(NoResponse):
244
+ """
245
+ Indicates general network error.
246
+ """
247
+
248
+ pass
249
+
250
+
251
+ if PY3:
252
+ # These functions are used to act upon strings in Python2, but bytes in
253
+ # Python3. Their functions are not necessary in PY3, so we NOOP them.
254
+ def ord(s):
255
+ return s
256
+
257
+ def chr(s):
258
+ return bytes([s])
259
+
260
+
261
+ def bytes_safe(s, e='utf-8'):
262
+ try:
263
+ return s.encode(e)
264
+ except (AttributeError, UnicodeDecodeError):
265
+ return s
266
+
267
+
268
+ def join(items):
269
+ """
270
+ Shortcut to join collection of strings.
271
+ """
272
+ return b''.join(items)
273
+
274
+
275
+ def authenticate(secret, username, password, host=None, port=None, **kwargs):
276
+ """
277
+ Authenticate the user against a radius server.
278
+
279
+ Return True if the user successfully logged in and False if not.
280
+
281
+ If the server replies with a challenge, a `ChallengeResponse` exception is
282
+ raised with the challenge.
283
+
284
+ Can raise either NoResponse or SocketError
285
+ """
286
+ # Pass host/port to the Radius instance. But ONLY if they are defined,
287
+ # otherwise we allow Radius to use the defaults for the kwargs.
288
+ rkwargs = {}
289
+ if host:
290
+ rkwargs['host'] = host
291
+ if port:
292
+ rkwargs['port'] = port
293
+ # Additional kwargs (like attributes) are sent to Radius.authenticate().
294
+ return Radius(secret, **rkwargs).authenticate(username, password, **kwargs)
295
+
296
+
297
+ def radcrypt(secret, authenticator, password):
298
+ """Encrypt a password with the secret and authenticator."""
299
+ # First, pad the password to multiple of 16 octets.
300
+ password += b'\0' * (16 - (len(password) % 16))
301
+
302
+ if len(password) > 128:
303
+ raise ValueError('Password exceeds maximun of 128 bytes')
304
+
305
+ result, last = b'', authenticator
306
+ while password:
307
+ # md5sum the shared secret with the authenticator,
308
+ # after the first iteration, the authenticator is the previous
309
+ # result of our encryption.
310
+ hash = md5(secret + last).digest()
311
+ for i in range(16):
312
+ result += chr(ord(hash[i]) ^ ord(password[i]))
313
+ # The next iteration will act upon the next 16 octets of the password
314
+ # and the result of our xor operation above. We will set last to
315
+ # the last 16 octets of our result (the xor we just completed). And
316
+ # remove the first 16 octets from the password.
317
+ last, password = result[-16:], password[16:]
318
+
319
+ return result
320
+
321
+
322
+ class Attributes(UserDict):
323
+ """
324
+ Dictionary-style interface.
325
+
326
+ Can retrieve or set values by name or by code. Internally stores items by
327
+ their assigned code. A given attribute can be present more than once.
328
+ """
329
+ def __init__(self, initialdata={}):
330
+ UserDict.__init__(self, {})
331
+ # Set keys via update() to invoke validation.
332
+ self.update(initialdata)
333
+
334
+ def __getkeys(self, value):
335
+ """Return tuple of code, name for given code or name."""
336
+ if isinstance(value, int):
337
+ return value, ATTRS[value]
338
+ else:
339
+ id = ATTR_NAMES[value.lower()]
340
+ return id, ATTRS[id]
341
+
342
+ def __contains__(self, key):
343
+ """
344
+ Override in operator.
345
+ """
346
+ code = self.__getkeys(key)[0]
347
+ return UserDict.__contains__(self, code)
348
+
349
+ def __getitem__(self, key):
350
+ """
351
+ Retrieve an item from attributes (by name or id).
352
+ """
353
+ for k in self.__getkeys(key):
354
+ try:
355
+ return UserDict.__getitem__(self, k)
356
+ except KeyError:
357
+ continue
358
+ raise KeyError(key)
359
+
360
+ def __setitem__(self, key, value):
361
+ """
362
+ Add an item to attributes (by name or id)
363
+ """
364
+ try:
365
+ code, name = self.__getkeys(key)
366
+ except KeyError:
367
+ raise ValueError('Invalid radius attribute: %s' % key)
368
+ values = self.get(code, [])
369
+ values.append(value)
370
+ UserDict.__setitem__(self, code, values)
371
+
372
+ def update(self, data):
373
+ """
374
+ Sets keys via __setitem__() to invoke validation.
375
+ """
376
+ for k, v in data.items():
377
+ self[k] = v
378
+
379
+ def nameditems(self):
380
+ """
381
+ Yields name value pairs as names (instead of ids).
382
+ """
383
+ for k, v in self.items():
384
+ yield self.__getkeys(k)[1], v
385
+
386
+ def pack(self):
387
+ """
388
+ Packs Attributes instance into data buffer.
389
+ """
390
+ data = []
391
+ for key, values in self.items():
392
+ for value in values:
393
+ data.append(struct.pack('BB%ds' % len(value), key,
394
+ len(value) + 2, bytes_safe(value)))
395
+ return join(data)
396
+
397
+ @staticmethod
398
+ def unpack(data):
399
+ """
400
+ Unpacks data into Attributes instance.
401
+ """
402
+ pos, attrs = 0, {}
403
+ while pos < len(data):
404
+ code, l = struct.unpack('BB', data[pos:pos + 2])
405
+ attrs[code] = data[pos + 2:pos + l]
406
+ pos += l
407
+ return Attributes(attrs)
408
+
409
+
410
+ class Message(object):
411
+ """
412
+ Represents a radius protocol packet.
413
+
414
+ This class can be used for requests and replies. The RFC dictates the
415
+ format.
416
+
417
+ 0 1 2 3
418
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
419
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
420
+ | Code | Identifier | Length |
421
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
422
+ | |
423
+ | Response Authenticator |
424
+ | |
425
+ | |
426
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
427
+ | Attributes ...
428
+ +-+-+-+-+-+-+-+-+-+-+-+-+-
429
+
430
+ Code - one octet, see CODES enum.
431
+ Identifier - one octet, unique value that represents request/response pair.
432
+ Provided by client and echoed by server.
433
+ Length - two octets, the length of the packet up to the max of 4096.
434
+ """
435
+
436
+ def __init__(self, secret, code, id=None, authenticator=None,
437
+ attributes=None):
438
+ self.code = code
439
+ self.secret = secret
440
+ self.id = id if id else randint(0, 255)
441
+ self.authenticator = authenticator if authenticator else os.urandom(16)
442
+ if isinstance(attributes, dict):
443
+ attributes = Attributes(attributes)
444
+ self.attributes = attributes if attributes else Attributes()
445
+
446
+ def pack(self):
447
+ """Pack the packet into binary form for transport."""
448
+ # First pack the attributes, since we need to know their length.
449
+ attrs = self.attributes.pack()
450
+ data = []
451
+ # Now pack the code, id, total length, authenticator
452
+ data.append(struct.pack('!BBH16s', self.code, self.id,
453
+ len(attrs) + 20, self.authenticator))
454
+ # Attributes take up the remainder of the message.
455
+ data.append(attrs)
456
+ return join(data)
457
+
458
+ @staticmethod
459
+ def unpack(secret, data):
460
+ """Unpack the data into it's fields."""
461
+ code, id, l, authenticator = struct.unpack('!BBH16s', data[:20])
462
+ if l != len(data):
463
+ LOGGER.warning('Too much data!')
464
+ attrs = Attributes.unpack(data[20:l])
465
+ return Message(secret, code, id, authenticator, attrs)
466
+
467
+ def verify(self, data):
468
+ """
469
+ Verify and unpack a response.
470
+
471
+ Ensures that a message is a valid response to this message, then
472
+ unpacks it.
473
+ """
474
+ id = ord(data[1])
475
+ assert self.id == id, 'ID mismatch (%s != %s)' % (self.id, id)
476
+ signature = md5(
477
+ data[:4] + self.authenticator + data[20:] + self.secret).digest()
478
+ assert signature == data[4:20], 'Invalid authenticator'
479
+ return Message.unpack(self.secret, data)
480
+
481
+
482
+ class Radius(object):
483
+ """
484
+ Radius client implementation.
485
+ """
486
+
487
+ def __init__(self, secret, host='radius', port=DEFAULT_PORT,
488
+ retries=DEFAULT_RETRIES, timeout=DEFAULT_TIMEOUT):
489
+ self._secret = bytes_safe(secret)
490
+ self.retries = retries
491
+ self.timeout = timeout
492
+ self._host = host
493
+ self._port = port
494
+
495
+ @property
496
+ def host(self):
497
+ return self._host
498
+
499
+ @property
500
+ def port(self):
501
+ return self._port
502
+
503
+ @property
504
+ def secret(self):
505
+ return self._secret
506
+
507
+ @contextmanager
508
+ def connect(self):
509
+ with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as c:
510
+ c.connect((self.host, self.port))
511
+ LOGGER.debug('Connected to %s:%s', self.host, self.port)
512
+ yield c
513
+
514
+ def send_message(self, message):
515
+ send = message.pack()
516
+
517
+ try:
518
+ with self.connect() as c:
519
+ for i in range(self.retries):
520
+ LOGGER.debug(
521
+ 'Sending (as hex): %s',
522
+ ':'.join(format(ord(c), '02x') for c in send))
523
+
524
+ c.send(send)
525
+
526
+ r, w, x = select([c], [], [], self.timeout)
527
+ if c in r:
528
+ recv = c.recv(PACKET_MAX)
529
+ else:
530
+ # No data available on our socket. Try again.
531
+ LOGGER.warning('Timeout expired on try %s', i)
532
+ continue
533
+
534
+ LOGGER.debug(
535
+ 'Received (as hex): %s',
536
+ ':'.join(format(ord(c), '02x') for c in recv))
537
+
538
+ try:
539
+ return message.verify(recv)
540
+ except AssertionError as e:
541
+ LOGGER.warning('Invalid response discarded %s', e)
542
+ # Silently discard invalid replies (as RFC states).
543
+ continue
544
+
545
+ except socket.error as e: # SocketError
546
+ LOGGER.debug('Socket error', exc_info=True)
547
+ raise SocketError(e)
548
+
549
+ LOGGER.error('Request timed out after %s tries', i)
550
+ raise NoResponse()
551
+
552
+ def access_request_message(self, username, password, **kwargs):
553
+ username = bytes_safe(username)
554
+ password = bytes_safe(password)
555
+
556
+ message = Message(self.secret, CODE_ACCESS_REQUEST, **kwargs)
557
+ message.attributes['User-Name'] = username
558
+ message.attributes['User-Password'] = \
559
+ radcrypt(self.secret, message.authenticator, password)
560
+
561
+ return message
562
+
563
+ def authenticate(self, username, password, **kwargs):
564
+ """
565
+ Attempt to authenticate with the given username and password.
566
+
567
+ Returns False on failure
568
+ Returns True on success
569
+ Raises a NoResponse (or its subclass SocketError) exception if no
570
+ responses or no valid responses are received
571
+ """
572
+ reply = self.send_message(
573
+ self.access_request_message(username, password, **kwargs))
574
+
575
+ if reply.code == CODE_ACCESS_ACCEPT:
576
+ LOGGER.info('Access accepted')
577
+ return True
578
+
579
+ elif reply.code == CODE_ACCESS_CHALLENGE:
580
+ LOGGER.info('Access challenged')
581
+ messages = reply.attributes.get('Reply-Message', None)
582
+ state = reply.attributes.get('State', [None])[0]
583
+ raise ChallengeResponse(messages, state)
584
+
585
+ LOGGER.info('Access rejected')
586
+ return False
587
+
588
+
589
+ # Don't break code written for radius.py distributed with the ZRadius
590
+ # Zope product
591
+ RADIUS = Radius
592
+
593
+
594
+ def main():
595
+ host = raw_input("Host [default: 'radius']: ")
596
+ port = raw_input('Port [default: %s]: ' % DEFAULT_PORT)
597
+
598
+ host = host if host else 'radius'
599
+ port = int(port) if port else DEFAULT_PORT
600
+
601
+ secret = username = password = None
602
+
603
+ while not secret:
604
+ secret = raw_input('Enter RADIUS Secret: ')
605
+
606
+ while not username:
607
+ username = raw_input('Enter your username: ')
608
+
609
+ while not password:
610
+ password = raw_input('Enter your password: ')
611
+
612
+ def _status(outcome):
613
+ if outcome:
614
+ print('Authentication Succeeded')
615
+ sys.exit(0)
616
+ else:
617
+ sys.exit('Authentication Failed')
618
+
619
+ try:
620
+ _status(authenticate(secret, username, password, host=host, port=port))
621
+ except ChallengeResponse as e:
622
+ pass
623
+ except Exception as e:
624
+ traceback.print_exc()
625
+ sys.exit('Authentication Error')
626
+
627
+ print('RADIUS server replied with a challenge.')
628
+
629
+ for m in e.messages:
630
+ print(' - %s' % m)
631
+
632
+ response = None
633
+ while not response:
634
+ response = raw_input('Enter your challenge response: ')
635
+
636
+ a = Attributes()
637
+ if e.state:
638
+ a['State'] = e.state
639
+
640
+ try:
641
+ _status(authenticate(secret, username, response, host=host, port=port,
642
+ attributes=a))
643
+ except Exception as e:
644
+ traceback.print_exc()
645
+ sys.exit('Authentication Error')
646
+
647
+
648
+ if __name__ == '__main__':
649
+ import sys
650
+ import traceback
651
+
652
+ LOGGER.addHandler(logging.StreamHandler())
653
+ LOGGER.setLevel(logging.DEBUG)
654
+
655
+ main()
@@ -0,0 +1,422 @@
1
+ from flask import Flask, render_template, send_from_directory, g, session, send_file, abort
2
+ from flask import Flask, flash, redirect, render_template, request, session, abort, url_for, jsonify
3
+ from flask_login import LoginManager, login_user, current_user, logout_user, login_required
4
+ from urllib.parse import urlparse, urljoin
5
+ import os
6
+ import json
7
+ import inspect
8
+ import logging
9
+ from datetime import datetime, timedelta
10
+ from threading import Lock, Thread
11
+ from time import sleep
12
+ import uuid
13
+ import re, glob
14
+ from typing import Callable
15
+ from flask_socketio import SocketIO, emit, disconnect
16
+ from werkzeug.middleware.proxy_fix import ProxyFix
17
+ from logging_handler import create_logger, DEBUG, INFO, WARNING, ERROR, CRITICAL, _log_level_number
18
+
19
+ '''
20
+ ==================================
21
+ Global Variables available in to all modules
22
+ ==================================
23
+ '''
24
+ FLASK_SECRET_LENGTH = 128
25
+ FLASK_DEFAULT_STATIC_DIR = 'static'
26
+ BASE_TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), 'base_templates')
27
+
28
+ SOCKETIO_DEFAULT_HANDLERS = {
29
+ "connect": "_socket_io_connect"
30
+ }
31
+
32
+ def load_config_json(config_file:str):
33
+ ''' Load a json config file '''
34
+ global config_data
35
+ with open(config_file, 'r', encoding='utf-8') as input_file:
36
+ config_data = json.loads(input_file.read())
37
+ return config_data
38
+
39
+
40
+ class FlaskLogFilter(logging.Filter):
41
+ ''' Class to handle filtering of web log mess '''
42
+ log_filter_list = []
43
+
44
+ def filter(self, record):
45
+ ''' Filter out selected log messages - returns FALSE if message should be filtered '''
46
+ for filter in self.log_filter_list:
47
+ if filter in record.getMessage():
48
+ return False
49
+ return True # don't filter!
50
+
51
+ class FlaskApp:
52
+ ''' Class to hold and manage all the general flask related data and functions '''
53
+ def __init__(self, config_file:str|None=None, web_log_level:str=INFO, app_log_level:str=INFO, app_path=None,
54
+ templates_path=os.path.join(os.path.dirname(__file__), 'templates')):
55
+ # app config
56
+ self.config_file = config_file
57
+ self.config = {}
58
+ self._templates = None
59
+ self.site_data = {
60
+ 'templates_path': os.path.abspath(templates_path),
61
+ 'app_path': app_path,
62
+ }
63
+
64
+ # init objects
65
+ self.app = None
66
+ self.login_manager = None
67
+ self.async_mode = None
68
+ self.socketio = None
69
+ self.user_controller = None
70
+
71
+ # save log levels
72
+ self.web_log_level = web_log_level if web_log_level in [DEBUG, INFO, WARNING, ERROR, CRITICAL] else INFO
73
+ self.app_log_level = app_log_level if app_log_level in [DEBUG, INFO, WARNING, ERROR, CRITICAL] else INFO
74
+ self.flask_logger = create_logger(console_level=self.app_log_level)
75
+ self.app_logger = create_logger(console_level=self.app_log_level, name=__name__)
76
+
77
+ # create list of web pages and API url's
78
+ self.web_pages = {
79
+ 'web_home': {
80
+ 'routes': ['/', '/index.html', '/default.html'],
81
+ 'params': {}
82
+ },
83
+ 'healthz': {
84
+ 'routes': ['/healthz']
85
+ }
86
+ }
87
+ self.api_pages = {}
88
+ self.web_log_filter = ['HEAD /healthz']
89
+ self._shutdown_post_uuid = str(uuid.uuid4())
90
+
91
+ # mapping of static path overrides and all static content pages
92
+ self.static_pages = {}
93
+ self.static_page_args = {}
94
+
95
+ # shutdown flags
96
+ self._shutdown = False
97
+
98
+ # socketio holders
99
+ self._socketio_background_threads = {}
100
+ self._socketio_clients = {}
101
+ self._socketio_client_lock = Lock()
102
+
103
+ self.init()
104
+
105
+ @property
106
+ def base_templates(self):
107
+ ''' Return a list of the available base templates '''
108
+ if self._templates is not None:
109
+ return self._templates
110
+ self._templates = []
111
+ for entry in os.scandir(BASE_TEMPLATE_PATH):
112
+ if entry.is_dir():
113
+ # check for a templates folder
114
+ for template_entry in os.scandir(entry.path):
115
+ if template_entry.name == 'templates':
116
+ self._templates.append(entry.name)
117
+ return self._templates
118
+
119
+ def init_login_manager(self):
120
+ ''' Configure the login manager '''
121
+ self.login_manager = LoginManager()
122
+ self.login_manager.login_view = self.site_data.get('login_page', '/login.html')
123
+ self.login_manager.init_app(self.app)
124
+ if self.config.get('auth', '').lower() == 'radius' or self.config.get('authentication', '').lower() == 'radius':
125
+ from .user_radius import RadiusUserController
126
+ self.user_controller = RadiusUserController(**self.config.get('radius'))
127
+ self.login_manager.user_loader(self.user_controller.get_user)
128
+ else:
129
+ from .user_generic import GenericUserController
130
+ self.user_controller = GenericUserController()
131
+ self.login_manager.user_loader(self.user_controller.get_user)
132
+
133
+ def init(self):
134
+ ''' Stop the running process and recreate all Flask objects. Allows a complete reset of the Flask environment with all routes '''
135
+ self.stop()
136
+ self.config = load_config_json(self.config_file) if self.config_file is not None else {}
137
+
138
+ # flask objects
139
+ self.app = Flask(__name__, static_folder=self.config.get('static_dir', os.path.join(os.getcwd(), FLASK_DEFAULT_STATIC_DIR)), template_folder=self.site_data['templates_path'])
140
+ self.web_static_dir = self.config.get('static_dir', FLASK_DEFAULT_STATIC_DIR)
141
+ self.web_static_inc_subs = self.config.get('web_static_inc_subs', True)
142
+ self.app.wsgi_app = ProxyFix(self.app.wsgi_app, **dict(x_proto=1, x_host=1, x_for=1, x_prefix=1) if self.config.get('behind_proxy', False) else {})
143
+ self.socketio = SocketIO(self.app, cors_allowed_origins=self.config.get('cors_allowed_origins', '*'))
144
+
145
+ # logging filter
146
+ self.web_log_filter = self.config.get('web_log_filter', self.web_log_filter)
147
+ if not isinstance(self.web_log_filter, list):
148
+ raise ValueError(f"web_log_filter mus be a list of string objects to match against logs. Got: {self.web_log_filter}")
149
+ web_log_filter_obj = FlaskLogFilter()
150
+ web_log_filter_obj.log_filter_list = self.web_log_filter
151
+ werkzeug_logger = logging.getLogger('werkzeug')
152
+ werkzeug_logger.addFilter(web_log_filter_obj)
153
+
154
+ self.site_data['base_template'] = self.config.get('base_template', None) if self.config.get('base_template', None) in self.base_templates else None
155
+ self.site_data['debug'] = self.config.get('debug', False)
156
+ self.site_data['auth'] = self.config.get('auth', None)
157
+ if self.site_data['auth'] is not None:
158
+ self.site_data['login_page'] = self.config.get('login_page', '/login.html')
159
+ self.site_data['logout_page'] = self.config.get('login_page', '/logout.html')
160
+
161
+ # configure the site template by creating a sym-link to the base template under the Flask site templates (Flask requires all templates to be in 1 dir)
162
+ if self.site_data.get('base_template', None) is not None:
163
+ if os.path.exists(os.path.join(self.site_data['templates_path'], '_base_template')):
164
+ os.unlink(os.path.join(self.site_data['templates_path'], '_base_template'))
165
+ os.symlink(os.path.join(BASE_TEMPLATE_PATH, self.site_data.get('base_template')), os.path.join(self.site_data['templates_path'], '_base_template'))
166
+ # set the base_template file to a local 'base.html.j2' file if it exists, otherwise use the template base file
167
+ if os.path.isfile(os.path.join(self.site_data['templates_path'], 'base.html.j2')):
168
+ self.site_data['site_template'] = 'base.html.j2' # path is relative to the 'templates' folder
169
+ else:
170
+ # if a 'site_template' is specified, use that
171
+ self.site_data['site_template'] = os.path.join('_base_template', 'templates', self.site_data.get('site_template', 'base.html.j2'))
172
+ self.site_data.update(self.config.get('site_data', {}))
173
+ self.web_pages.update(self.config.get('web_pages', {}))
174
+ self.api_pages.update(self.config.get('api_pages', {}))
175
+
176
+ # add the shutdown endpoint
177
+ self._shutdown_post_uuid = str(uuid.uuid4())
178
+ self.web_pages.update({'shutdown_server': {'routes': [f'/shutdown_server'], 'params': {'methods': ['POST']}}})
179
+ self.app_logger.debug(f"Shutdown endpoint UUID: {self._shutdown_post_uuid}. Shutdown server with POST to /shutdown_server wuth form endcoded 'UUID' parameter and value.")
180
+
181
+ # create sym link for the app in addition to the base template
182
+ if self.site_data.get('app_path', None) is not None and os.path.join(self.site_data.get('app_path', None), 'templates') != self.site_data['templates_path']:
183
+ if os.path.exists(os.path.join(self.site_data['templates_path'], '_app')):
184
+ os.unlink(os.path.join(self.site_data['templates_path'], '_app'))
185
+ os.symlink(self.site_data.get('app_path'), os.path.join(self.site_data['templates_path'], '_app'))
186
+
187
+ # configure login manager
188
+ #if self.config.get('auth', None) != None or self.config.get('authentication', None) != None:
189
+ self.init_login_manager()
190
+
191
+ # load or generate flask secret key
192
+ if os.path.isfile(self.config.get('flask_secret_file', '.flask_secret')):
193
+ with open(self.config.get('flask_secret_file', '.flask_secret'), 'rb') as input_file:
194
+ self.app_logger.info(f"{self.info_str}: Reading flask secret file {self.config.get('flask_secret_file', '.flask_secret')}")
195
+ self.app.secret_key = input_file.read()
196
+ else:
197
+ self.app.secret_key = os.urandom(FLASK_SECRET_LENGTH)
198
+ with open(self.config.get('flask_secret_file', '.flask_secret'), 'wb') as output_file:
199
+ self.app_logger.info(f"{self.info_str}: Writing flask secret file {self.config.get('flask_secret_file', '.flask_secret')}")
200
+ output_file.write(self.app.secret_key)
201
+ self.update_flask_routes(reinit=False)
202
+
203
+ # configure dropdowns
204
+ for dropdown_menu in self.config.get('dropdowns', []):
205
+ self.add_dropdown(name=dropdown_menu.get('name', 'Menu'), items=dropdown_menu.get('items', []), replace=True)
206
+
207
+ # configure socketio handlers
208
+ for socketio_handler in self.config.get('socketio', []):
209
+ self.app_logger.info(f"{self.info_str}: Adding socketio handler: {socketio_handler}")
210
+ self._socketio_clients[socketio_handler.get('namespace', 'default')] = []
211
+ for handler, function in socketio_handler.get('handlers', {}).items():
212
+ self.socketio.on_event(handler, getattr(self, function), socketio_handler.get('namespace', 'default'))
213
+ #if socketio_handler.get('direction', 'out') == 'out':
214
+ # self.socketio.on_event("connect", self._socket_io_connect, socketio_handler.get('namespace', 'default'))
215
+
216
+ def _socket_io_connect(self):
217
+ ''' On a connect request, start the background thread if not currently running '''
218
+ if self.socketio:
219
+ self.app_logger.info(f"{self.info_str}: client connect for namespace {request.namespace}...") # pyright: ignore[reportAttributeAccessIssue]
220
+ try:
221
+ # add client to the list of clients for the namespace
222
+ with self._socketio_client_lock:
223
+ if request.namespace not in self._socketio_clients: # pyright: ignore[reportAttributeAccessIssue]
224
+ self._socketio_clients[request.namespace] = [] # pyright: ignore[reportAttributeAccessIssue]
225
+ self._socketio_clients[request.namespace].append(request.sid) # pyright: ignore[reportAttributeAccessIssue]
226
+ self.app_logger.info(f"{self.info_str}: client connected for namespace {request.namespace}. Total clients for namespace: {len(self._socketio_clients[request.namespace])}") # pyright: ignore[reportAttributeAccessIssue]
227
+
228
+ if isinstance(self._socketio_background_threads.get(request.namespace), Thread) and self._socketio_background_threads[request.namespace].is_alive(): # pyright: ignore[reportAttributeAccessIssue]
229
+ self.app_logger.debug(f"{self.info_str}: socketio background thread already running")
230
+ else:
231
+ socketio_config = [x for x in self.config['socketio'] if x.get('namespace') == request.namespace][0] # pyright: ignore[reportAttributeAccessIssue]
232
+ self.app_logger.info(f"Starting background thread for {request.namespace}, socketio config: {socketio_config}...") # pyright: ignore[reportAttributeAccessIssue]
233
+ self._socketio_background_threads[socketio_config.get('namespace')] = self.socketio.start_background_task(target=getattr(self, socketio_config.get('update_function'))) # pyright: ignore[reportAttributeAccessIssue]
234
+ except Exception as e:
235
+ self.app_logger.error(f"SocketIO Connect error occured: {e.__class__.__name__}: {e}")
236
+ else:
237
+ self.app_logger.critical("SocketIO connect received, but socketio not running!")
238
+
239
+ def _socket_io_disconnect(self):
240
+ ''' On disconnect, remove the client from the list of clients for the namespace '''
241
+ if self.socketio:
242
+ self.app_logger.info(f"{self.info_str}: client disconnect for namespace {request.namespace}...") # pyright: ignore[reportAttributeAccessIssue]
243
+ with self._socketio_client_lock:
244
+ if request.namespace in self._socketio_clients and request.sid in self._socketio_clients[request.namespace]: # pyright: ignore[reportAttributeAccessIssue]
245
+ self._socketio_clients[request.namespace].remove(request.sid) # pyright: ignore[reportAttributeAccessIssue]
246
+ self.app_logger.info(f"{self.info_str}: client disconnected for namespace {request.namespace}. Total clients for namespace: {len(self._socketio_clients[request.namespace])}") # pyright: ignore[reportAttributeAccessIssue]
247
+ else:
248
+ self.app_logger.warning(f"{self.info_str}: disconnect received for namespace {request.namespace} but client SID not found in client list!") # pyright: ignore[reportAttributeAccessIssue]
249
+ else:
250
+ self.app_logger.critical("SocketIO disconnect received, but socketio not running!")
251
+
252
+ @property
253
+ def dropdown_menus(self) -> list:
254
+ ''' Returns a list of the dropdown menus that are currently configured '''
255
+ if 'dropdowns' not in self.site_data:
256
+ self.site_data['dropdowns'] = []
257
+ return self.site_data['dropdowns']
258
+
259
+ def remove_dropdown(self, name:str):
260
+ ''' Deletes a dropdown based on the display name '''
261
+ for i in range(len(self.dropdown_menus)):
262
+ if self.dropdown_menus[i]['name'] == name:
263
+ self.dropdown_menus.remove(i)
264
+ return
265
+
266
+ def add_dropdown(self, name:str, items:list, replace=True):
267
+ ''' Add a dropdown to the list of dropdown menus. Replace will replace the existing menu definition with the provided definition
268
+ Items format: [
269
+ {'name': '[Display name]',
270
+ 'url': '[URL for the link, relative should start with /]'],
271
+ 'newtab': true|false (default is False)}
272
+ ] '''
273
+ if replace:
274
+ self.remove_dropdown(name)
275
+ for i in range(len(self.dropdown_menus)):
276
+ if self.dropdown_menus[i]['name'] == name:
277
+ for k in range(len(items)):
278
+ for j in range(len(self.dropdown_menus[i].get('items',[]))):
279
+ if self.dropdown_menus[i]['items'][j].get('name') == items[k].get('name'):
280
+ dict(self.dropdown_menus[i]['items'][j]).update(items[k])
281
+ return
282
+ # we didn't find a matching item, so add it
283
+ self.dropdown_menus[i]['items'].append(items[k])
284
+ return
285
+ # if we didn't run an update, add the menu
286
+ self.dropdown_menus.append({'name': name, 'items': items})
287
+
288
+ @property
289
+ def info_str(self):
290
+ ''' Returns the info string for the class (used in logging commands) '''
291
+ return f"{self.__class__.__name__} ({self.config.get('address', '0.0.0.0')}:{self.config.get('port', 8080)}){':DEBUG' if self.config.get('debug', False) else ''}"
292
+
293
+ def __del__(self):
294
+ self.stop()
295
+
296
+ def update_flask_routes(self, reinit=False):
297
+ ''' Update the flask routes '''
298
+ if reinit or self.app is None:
299
+ self.init()
300
+ # add base template static files
301
+ if self.site_data.get('base_template', None) is not None and os.path.isdir(os.path.join(self.site_data['templates_path'], '_base_template', 'static')):
302
+ self._add_flask_static_files(os.path.join(self.site_data['templates_path'], '_base_template', 'static'))
303
+ # add app static files
304
+ if self.site_data.get('app_path', None) is not None and os.path.isdir(os.path.join(self.site_data['templates_path'], '_app', 'static')):
305
+ self._add_flask_static_files(os.path.join(self.site_data['templates_path'], '_app', 'static'))
306
+ # add static files from the project
307
+ self._add_flask_static_files(os.path.join(os.getcwd(), self.config.get('static_dir', FLASK_DEFAULT_STATIC_DIR)))
308
+
309
+ # add dynamic pages
310
+ for page in self.web_pages:
311
+ for route in self.web_pages[page]['routes']:
312
+ self.app.add_url_rule(route, view_func=getattr(self, page), **self.web_pages[page].get('params', {}))
313
+
314
+ # add api pages
315
+ for page in self.api_pages:
316
+ for route in self.api_pages[page]['routes']:
317
+ self.app.add_url_rule(route, view_func=getattr(self, page), **self.api_pages[page].get('params', {}))
318
+
319
+ def _add_flask_static_files(self, root_path):
320
+ ''' Loop through all files in the path specified and add as static files. If '_base_template', files will be added WITHOUT the '_base_template' in the route '''
321
+ for static_file in get_all_files(root_path, True):
322
+ self.static_pages[static_file.split(root_path)[1]] = static_file
323
+ self.app.add_url_rule(static_file.split(root_path)[1], view_func=self.web_static_file, **self.static_page_args)
324
+
325
+ def shutdown_server(self):
326
+ ''' Execute a shutdown of the server, must be a POST and include the UUID in the body '''
327
+ if request.method == 'POST' and request.form.get('UUID', None) == self._shutdown_post_uuid:
328
+ if isinstance(self.socketio, SocketIO):
329
+ self.app_logger.info(f"Received shutdown request from {request.remote_addr}. Stopping services...")
330
+ self.socketio.stop()
331
+ return 'Services shutting down...\n', 200
332
+ else:
333
+ self.app_logger.error(f"Received shutdown request from {request.remote_addr}. Services not running!")
334
+ return "Services not available", 500
335
+ self.app_logger.critical(f"Received shutdown request from {request.remote_addr}. Missing proper UUID. Verify Proper usage.")
336
+ return 'ACCESS DENIED', 403
337
+
338
+ def start(self):
339
+ ''' Start the Flask process in a thread '''
340
+ try:
341
+ self.socketio.run(self.app,
342
+ host=self.config.get('address', '0.0.0.0'),
343
+ port=self.config.get('port', 8080),
344
+ debug=self.config.get('debug', False),
345
+ use_reloader=False)
346
+ while True:
347
+ sleep(5)
348
+ except KeyboardInterrupt:
349
+ # CTRL+C will end the program
350
+ self.app_logger.info("CTRL+C Caught. Closing...")
351
+ self.stop()
352
+
353
+ def render_template(self, template:str, page=None, **kwargs):
354
+ ''' Render the requested template. Automatically inserts base page data '''
355
+ # get function name of the calling function
356
+ stack = inspect.stack()
357
+ for x in range(len(stack)):
358
+ if stack[x].function == 'render_template':
359
+ break
360
+ if x + 1 < len(stack):
361
+ calling_func = stack[x+1].function
362
+ if os.path.exists(os.path.join(self.site_data['templates_path'], template)):
363
+ return render_template(template, site=self.site_data, page=self.web_pages[calling_func].get('data', {}) if page is None else page, **kwargs)
364
+ elif os.path.exists(os.path.join(self.site_data['templates_path'], '_app', 'templates', template)):
365
+ return render_template(os.path.join('_app', template), site=self.site_data, page=self.web_pages[calling_func].get('data', {}) if page is None else page, **kwargs)
366
+ return render_template(os.path.join('_base_template', 'templates', template), site=self.site_data, page=self.web_pages[calling_func].get('data', {}) if page is None else page, **kwargs)
367
+
368
+ def return_error(self, code:int=404):
369
+ ''' Return an error code '''
370
+ abort(code)
371
+
372
+ def stop(self):
373
+ pass
374
+
375
+ def web_home(self):
376
+ return "<body>test123</body>", 200
377
+
378
+ def healthz(self):
379
+ ''' Override if more complex healthcheck is required beyond 'the web service is operational' '''
380
+ return 'OK', 200
381
+
382
+ def web_static_file(self):
383
+ ''' Return a static file '''
384
+ file_name = request.url_rule.rule.rsplit('/', 1)[-1]
385
+ return send_file(self.static_pages[request.url_rule.rule], download_name=file_name, as_attachment=bool(safe_string(request.args.get('download', False))))
386
+
387
+ def request_args_safe(self, *args) -> bool:
388
+ ''' Checks that all request arguments are safe strings. Non-alphanumeric characters that are accepted can be passed as arguments '''
389
+ for argument in request.args:
390
+ if not safe_string(request.args.get(argument), *args):
391
+ self._logger.error(f"{self.info_str}: Argument '{argument}' failed safe check!")
392
+ return False
393
+ return True
394
+
395
+
396
+ def get_all_files(path:str, include_subdir:bool):
397
+ ''' Recursive function to return a list of all files '''
398
+ file_list = []
399
+ if os.path.isdir(path):
400
+ with os.scandir(path) as entries:
401
+ for entry in entries:
402
+ if entry.is_file():
403
+ file_list.append(entry.path)
404
+ elif entry.is_dir():
405
+ file_list.extend(get_all_files(entry.path, include_subdir))
406
+ return file_list
407
+
408
+
409
+ def is_safe_url(target):
410
+ ref_url = urlparse(request.host_url)
411
+ test_url = urlparse(urljoin(request.host_url, target))
412
+ return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc
413
+
414
+
415
+ def safe_string(value, *args):
416
+ """ Verify that a passed string is alpha numeric plus any additional safe characters """
417
+ if value is not None:
418
+ for arg in args:
419
+ value = value.replace(arg, 'X')
420
+ if (isinstance(value, str) and str(value).isalnum()) or value == '':
421
+ return True
422
+ return False
@@ -0,0 +1,30 @@
1
+ from gevent.pywsgi import WSGIHandler
2
+ from datetime import datetime
3
+
4
+ def patched_format_request(self):
5
+ now = datetime.now().replace(microsecond=0)
6
+ length = self.response_length or '-'
7
+ if self.time_finish:
8
+ delta = '%.6f' % (self.time_finish - self.time_start)
9
+ else:
10
+ delta = '-'
11
+ if getattr(self, 'environ', None):
12
+ if 'HTTP_X_REAL_IP' in self.environ:
13
+ client_address = self.environ['HTTP_X_REAL_IP']
14
+ else:
15
+ client_address = self.client_address[0] if isinstance(self.client_address, tuple) else self.client_address
16
+ else:
17
+ client_address = self.client_address[0] if isinstance(self.client_address, tuple) else self.client_address
18
+ return '%s - - [%s] "%s" %s %s %s' % (
19
+ client_address or '-',
20
+ now,
21
+ self.requestline or '',
22
+ # Use the native string version of the status, saved so we don't have to
23
+ # decode. But fallback to the encoded 'status' in case of subclasses
24
+ # (Is that really necessary? At least there's no overhead.)
25
+ (self._orig_status or self.status or '000').split()[0],
26
+ length,
27
+ delta)
28
+
29
+ def patch_wsgihandler():
30
+ WSGIHandler.format_request = patched_format_request
@@ -0,0 +1,86 @@
1
+ from flask_login import UserMixin
2
+ import logging
3
+
4
+ class FlaskUserController:
5
+ ''' Parent class to handle basic user management functions. Tasks should be overriden by an inherritting class '''
6
+ def __init__(self, logger=logging):
7
+ self._logger = logger
8
+ self._inherit_info_str = ''
9
+
10
+ def __del__(self):
11
+ self.close()
12
+
13
+ def close(self):
14
+ pass
15
+
16
+ @property
17
+ def info_str(self):
18
+ ''' Returns the info string for the class (used in logging commands) '''
19
+ return f"{self.__class__.__name__}({self._inherit_info_str})"
20
+
21
+ def authenticate_user(self, username:str, password=None, password_hash=None):
22
+ ''' Authenticate a user and return a FlaskUser object '''
23
+ return NotImplemented
24
+
25
+ def authorize_user(self, username:str, **kwargs):
26
+ ''' Authorize a user based on criteria that is passed '''
27
+ return NotImplemented
28
+
29
+ def get_user(self, username=None, user_id=None):
30
+ ''' Find a user from a username or user_id '''
31
+ return NotImplemented
32
+
33
+ def enable_user(self, user_id):
34
+ ''' Mark a user as enabled '''
35
+ return NotImplemented
36
+
37
+ def disable_user(self, user_id):
38
+ ''' Mark a user as disabled '''
39
+ return NotImplemented
40
+
41
+ def add_user(self, username:str, password=None, enabled=True, **kwargs):
42
+ ''' Create a new user in the underlying system '''
43
+ return NotImplemented
44
+
45
+ def delete_user(user_id):
46
+ ''' Delete a user in the underlying system '''
47
+ return NotImplemented
48
+
49
+ def update_user(self, user_id, username=None, password=None, enabled=None, **kwargs):
50
+ ''' Update properties for a user in the underlying system '''
51
+ return NotImplemented
52
+
53
+
54
+ class FlaskUser(UserMixin):
55
+ '''
56
+ Represents a user that has attempted a login via the FlaskLoginController.
57
+ Extends the Flask UserMixin class to represent the user. Base class includes __eq__ and __ne__ functions
58
+ '''
59
+ def __init__(self, user_id:str|int, username:str, auth_ok:bool, acct_active:bool):
60
+ ''' Create an instance to represent a user login '''
61
+ self._user_data = (user_id, username, auth_ok, acct_active)
62
+
63
+ @property
64
+ def is_active(self):
65
+ ''' is_active returns True if the account is active (not suspended or rejected for reasons other than auth) '''
66
+ return self._user_data[4]
67
+
68
+ @property
69
+ def username(self):
70
+ return self._user_data[1]
71
+
72
+ @property
73
+ def name(self):
74
+ return self.user_data[1]
75
+
76
+ @property
77
+ def is_authenticated(self):
78
+ ''' Returns True if the account is authenticated '''
79
+ return self._user_data[2]
80
+
81
+ def get_id(self):
82
+ return self._user_data[0]
83
+
84
+ def __str__(self):
85
+ ''' Return the username and ID as a string '''
86
+ return f"{self.username}({self.get_id()})"
@@ -0,0 +1,20 @@
1
+ '''
2
+ Generic user controller for when no other user controller is used. Returns 'admin' as the user and always logged in
3
+ '''
4
+
5
+ import logging
6
+ from .user_controller import FlaskUserController, FlaskUser
7
+
8
+
9
+ class GenericUserController(FlaskUserController):
10
+ ''' Extends the base FlaskUserController to always return a logged in user of 'admin' '''
11
+ def __init__(self, logger=logging):
12
+ super().__init__(logger=logger)
13
+
14
+ def authenticate_user(self, *args, **kwargs):
15
+ ''' Authenticate a user and return a FlaskUser object '''
16
+ return FlaskUser(user_id='admin', username='admin', auth_ok=True, acct_active=True)
17
+
18
+ def get_user(self, user_id=None):
19
+ ''' Find a user from a user_id - Currently requires the user list '''
20
+ return FlaskUser(user_id='admin', username='admin', auth_ok=True, acct_active=True)
@@ -0,0 +1,40 @@
1
+ import logging
2
+ from .user_controller import FlaskUserController, FlaskUser
3
+ from ._radius import Radius, DEFAULT_RETRIES, DEFAULT_TIMEOUT
4
+
5
+
6
+ class RadiusUserController(FlaskUserController):
7
+ ''' Extends the base FlaskUserController to utilize a RADIUS backend for authentication and authorization
8
+ NOTES:
9
+ - Radius user controller only supports read methods,
10
+ - user_id is the username
11
+ - user_table is a list of user id's that should be permitted (can be used to filter users): [1, 55, 132]
12
+ '''
13
+ def __init__(self, host:str, shared_secret:str, port=1812, user_table=None, logger=logging, retries=DEFAULT_RETRIES, timeout=DEFAULT_TIMEOUT):
14
+ super().__init__(logger=logger)
15
+ self._inherit_info_str = f'{host}:{port}'
16
+ self._logger.info(f"{self.info_str}: Connecting to RADIUS Server")
17
+ self.radius = Radius(secret=shared_secret, host=host, port=port, retries=retries, timeout=timeout)
18
+ self.user_table = user_table if user_table is not None else []
19
+
20
+ def authenticate_user(self, username:str, password=None, password_hash=None, strip_username=True, lcase_username=True):
21
+ ''' Authenticate a user and return a FlaskUser object '''
22
+ if strip_username:
23
+ username = username.strip() # Remove spaces that might be before or after the username
24
+ if lcase_username:
25
+ username = username.lcase() # Easier for mobile devices that might capitalize the first letter
26
+ if len(self.user_table) == 0 or username in self.user_table:
27
+ if self.radius.authenticate(username=username, password=password):
28
+ self._logger.info(f"{self.info_str}: {username}: Auth Successful")
29
+ return FlaskUser(user_id=username, username=username, auth_ok=True, acct_active=True)
30
+ return None
31
+
32
+ def authorize_user(self, username: str, **kwargs):
33
+ ''' Need to setup the radius package to return extended attributes to use authorization '''
34
+ return NotImplemented
35
+
36
+ def get_user(self, user_id=None):
37
+ ''' Find a user from a user_id - Currently requires the user list '''
38
+ if user_id in self.user_table:
39
+ return FlaskUser(user_id, user_id, False, False)
40
+ return None
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: flask_app_class
3
+ Version: 0.1.0
4
+ Summary: Class based tool to build Flask applications that include socketio and authentication
5
+ Author-email: Thomas Dunteman <git@learningtopi.com>
6
+ Project-URL: homepage, https://github.com/LearningToPi/flask_app_class
7
+ Project-URL: bug tracker, https://github.com/LearningToPi/flask_app_class/issues
8
+ Project-URL: source code, https://github.com/LearningToPi/flask_app_class
9
+ Keywords: flask,socketio
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Framework :: Flask
13
+ Classifier: Environment :: Web Environment
14
+ Requires-Python: >=3.12
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: aiosmtpd==1.4.6
17
+ Requires-Dist: atpublic==7.0.0
18
+ Requires-Dist: attrs==25.4.0
19
+ Requires-Dist: azure-communication-email==1.1.0
20
+ Requires-Dist: azure-core==1.38.0
21
+ Requires-Dist: azure-identity==1.25.1
22
+ Requires-Dist: certifi==2026.1.4
23
+ Requires-Dist: cffi==2.0.0
24
+ Requires-Dist: charset-normalizer==3.4.4
25
+ Requires-Dist: cryptography==46.0.3
26
+ Requires-Dist: idna==3.11
27
+ Requires-Dist: ipcalc==1.99.0
28
+ Requires-Dist: isodate==0.7.2
29
+ Requires-Dist: logging-handler==1.0.7
30
+ Requires-Dist: msal==1.34.0
31
+ Requires-Dist: msal-extensions==1.3.1
32
+ Requires-Dist: parameter-verification==0.1.1
33
+ Requires-Dist: pycparser==3.0
34
+ Requires-Dist: PyJWT==2.10.1
35
+ Requires-Dist: queue_processor==1.1.2
36
+ Requires-Dist: requests==2.32.5
37
+ Requires-Dist: six==1.17.0
38
+ Requires-Dist: typing_extensions==4.15.0
39
+ Requires-Dist: urllib3==2.6.3
40
+ Requires-Dist: validators==0.35.0
@@ -0,0 +1,12 @@
1
+ flask_app_class/__init__.py,sha256=7Lvx93yptZlySx3QRFv3vvjk7_o92vGazUywdNZKEnY,64
2
+ flask_app_class/__main__.py,sha256=Qkgk89cSulYr2lFe1eDP__JiNJ_bVoncxfPg3jL-Rss,606
3
+ flask_app_class/_radius.py,sha256=2Pu150fskWhcZ_QN3uzYrx8fv8TF_OPpQ5oGuwIOc_0,20255
4
+ flask_app_class/flask_app.py,sha256=WC16ZGvrrn47jOarppFAYMHooGuMNuqBqFaSOHo7L_Y,23222
5
+ flask_app_class/logging_patch.py,sha256=dlTpCJGBIwI0oDJ_9mV56X80J9rci3mn_lECzXRh8Fc,1233
6
+ flask_app_class/user_controller.py,sha256=ALrDt-7M5Ner8Fpz-FZtXdZ1ZbsrSuW4rHfRJcslgLg,2859
7
+ flask_app_class/user_generic.py,sha256=DKmCQCjV9INrgF_7oA0ZXeyafAjG4yWsdY2REuHBuu0,845
8
+ flask_app_class/user_radius.py,sha256=s9qphsBHkaneVo0Z9Lx2fj1SgVU4orM4enHJ3fQD7Oo,2264
9
+ flask_app_class-0.1.0.dist-info/METADATA,sha256=MNC6zTCE8EAgLpTfdQqE91768YpJu2M_lmUpNPyzB08,1539
10
+ flask_app_class-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
11
+ flask_app_class-0.1.0.dist-info/top_level.txt,sha256=X0NAW5bsPKXKoEPGHKPi_Mn5ry3bXY7L1_ccTBrrZ2w,16
12
+ flask_app_class-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ flask_app_class