Feat(Chore): Verify Flow, Invites, Suspend, Depart Cert Policy

feat: add password reset and email verification flow
feat: add org invite listing, cancellation, and invite link fallback
feat: add user suspend/unsuspend with audit logging
feat: add department certificate policy (expiry, extensions)
feat: enforce dept cert policy on SSH certificate signing
feat: wire up OIDC consent and token flow (replace mocks)
feat: rework CLI auth bridge to use frontend login flow
feat: add admin OAuth provider management (CRUD)
chore: refactor model import paths after module reorganisation
chore: clean up config, decorators, and dev tooling
This commit is contained in:
2026-03-01 16:50:27 +05:45
parent 07193a2d2e
commit a0d4e59c24
39 changed files with 2035 additions and 611 deletions
+187 -219
View File
@@ -1,6 +1,5 @@
#!/usr/bin/python3
import base64
from datetime import datetime
import os
import sys
import webbrowser
@@ -17,7 +16,6 @@ from sshkey_tools.cert import SSHCertificate
import logging
import coloredlogs
import subprocess
import base64
# Load environment variables from the .env file
load_dotenv()
@@ -36,11 +34,18 @@ CHALLENGE_SIG_FILE_PATH = "/tmp/challenge.txt.sig"
logger = logging.getLogger(__name__)
coloredlogs.install(level='DEBUG', logger=logger, fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
token = ""
def auth_headers(content_type="application/json"):
"""Return auth headers using the current cached token."""
return {"Authorization": f"Bearer {token}", "Content-Type": content_type}
class MyServer(BaseHTTPRequestHandler):
def do_GET(self):
"""Handle GET requests and process token reception."""
global server_done, token
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
@@ -49,12 +54,13 @@ class MyServer(BaseHTTPRequestHandler):
self.wfile.write(bytes("<p>Window closing in <span id='countdown'>5</span> seconds...</p>", "utf-8"))
self.wfile.write(bytes("<script>var count = 5; setInterval(function() { count--; document.getElementById('countdown').textContent = count; if (count === 0) window.close(); }, 1000);</script>", "utf-8"))
self.wfile.write(bytes("</body></html>", "utf-8"))
parsed_url = urlparse(self.path)
query_data = dict(parse_qsl(parsed_url.query))
token = query_data.get('token')
received_token = query_data.get('token')
if token:
if received_token:
token = received_token
server_done = True
logger.info("Token received")
save_token_to_cache(token)
@@ -87,37 +93,42 @@ def clear_token_cache():
logger.info("No cached token found.")
def decode_and_validate_token(token):
"""Decode the JWT and validate its claims."""
"""Decode the JWT and validate its claims.
Returns True if the token is a valid, non-expired JWT.
Returns False if the token is not a JWT (e.g. opaque session token)
or if it has expired — callers should then fall back to /auth/me.
"""
try:
decoded_token = jwt.decode(token, options={"verify_signature": False})
logger.debug("debug_jwt - Decoded token ok")
iat = decoded_token.get('iat')
exp = decoded_token.get('exp')
if iat is None or exp is None:
raise ValueError("Token must contain 'iat' and 'exp' claims.")
iat_utc = datetime.datetime.fromtimestamp(iat, pytz.UTC).isoformat()
exp_utc = datetime.datetime.fromtimestamp(exp, pytz.UTC).isoformat()
logger.debug(f"debug_jwt - iat (UTC ISO): {iat_utc}")
logger.debug(f"debug_jwt - exp (UTC ISO): {exp_utc}")
now = datetime.datetime.now(pytz.UTC)
if datetime.datetime.fromtimestamp(iat, pytz.UTC) > now:
logger.debug(f"debug_jwt - Token 'iat' is after the current time.")
if datetime.datetime.fromtimestamp(exp, pytz.UTC) < now:
logger.debug(f"debug_jwt - Token 'exp' is before the current time.")
return False # Token has expired
return True # Token is valid
except Exception as e:
logger.debug(f"Token validation failed: {e}")
except jwt.exceptions.DecodeError:
# Not a JWT — likely an opaque session token; let /auth/me handle it.
return False
except Exception as e:
logger.debug(f"Unexpected JWT decode error: {e}")
return False
iat = decoded_token.get('iat')
exp = decoded_token.get('exp')
if iat is None or exp is None:
logger.debug("JWT is missing 'iat' or 'exp' claims — treating as invalid.")
return False
now = datetime.datetime.now(pytz.UTC)
exp_dt = datetime.datetime.fromtimestamp(exp, pytz.UTC)
iat_dt = datetime.datetime.fromtimestamp(iat, pytz.UTC)
logger.debug(f"JWT iat={iat_dt.isoformat()} exp={exp_dt.isoformat()}")
if exp_dt < now:
logger.debug("JWT has expired.")
return False
if iat_dt > now:
logger.debug("JWT 'iat' is in the future — clock skew?")
return True
def request_token():
global server_done, token
@@ -177,57 +188,80 @@ def request_token():
return token
def get_activated_ssh_key():
"""Retrieve the list of SSH keys and return the first verified key."""
"""Retrieve the list of SSH keys and return the ID of a verified key."""
try:
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
response = requests.get(f"{SIGN_URL}/api/v1/ssh/keys", headers=headers)
if response.status_code == 200:
keys = response.json().get('keys', [])
verified_keys = [key for key in keys if key['verified']]
if not verified_keys:
logger.error("No verified SSH keys found for the user.")
exit(1)
if len(verified_keys) > 1:
# If running interactively, let the user pick; otherwise use the most recently added key
if sys.stdout.isatty():
print("\nMultiple verified SSH keys found. Please choose one:")
for i, k in enumerate(verified_keys):
print(f" [{i+1}] {k['id'][:8]}... fingerprint={k.get('fingerprint','?')} name={k.get('key_comment','?')}")
try:
choice = int(input("Enter number: ").strip()) - 1
if 0 <= choice < len(verified_keys):
return verified_keys[choice]['id']
except (ValueError, EOFError):
pass
logger.info("Multiple verified SSH keys found; using the most recently added one.")
verified_keys.sort(key=lambda k: k.get('created_at', ''), reverse=True)
return verified_keys[0]['id']
else:
response = requests.get(f"{SIGN_URL}/api/v1/ssh/keys", headers=auth_headers())
if response.status_code != 200:
logger.error(f"Failed to retrieve SSH keys: {response.status_code} - {response.text}")
exit(1)
keys = response.json().get('data', {}).get('keys', [])
verified_keys = [k for k in keys if k['verified']]
if not verified_keys:
logger.error("No verified SSH keys found for the user.")
exit(1)
if len(verified_keys) > 1 and sys.stdout.isatty():
print("\nMultiple verified SSH keys found. Please choose one:")
for i, k in enumerate(verified_keys):
print(f" [{i+1}] {k['id'][:8]}... fingerprint={k.get('fingerprint','?')} name={k.get('key_comment','?')}")
try:
choice = int(input("Enter number: ").strip()) - 1
if 0 <= choice < len(verified_keys):
return verified_keys[choice]['id']
except (ValueError, EOFError):
pass
logger.info("Invalid choice; using the most recently added key.")
verified_keys.sort(key=lambda k: k.get('created_at', ''), reverse=True)
return verified_keys[0]['id']
except SystemExit:
raise
except Exception as e:
logger.error(f"Error while retrieving SSH keys: {e}")
exit(1)
def request_certificate(principals=None):
def fetch_my_principals():
"""Fetch all principal names the current user is entitled to from the API.
For regular members: returns their assigned principals.
For org admins/owners: returns all principals in the org (they can sign for any).
"""
global token
response = requests.get(
f"{SIGN_URL}/api/v1/users/me/principals",
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
if response.status_code != 200:
logger.error(f"Failed to fetch principals from server: {response.status_code} - {response.text}")
exit(1)
orgs = response.json().get("data", {}).get("orgs", [])
principal_names = []
for org in orgs:
# Admins/owners get all principals; regular members get only their assigned ones
if org.get("is_admin"):
source = org.get("all_principals", [])
else:
source = org.get("my_principals", [])
for p in source:
if p["name"] not in principal_names:
principal_names.append(p["name"])
return principal_names
def request_certificate():
CERT_ID = os.getenv("CERT_ID") or get_activated_ssh_key()
principals = fetch_my_principals()
if not principals:
env_principals = os.getenv("PRINCIPALS")
if env_principals:
principals = [p.strip() for p in env_principals.split(',')]
else:
principals = [os.getlogin()]
logger.error("You have no principals assigned. Contact your org admin.")
exit(1)
logger.info(f"Requesting certificate for principals: {', '.join(principals)}")
headers = {
'content-type': 'application/json',
@@ -243,7 +277,7 @@ def request_certificate(principals=None):
response = requests.post(f"{SIGN_URL}/api/v1/ssh/sign", json=payload, headers=headers)
if response.status_code == 201:
json_result = response.json()
json_result = response.json().get('data', response.json())
with open(CERT_FILE_PATH, 'w') as f:
f.write(json_result['certificate'])
logger.info(f"Certificate signed successfully, located at {CERT_FILE_PATH}")
@@ -257,124 +291,84 @@ def request_certificate(principals=None):
except Exception as e:
logger.error(f"Error during certificate signing: {e}")
def generate_and_sign_challenge(ssh_key_file,key_id):
"""Generate a challenge text, sign it using the SSH key, and return the signature."""
def generate_and_sign_challenge(ssh_key_file, key_id):
"""Fetch a challenge from the server, sign it with the SSH key, and submit the signature."""
logger.debug(f"generate_and_sign_challenge - {ssh_key_file} {key_id}")
#Fetch challenge text from API
try:
global token
if not token:
raise EnvironmentError("TOKEN environment variable is not set")
headers = {
'Authorization': f'Bearer {token}',
"Content-Type": "application/json",
}
# Send the POST request
response = requests.get(
f"{SIGN_URL}/api/v1/ssh/keys/{key_id}/verify",
headers=headers
)
if response.status_code!=200:
# Fetch challenge text
try:
response = requests.get(f"{SIGN_URL}/api/v1/ssh/keys/{key_id}/verify", headers=auth_headers())
if response.status_code != 200:
logger.error(f"Server returned unexpected code {response.status_code}")
return False
challenge_text=response.json().get('challenge_text', response.json().get('validationText', ''))+"\n"
resp_json = response.json()
data = resp_json.get('data', resp_json)
challenge_text = data.get('challenge_text', data.get('validationText', '')) + "\n"
except Exception as e:
logger.error(f"Unable to fetch SSH Key validation data {e}")
logger.error(f"Unable to fetch SSH Key validation data: {e}")
return False
# Sign the challenge
try:
logger.debug(f"generate_and_sign_challenge - procesing challenge with text {challenge_text}")
if os.path.exists(CHALLENGE_FILE_PATH):
os.remove(CHALLENGE_FILE_PATH)
if os.path.exists(CHALLENGE_SIG_FILE_PATH):
os.remove(CHALLENGE_SIG_FILE_PATH)
for path in (CHALLENGE_FILE_PATH, CHALLENGE_SIG_FILE_PATH):
if os.path.exists(path):
os.remove(path)
with open(CHALLENGE_FILE_PATH, 'w') as f:
f.write(challenge_text)
# Sign the challenge text using the SSH key
result=subprocess.run(["ssh-keygen", "-Y", "sign", "-f", ssh_key_file, "-n", "file", CHALLENGE_FILE_PATH], check=True)
logger.debug(f"generate_and_sign_challenge - {result}")
# Read the signature
with open(CHALLENGE_SIG_FILE_PATH, 'rb') as f:
signature = base64.b64encode(f.read()).decode('utf-8')
submit_signature_validation(signature,key_id)
return signature
except Exception as e:
logger.error(f"Unable to sign the challenge reponse {e}")
def submit_signature_validation(signature, key_id):
try:
# Define the headers and payload
global token
if not token:
raise EnvironmentError("TOKEN environment variable is not set")
headers = {
'Authorization': f'Bearer {token}',
"Content-Type": "application/json",
}
logger.debug(f"submit_signature_validation - {signature}")
payload = {
"signature": signature
}
# Send the POST request
response = requests.post(
f"{SIGN_URL}/api/v1/ssh/keys/{key_id}/verify",
headers=headers,
json=payload
subprocess.run(
["ssh-keygen", "-Y", "sign", "-f", ssh_key_file, "-n", "file", CHALLENGE_FILE_PATH],
check=True,
)
# Print the response
print(response.status_code)
print(response.text)
with open(CHALLENGE_SIG_FILE_PATH, 'rb') as f:
signature = base64.b64encode(f.read()).decode('utf-8')
except Exception as e:
logger.error(f"submit_signature_validation - Unable to submit the challenge response {e}")
logger.error(f"Unable to sign the challenge response: {e}")
return False
# Submit signature
try:
response = requests.post(
f"{SIGN_URL}/api/v1/ssh/keys/{key_id}/verify",
headers=auth_headers(),
json={"signature": signature},
)
if response.status_code == 200:
logger.info("SSH key verified successfully.")
else:
logger.error(f"Verification failed: {response.status_code} - {response.text}")
except Exception as e:
logger.error(f"Unable to submit the challenge response: {e}")
return signature
def remove_ssh_key(key_id=None):
"""
Remove an SSH key from the server. If key_id is None, list keys and prompt user to pick one.
"""
global token
if not token:
raise EnvironmentError("TOKEN environment variable is not set")
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
# List keys first
response = requests.get(f"{SIGN_URL}/api/v1/ssh/keys", headers=headers)
response = requests.get(f"{SIGN_URL}/api/v1/ssh/keys", headers=auth_headers())
if response.status_code != 200:
logger.error(f"Failed to list SSH keys: {response.status_code} - {response.text}")
exit(1)
keys = response.json().get('keys', [])
keys = response.json().get('data', {}).get('keys', [])
if not keys:
logger.info("No SSH keys found for your user.")
return
if key_id:
# Delete specific key
target = next((k for k in keys if k['id'] == key_id), None)
if not target:
logger.error(f"Key ID {key_id} not found in your profile.")
exit(1)
keys_to_delete = [target]
else:
# Show all keys and let user pick
print("\nYour SSH keys:")
for i, k in enumerate(keys):
verified = "✓ verified" if k['verified'] else "✗ unverified"
print(f" [{i+1}] {k['id']} {verified} {k.get('description','')} (added {k['created_at'][:10]})")
print(f" [{i+1}] {k['id']} {verified} {k.get('description', '')} (added {k['created_at'][:10]})")
print(" [a] Delete ALL keys")
print(" [q] Quit")
choice = input("\nEnter number to delete (or 'a' for all, 'q' to quit): ").strip().lower()
@@ -394,7 +388,7 @@ def remove_ssh_key(key_id=None):
exit(1)
for k in keys_to_delete:
del_response = requests.delete(f"{SIGN_URL}/api/v1/ssh/keys/{k['id']}", headers=headers)
del_response = requests.delete(f"{SIGN_URL}/api/v1/ssh/keys/{k['id']}", headers=auth_headers())
if del_response.status_code == 200:
logger.info(f"Key {k['id']} removed successfully.")
else:
@@ -402,56 +396,36 @@ def remove_ssh_key(key_id=None):
def add_ssh_key(ssh_key_file):
"""
Add an SSH key to the server.
Args:
ssh_key_file (file): The SSH key file to be added.
"""
global token
if not token:
raise EnvironmentError("TOKEN environment variable is not set")
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
"""Add an SSH key to the server and auto-verify it."""
if hasattr(ssh_key_file, 'read'):
key_bytes = ssh_key_file.read()
key_path = ssh_key_file.name
elif isinstance(ssh_key_file, bytes):
key_bytes = ssh_key_file
key_path = None
else:
key_path = str(ssh_key_file)
with open(key_path, 'rb') as f:
key_bytes = f.read()
if hasattr(ssh_key_file, 'read'):
# File object (e.g. argparse.FileType('rb'))
key_bytes = ssh_key_file.read()
key_path = ssh_key_file.name
elif isinstance(ssh_key_file, bytes):
key_bytes = ssh_key_file
key_path = None
else:
# String path
key_path = str(ssh_key_file)
with open(key_path, 'rb') as f:
key_bytes = f.read()
ssh_key = key_bytes.decode('utf-8').strip()
payload = {
'description': 'Added via gatehouse CLI tool',
'key': ssh_key,
}
ssh_key = key_bytes.decode('utf-8').strip()
payload = {
'description': 'Added via gatehouse CLI tool',
'key': ssh_key
}
response = requests.post(f"{SIGN_URL}/api/v1/ssh/keys", json=payload, headers=headers)
if response.status_code == 201:
ssh_key_id=response.json()['id']
logger.info(f"SSH key {ssh_key_id} added successfully")
if key_path:
# Strip .pub suffix to get the private key path for signing
private_key_path = key_path[:-4] if key_path.endswith('.pub') else key_path
generate_and_sign_challenge(private_key_path, ssh_key_id)
else:
logger.warning("No key file path available — skipping auto-verification. "
"Run with -k <path> to enable automatic key verification.")
else:
logger.error(f"Failed to add SSH key: {response.status_code} - {response.text}")
response = requests.post(f"{SIGN_URL}/api/v1/ssh/keys", json=payload, headers=auth_headers())
if response.status_code == 201:
ssh_key_id = response.json().get('data', {}).get('id')
logger.info(f"SSH key {ssh_key_id} added successfully")
if key_path:
private_key_path = key_path[:-4] if key_path.endswith('.pub') else key_path
generate_and_sign_challenge(private_key_path, ssh_key_id)
else:
logger.warning("No key file path available — skipping auto-verification. "
"Run with -k <path> to enable automatic key verification.")
else:
logger.error(f"Failed to add SSH key: {response.status_code} - {response.text}")
def checkCert():
logger.info("Running cert check")
@@ -459,8 +433,11 @@ def checkCert():
logger.warning("Certificate does not exist, new certificate required")
return 1
# Check the current cert first
certificate = SSHCertificate.from_file(CERT_FILE_PATH)
try:
certificate = SSHCertificate.from_file(CERT_FILE_PATH)
except Exception:
logger.warning("Certificate file is invalid or corrupt, renewal required")
return 1
# Get the current datetime
now = datetime.datetime.now()
@@ -486,7 +463,6 @@ if __name__ == "__main__":
parser.add_argument("-a", "--add-key", action='store_true', default=False, help="Add SSH key to the server")
parser.add_argument("-c", "--check-cert", action='store_true', default=False, help="Check the certificate, if it's valid exit 0, if it's invalid exit 1")
parser.add_argument("-r", "--request-cert", action='store_true', default=False, help="Request that gatehouse sign a new certificate for you based on an SSH public key on file in your profile")
parser.add_argument("--principals", nargs='+', metavar='PRINCIPAL', help="Unix usernames for the certificate (default: current OS user)")
parser.add_argument("--clear-cache", action='store_true', default=False, help="Remove the cached authentication token")
parser.add_argument("--remove-key", nargs='?', const='', metavar='KEY_ID', help="Remove an SSH key from your profile. Omit KEY_ID to pick interactively.")
parser.add_argument("--list-keys", action='store_true', default=False, help="List SSH keys in your profile")
@@ -515,13 +491,9 @@ if __name__ == "__main__":
if args.list_keys:
request_token()
response = requests.get(
f"{SIGN_URL}/api/v1/ssh/keys",
headers={"Authorization": f"Bearer {token}"},
)
response = requests.get(f"{SIGN_URL}/api/v1/ssh/keys", headers=auth_headers())
if response.status_code == 200:
data = response.json()
keys = data.get('keys', [])
keys = response.json().get('data', {}).get('keys', [])
if not keys:
print("No SSH keys found in your profile.")
else:
@@ -548,14 +520,10 @@ if __name__ == "__main__":
exit(0)
if args.request_cert:
if args.request_cert:
request_token()
if args.force:
request_token()
logger.info("Forcing renewal of certificate")
request_certificate(principals=args.principals)
if checkCert() == 1:
request_token()
request_certificate(principals=args.principals)
if args.force or checkCert() == 1:
request_certificate()
exit(0)