diff options
| author | Sam Chudnick <sam@chudnick.com> | 2022-06-27 20:41:01 -0400 |
|---|---|---|
| committer | Sam Chudnick <sam@chudnick.com> | 2022-06-27 20:41:01 -0400 |
| commit | 570d0da295f3e2fcd7b8c80ae2e6c42fc365abdd (patch) | |
| tree | ccfe8614fc644b08db5d15091c344c77fff44fb1 /server | |
Initial commit
Diffstat (limited to 'server')
| -rwxr-xr-x | server/mfac.py | 113 | ||||
| -rwxr-xr-x | server/mfad.py | 246 |
2 files changed, 359 insertions, 0 deletions
diff --git a/server/mfac.py b/server/mfac.py new file mode 100755 index 0000000..779fa44 --- /dev/null +++ b/server/mfac.py | |||
| @@ -0,0 +1,113 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | |||
| 3 | import argparse | ||
| 4 | import sqlite3 | ||
| 5 | import pyotp | ||
| 6 | import sys | ||
| 7 | |||
| 8 | DB_NAME = "mfa.db" | ||
| 9 | KEY_LENGTH = 64 | ||
| 10 | |||
| 11 | def die(msg): | ||
| 12 | print(msg) | ||
| 13 | sys.exit(1) | ||
| 14 | |||
| 15 | def parse_arguments(): | ||
| 16 | parser = argparse.ArgumentParser() | ||
| 17 | parser.add_argument("--add-client",action="store_true",help="Add a client") | ||
| 18 | parser.add_argument("--alias",type=str,help="Alias for new client") | ||
| 19 | |||
| 20 | parser.add_argument("--add-app",action="store_true",help="Add an application") | ||
| 21 | parser.add_argument("--user",type=str,help="Application username") | ||
| 22 | parser.add_argument("--host",type=str,help="Application hostname") | ||
| 23 | parser.add_argument("--service",type=str,help="Application service name") | ||
| 24 | parser.add_argument("--methods",type=str,nargs="+",help="Allowed MFA methods") | ||
| 25 | |||
| 26 | parser.add_argument("--get-client",action="store_true",help="Get a client key") | ||
| 27 | |||
| 28 | return parser.parse_args() | ||
| 29 | |||
| 30 | |||
| 31 | def alias_exists(alias): | ||
| 32 | conn = sqlite3.connect(DB_NAME) | ||
| 33 | c = conn.cursor() | ||
| 34 | c.execute("SELECT * FROM clients WHERE alias=?",(alias,)) | ||
| 35 | client = c.fetchall() | ||
| 36 | conn.close() | ||
| 37 | if len(client) == 0: | ||
| 38 | return False | ||
| 39 | elif len(client) == 1: | ||
| 40 | return True | ||
| 41 | |||
| 42 | |||
| 43 | def get_client_key(alias): | ||
| 44 | CLIENT_ALIAS_INDEX = 0 | ||
| 45 | CLIENT_KEY_INDEX = 1 | ||
| 46 | if not alias_exists(alias): | ||
| 47 | die("Error: alias does not exist") | ||
| 48 | else: | ||
| 49 | conn = sqlite3.connect(DB_NAME) | ||
| 50 | c = conn.cursor() | ||
| 51 | c.execute("SELECT * FROM clients WHERE alias=?",(alias,)) | ||
| 52 | client = c.fetchone() | ||
| 53 | conn.close() | ||
| 54 | return str(client[CLIENT_KEY_INDEX]) | ||
| 55 | |||
| 56 | |||
| 57 | def add_client(alias): | ||
| 58 | if not alias_exists(alias): | ||
| 59 | key = pyotp.random_base32(length=64) | ||
| 60 | conn = sqlite3.connect(DB_NAME) | ||
| 61 | c = conn.cursor() | ||
| 62 | c.execute("INSERT INTO clients VALUES (?,?)",(alias,key)) | ||
| 63 | conn.commit() | ||
| 64 | conn.close() | ||
| 65 | print("key: " + key) | ||
| 66 | else: | ||
| 67 | die("Error: alias already used") | ||
| 68 | |||
| 69 | |||
| 70 | def add_app(username, hostname, service, alias, mfa_methods): | ||
| 71 | if not alias_exists(alias): | ||
| 72 | die("Error: alias does not exist") | ||
| 73 | else: | ||
| 74 | client_key = get_client_key(alias) | ||
| 75 | conn = sqlite3.connect(DB_NAME) | ||
| 76 | c = conn.cursor() | ||
| 77 | c.execute("INSERT INTO applications VALUES (?,?,?,?,?)", | ||
| 78 | (username,hostname,service,alias,mfa_methods)) | ||
| 79 | conn.commit() | ||
| 80 | conn.close() | ||
| 81 | |||
| 82 | |||
| 83 | def main(): | ||
| 84 | args = parse_arguments() | ||
| 85 | # Sanity checks | ||
| 86 | if (args.add_client and args.add_app) or (args.add_client and args.get_client) \ | ||
| 87 | or (args.get_client and args.add_app): | ||
| 88 | die("Error: cannot specify multiple actions") | ||
| 89 | if args.add_client and args.alias == None: | ||
| 90 | die("Error: must specify alias to provision a client") | ||
| 91 | if args.get_client and args.alias == None: | ||
| 92 | die("Error: no alias specified") | ||
| 93 | if args.add_app and (args.user == None or args.host == None or \ | ||
| 94 | args.service == None or args.alias == None \ | ||
| 95 | or args.methods == None): | ||
| 96 | die("Error: --add-app requires all of --user,--host,--service,--alias,--methods") | ||
| 97 | |||
| 98 | if args.add_client: | ||
| 99 | add_client(args.alias) | ||
| 100 | elif args.get_client: | ||
| 101 | key = get_client_key(args.alias) | ||
| 102 | print(key) | ||
| 103 | elif args.add_app: | ||
| 104 | methods = " ".join(args.methods) | ||
| 105 | add_app(args.user,args.host,args.service,args.alias,methods) | ||
| 106 | |||
| 107 | |||
| 108 | |||
| 109 | |||
| 110 | if __name__ == '__main__': | ||
| 111 | main() | ||
| 112 | |||
| 113 | |||
diff --git a/server/mfad.py b/server/mfad.py new file mode 100755 index 0000000..7a2fc40 --- /dev/null +++ b/server/mfad.py | |||
| @@ -0,0 +1,246 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import socket | ||
| 3 | import os | ||
| 4 | import sys | ||
| 5 | import time | ||
| 6 | import threading | ||
| 7 | import pyotp | ||
| 8 | import sqlite3 | ||
| 9 | import re | ||
| 10 | |||
| 11 | ## Listens for authentication request from PAM module | ||
| 12 | ## Recevies connection from client | ||
| 13 | ## Correlates authentication request to client connection | ||
| 14 | ## Sends MFA prompt to client | ||
| 15 | ## Evaluates response from client | ||
| 16 | ## Return pass or fail response to PAM moudle | ||
| 17 | |||
| 18 | |||
| 19 | DB_NAME = "mfa.db" | ||
| 20 | HEADER_LENGTH = 64 | ||
| 21 | KEY_LENGTH = 64 | ||
| 22 | DISCONNECT_LENGTH = ACK_LENGTH = 3 | ||
| 23 | ACK_MESSAGE = "ACK" | ||
| 24 | DISCONNECT_MESSAGE = "BYE" | ||
| 25 | FORMAT = "utf-8" | ||
| 26 | AUTHED = 0 | ||
| 27 | DENIED = 1 | ||
| 28 | |||
| 29 | # Stores connected clients as a dictionary with the client key as the dictionary | ||
| 30 | # key and a tuple of (socket,(addr,port)) as the value | ||
| 31 | client_connections = dict() | ||
| 32 | |||
| 33 | |||
| 34 | def eval_mfa(mfa_methods,client_response): | ||
| 35 | # Evaluates MFA and decides if authenticated or denied | ||
| 36 | # Returns 0 for authenticated on 1 for denied | ||
| 37 | if "push" in mfa_methods and client_response == "allow": | ||
| 38 | return AUTHED | ||
| 39 | elif "totp" in mfa_methods and len(client_response) == 6: | ||
| 40 | # Only attempt to validate if response is a valid TOTP format | ||
| 41 | totp_format = (r'(\d)(\d)(\d)(\d)(\d)(\d)') | ||
| 42 | totp_regex = re.compile(totp_format) | ||
| 43 | matched = totp_regex.match(client_response) | ||
| 44 | if matched: | ||
| 45 | return validate_totp(int(client_response)) | ||
| 46 | return DENIED | ||
| 47 | |||
| 48 | |||
| 49 | def validate_totp(client_response): | ||
| 50 | pass | ||
| 51 | |||
| 52 | |||
| 53 | ################################################################################ | ||
| 54 | |||
| 55 | # Client is registered by admin with secret key stored on server | ||
| 56 | # Client is provisioned with secret key and passes secret key to server on | ||
| 57 | # connection for identification | ||
| 58 | # Client key is used to identify client throughout communication process | ||
| 59 | |||
| 60 | # //TODO RSA public/private key pairs for proper authentication | ||
| 61 | |||
| 62 | def get_client_key(username,hostname,service): | ||
| 63 | # Correlates a PAM request to a registered client | ||
| 64 | # This is done by checking the PAM request against a preconfigured | ||
| 65 | # database mapping request info (username,hostname,etc...) to clients | ||
| 66 | # Returns a tuple consisting of the key and approved MFA methods | ||
| 67 | DB_USERNAME_INDEX = 0 | ||
| 68 | DB_HOSTNAME_INDEX = 1 | ||
| 69 | DB_SERVICE_INDEX = 2 | ||
| 70 | DB_ALIAS_INDEX = 3 | ||
| 71 | DB_MFAMETHODS_INDEX = 4 | ||
| 72 | |||
| 73 | CLIENT_ALIAS_INDEX = 0 | ||
| 74 | CLIENT_KEY_INDEX = 1 | ||
| 75 | |||
| 76 | conn = sqlite3.connect(DB_NAME) | ||
| 77 | c = conn.cursor() | ||
| 78 | c.execute("""SELECT * FROM applications WHERE username=? AND hostname=? | ||
| 79 | AND service=?""",(username,hostname,service)) | ||
| 80 | application = c.fetchone() | ||
| 81 | # Return None if no results found | ||
| 82 | if application == None: | ||
| 83 | return application | ||
| 84 | |||
| 85 | alias = application[DB_ALIAS_INDEX] | ||
| 86 | c.execute("SELECT * FROM clients WHERE alias=?",(alias,)) | ||
| 87 | client = c.fetchone() | ||
| 88 | conn.close() | ||
| 89 | client_key = client[CLIENT_KEY_INDEX] | ||
| 90 | methods = application[DB_MFAMETHODS_INDEX] | ||
| 91 | return (client_key,methods) | ||
| 92 | |||
| 93 | |||
| 94 | |||
| 95 | def prompt_client(client_key, user, host, service, methods, timeout=10): | ||
| 96 | # Prompts client for MFA | ||
| 97 | timer = 0 | ||
| 98 | while timer < timeout: | ||
| 99 | if client_key in client_connections.keys(): | ||
| 100 | conn = client_connections[client_key][0] | ||
| 101 | # Use try block to catch cases where client was connected and so | ||
| 102 | # is in list but is not currently connected | ||
| 103 | try: | ||
| 104 | # send prompts | ||
| 105 | methodstr = ", ".join(methods) | ||
| 106 | methodstr = "Available methods: " + methodstr | ||
| 107 | prompt_msg = "Login approved for user '" + user + \ | ||
| 108 | "' attempting to access service '" + service + \ | ||
| 109 | "' on host '" + host + "'?\n" + methodstr | ||
| 110 | prompt_len = len(prompt_msg) | ||
| 111 | length_msg = str(prompt_len) | ||
| 112 | length_msg += ' ' * (HEADER_LENGTH - len(length_msg)) | ||
| 113 | conn.send(length_msg.encode(FORMAT)) | ||
| 114 | conn.send(prompt_msg.encode(FORMAT)) | ||
| 115 | # receive response | ||
| 116 | response_length = int(conn.recv(HEADER_LENGTH).decode(FORMAT)) | ||
| 117 | response = conn.recv(response_length).decode(FORMAT) | ||
| 118 | return response | ||
| 119 | except BrokenPipeError: | ||
| 120 | client_connections.pop(client_key) | ||
| 121 | timer += 1 | ||
| 122 | time.sleep(1) | ||
| 123 | else: | ||
| 124 | timer +=1 | ||
| 125 | time.sleep(1) | ||
| 126 | |||
| 127 | return 0 | ||
| 128 | |||
| 129 | |||
| 130 | def validate_client(client_key): | ||
| 131 | # Validates a client | ||
| 132 | conn = sqlite3.connect(DB_NAME) | ||
| 133 | c = conn.cursor() | ||
| 134 | c.execute("SELECT * FROM clients WHERE key=?",(client_key,)) | ||
| 135 | client = c.fetchall() | ||
| 136 | conn.close() | ||
| 137 | if len(client) == 0: | ||
| 138 | # No client matches provided key, invalid | ||
| 139 | return False | ||
| 140 | elif len(client) == 1: | ||
| 141 | return True | ||
| 142 | else: | ||
| 143 | print("A strange error has occurred") | ||
| 144 | return False | ||
| 145 | |||
| 146 | |||
| 147 | |||
| 148 | def handle_client(conn, addr): | ||
| 149 | # Receive key from client | ||
| 150 | key = conn.recv(KEY_LENGTH).decode(FORMAT) | ||
| 151 | # Validate client | ||
| 152 | if not validate_client(key): | ||
| 153 | print("WARNING: client attempted to connect with invalid key") | ||
| 154 | conn.send(DISCONNECT_MESSAGE.encode(FORMAT)) | ||
| 155 | conn.close() | ||
| 156 | else: | ||
| 157 | conn.send(ACK_MESSAGE.encode(FORMAT)) | ||
| 158 | client_connections[key] = (conn,addr) | ||
| 159 | print("client connected with key " + key) | ||
| 160 | |||
| 161 | |||
| 162 | def parse_pam_data(data): | ||
| 163 | # Parses pam data and returns (user,host,service) tuple | ||
| 164 | return tuple(data.split(',')) | ||
| 165 | |||
| 166 | def handle_pam(conn, addr): | ||
| 167 | # Get request and data from PAM module | ||
| 168 | data_length = int(conn.recv(HEADER_LENGTH).decode(FORMAT)) | ||
| 169 | pam_data = conn.recv(data_length).decode(FORMAT) | ||
| 170 | print("Got pam_data: " + pam_data) | ||
| 171 | user,host,service = parse_pam_data(pam_data) | ||
| 172 | |||
| 173 | # Correlate request to client | ||
| 174 | client_key,mfa_methods = get_client_key(user,host,service) | ||
| 175 | mfa_methods = mfa_methods.split(',') | ||
| 176 | if client_key == None: | ||
| 177 | print("No applications found for user="+user+" host="+host+" service="+service) | ||
| 178 | conn.send(str(DENIED).encode(FORMAT)) | ||
| 179 | return | ||
| 180 | |||
| 181 | # Prompt client | ||
| 182 | response = prompt_client(client_key,user,host,service,mfa_methods) | ||
| 183 | |||
| 184 | # Evaluate Response | ||
| 185 | auth_type = "push" | ||
| 186 | decision = eval_mfa(auth_type, response) | ||
| 187 | |||
| 188 | # Return response to PAM module | ||
| 189 | # Respone will either be 0 for authenticated and 1 for denied | ||
| 190 | conn.send(str(decision).encode(FORMAT)) | ||
| 191 | |||
| 192 | |||
| 193 | def listen_client(addr, port): | ||
| 194 | with socket.create_server((addr, port)) as server: | ||
| 195 | while True: | ||
| 196 | conn, addr = server.accept() | ||
| 197 | thread = threading.Thread(target=handle_client,args=(conn,addr)) | ||
| 198 | thread.start() | ||
| 199 | |||
| 200 | |||
| 201 | def listen_pam(addr, port): | ||
| 202 | with socket.create_server((addr,port)) as pam_server: | ||
| 203 | while True: | ||
| 204 | conn, addr = pam_server.accept() | ||
| 205 | thread = threading.Thread(target=handle_pam,args=(conn,addr)) | ||
| 206 | thread.start() | ||
| 207 | |||
| 208 | |||
| 209 | ################################################################################ | ||
| 210 | |||
| 211 | def create_db(): | ||
| 212 | conn = sqlite3.connect(DB_NAME) | ||
| 213 | c = conn.cursor() | ||
| 214 | c.execute("""CREATE TABLE applications ( | ||
| 215 | username text, | ||
| 216 | hostname text, | ||
| 217 | service text, | ||
| 218 | client_key text, | ||
| 219 | mfa_methods text | ||
| 220 | )""") | ||
| 221 | conn.commit() | ||
| 222 | c.execute("""CREATE TABLE clients ( | ||
| 223 | alias, | ||
| 224 | key | ||
| 225 | )""") | ||
| 226 | conn.commit() | ||
| 227 | conn.close() | ||
| 228 | |||
| 229 | |||
| 230 | def main(): | ||
| 231 | global connection_list | ||
| 232 | bind_addr = "127.0.0.1" | ||
| 233 | pam_port = 8000 | ||
| 234 | client_port = 8001 | ||
| 235 | |||
| 236 | if not os.path.exists(DB_NAME): | ||
| 237 | create_db() | ||
| 238 | |||
| 239 | clients = threading.Thread(target=listen_client,args=(bind_addr,client_port)) | ||
| 240 | pam = threading.Thread(target=listen_pam,args=(bind_addr,pam_port)) | ||
| 241 | clients.start() | ||
| 242 | pam.start() | ||
| 243 | |||
| 244 | |||
| 245 | if __name__ == '__main__': | ||
| 246 | main() | ||
