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.
- flask_app_class/__init__.py +1 -0
- flask_app_class/__main__.py +12 -0
- flask_app_class/_radius.py +655 -0
- flask_app_class/flask_app.py +422 -0
- flask_app_class/logging_patch.py +30 -0
- flask_app_class/user_controller.py +86 -0
- flask_app_class/user_generic.py +20 -0
- flask_app_class/user_radius.py +40 -0
- flask_app_class-0.1.0.dist-info/METADATA +40 -0
- flask_app_class-0.1.0.dist-info/RECORD +12 -0
- flask_app_class-0.1.0.dist-info/WHEEL +5 -0
- flask_app_class-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
flask_app_class
|