feat(auth): Add OAuth2 authentication
This commit is contained in:
9
secnex/app/__init__.py
Normal file
9
secnex/app/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .auth import auth_app
|
||||
from .application import application_app
|
||||
from .config import config_app
|
||||
from .query import query_app
|
||||
from .tenant import tenant_app
|
||||
from .utils import utils_app
|
||||
from .user import user_app
|
||||
|
||||
__all__ = ["auth_app", "application_app", "config_app", "query_app", "tenant_app", "utils_app", "user_app"]
|
||||
65
secnex/app/application.py
Normal file
65
secnex/app/application.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import typer
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.prompt import Prompt
|
||||
|
||||
import secnex.utils as utils
|
||||
|
||||
from secnex.kit.auth.auth import Auth
|
||||
from secnex.kit.config.config import Config
|
||||
|
||||
from secnex.kit.clients.application import ApplicationClient
|
||||
|
||||
console = Console()
|
||||
|
||||
application_app = typer.Typer()
|
||||
|
||||
@application_app.command(name="create", help="Create a new application")
|
||||
def create(
|
||||
name: Optional[str] = typer.Option(None, "--name", "-n", help="The name of the application"),
|
||||
tenant: Optional[str] = typer.Option(None, "--tenant", "-t", help="The tenant to set for the application"),
|
||||
):
|
||||
"""Create a new application"""
|
||||
if name is None:
|
||||
name = Prompt.ask("Enter the name of the application")
|
||||
if tenant is None:
|
||||
tenant = Prompt.ask("Enter the ID of the tenant")
|
||||
config = Config("default")
|
||||
application_client = ApplicationClient(config)
|
||||
secret = utils.generate_secret()
|
||||
app_id, status_code, success = application_client.create(name, tenant, secret)
|
||||
if not success:
|
||||
console.print(f"Failed to create application! Please check the logs for more information.", style="bold red")
|
||||
return
|
||||
console.print(f"Application created successfully!", style="bold green")
|
||||
console.print(f"Application ID: {app_id}", style="bold green")
|
||||
console.print(f"Secret: {secret}", style="bold green")
|
||||
|
||||
@application_app.command(name="ls", help="List all applications")
|
||||
def ls():
|
||||
"""List all applications"""
|
||||
config = Config("default")
|
||||
application_client = ApplicationClient(config)
|
||||
applications, status_code, success = application_client.get_all()
|
||||
if applications is None:
|
||||
console.print(f"No applications found!", style="bold red")
|
||||
return
|
||||
count_applications = len(applications)
|
||||
console.print(f"Found {count_applications} applications")
|
||||
table = Table(title="Applications")
|
||||
table.add_column("ID", style="bold green")
|
||||
table.add_column("Name", style="bold yellow")
|
||||
table.add_column("Tenant ID", style="bold purple")
|
||||
for app in applications:
|
||||
table.add_row(app.id, app.name, app.tenant_id)
|
||||
console.print(table)
|
||||
|
||||
@application_app.command(name="rm", help="Remove an application")
|
||||
def rm(id: str):
|
||||
"""Remove an application"""
|
||||
config = Config("default")
|
||||
application_client = ApplicationClient(config)
|
||||
application_client.remove(id)
|
||||
console.print(f"Application {id} removed successfully!")
|
||||
39
secnex/app/auth.py
Normal file
39
secnex/app/auth.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import typer
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.prompt import Prompt
|
||||
|
||||
from secnex.kit.auth.auth import Auth
|
||||
from secnex.kit.config.config import Config
|
||||
|
||||
console = Console()
|
||||
|
||||
auth_app = typer.Typer()
|
||||
|
||||
@auth_app.command(name="login", help="Login to the SecNex API")
|
||||
def login():
|
||||
"""Login to the SecNex API"""
|
||||
config = Config("default")
|
||||
auth = Auth(config)
|
||||
auth.login()
|
||||
|
||||
@auth_app.command(name="logout", help="Logout from the SecNex API")
|
||||
def logout():
|
||||
"""Logout from the SecNex API"""
|
||||
config = Config("default")
|
||||
auth = Auth(config)
|
||||
auth.delete_token()
|
||||
console.print(f"Logged out successfully!", style="bold green")
|
||||
|
||||
@auth_app.command(name="status", help="Check if user is already logged in")
|
||||
def status():
|
||||
"""Check if user is already logged in"""
|
||||
config = Config("default")
|
||||
auth = Auth(config)
|
||||
token = auth.get_token()
|
||||
if token is not None:
|
||||
console.print(f"User is logged in!", style="bold green")
|
||||
else:
|
||||
console.print(f"User is not logged in!", style="bold red")
|
||||
114
secnex/app/config.py
Normal file
114
secnex/app/config.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import typer
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.prompt import Prompt
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
console = Console()
|
||||
|
||||
config_app = typer.Typer()
|
||||
|
||||
@config_app.command()
|
||||
def create(
|
||||
name: Optional[str] = typer.Option(None, "--name", "-n", help="The name of the config"),
|
||||
client_id: Optional[str] = typer.Option(None, "--client-id", "-c", help="The ID of the client"),
|
||||
client_secret: Optional[str] = typer.Option(None, "--client-secret", "-s", help="The secret of the client"),
|
||||
tenant_id: Optional[str] = typer.Option(None, "--tenant-id", "-t", help="The ID of the tenant"),
|
||||
ui: Optional[str] = typer.Option(None, "--ui", "-u", help="The UI to use"),
|
||||
host: Optional[str] = typer.Option(None, "--host", "-h", help="The host of the SecNex API"),
|
||||
):
|
||||
"""Create a new config"""
|
||||
if name is None:
|
||||
name = "default"
|
||||
if host is None:
|
||||
host = Prompt.ask("Enter the host of the SecNex API", default="http://localhost:3003")
|
||||
if ui is None:
|
||||
ui = Prompt.ask("Enter the UI to use", default="http://localhost:3000")
|
||||
if client_id is None:
|
||||
client_id = Prompt.ask("Enter the ID of the client")
|
||||
if client_secret is None:
|
||||
client_secret = Prompt.ask("Enter the secret of the client")
|
||||
if tenant_id is None:
|
||||
tenant_id = Prompt.ask("Enter the ID of the tenant")
|
||||
if not os.path.exists(os.path.join(os.path.expanduser("~/.secnex"))):
|
||||
os.makedirs(os.path.join(os.path.expanduser("~/.secnex")))
|
||||
|
||||
if not os.path.exists(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json")):
|
||||
with open(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json"), "w") as f:
|
||||
json.dump({}, f)
|
||||
else:
|
||||
typer.echo(f"Config {name} already exists!", err=True, color=True)
|
||||
return
|
||||
|
||||
with open(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json"), "w") as f:
|
||||
json.dump({
|
||||
"host": host,
|
||||
"ui": ui,
|
||||
"client": {
|
||||
"id": client_id,
|
||||
"secret": client_secret,
|
||||
"tenant": tenant_id,
|
||||
}
|
||||
}, f)
|
||||
|
||||
typer.echo(f"Config {name} created successfully!", color=True)
|
||||
|
||||
@config_app.command(name="show", help="Show a config")
|
||||
def show(name: Optional[str] = typer.Option(None, "--name", "-n", help="The name of the config")):
|
||||
"""Show a config"""
|
||||
if name is None:
|
||||
name = "default"
|
||||
|
||||
if not os.path.exists(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json")):
|
||||
typer.echo(f"Config {name} does not exist!", err=True, color=True)
|
||||
return
|
||||
|
||||
with open(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json"), "r") as f:
|
||||
config = json.load(f)
|
||||
console.print(f"Config {name}: {config}", style="dim")
|
||||
|
||||
@config_app.command(name="ls", help="List all configs")
|
||||
def ls():
|
||||
"""List all configs"""
|
||||
if not os.path.exists(os.path.join(os.path.expanduser("~/.secnex"))):
|
||||
typer.echo("No configs found!", err=True, color=True)
|
||||
return
|
||||
|
||||
table = Table(title="Configs")
|
||||
table.add_column("Name", style="bold green")
|
||||
table.add_column("Path", style="bold yellow")
|
||||
|
||||
for file in os.listdir(os.path.join(os.path.expanduser("~/.secnex"))):
|
||||
if file.endswith(".json"):
|
||||
table.add_row(file.replace(".json", ""), os.path.join(os.path.expanduser("~/.secnex"), file))
|
||||
|
||||
console.print(table)
|
||||
|
||||
@config_app.command(name="rm", help="Remove a config")
|
||||
def rm(name: Optional[str] = typer.Option(None, "--name", "-n", help="The name of the config")):
|
||||
"""Remove a config"""
|
||||
if name is None:
|
||||
name = "default"
|
||||
|
||||
if not os.path.exists(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json")):
|
||||
typer.echo(f"Config {name} does not exist!", err=True, color=True)
|
||||
return
|
||||
|
||||
os.remove(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json"))
|
||||
typer.echo(f"Config {name} removed successfully!", color=True)
|
||||
|
||||
@config_app.command(name="path", help="Get the path of a config")
|
||||
def path(name: Optional[str] = typer.Option(None, "--name", "-n", help="The name of the config")):
|
||||
"""Get the path of a config"""
|
||||
if name is None:
|
||||
name = "default"
|
||||
|
||||
if not os.path.exists(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json")):
|
||||
typer.echo(f"Config {name} does not exist!", err=True, color=True)
|
||||
return
|
||||
|
||||
typer.echo(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json"), color=True)
|
||||
36
secnex/app/query.py
Normal file
36
secnex/app/query.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import typer
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.prompt import Prompt
|
||||
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import base64
|
||||
|
||||
import secnex.utils as utils
|
||||
|
||||
console = Console()
|
||||
|
||||
query_app = typer.Typer()
|
||||
|
||||
@query_app.command(name="tables", help="Create query to list all tables")
|
||||
def create_query_tables():
|
||||
"""Create query to list all tables"""
|
||||
q = """SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'public'"""
|
||||
console.print(f"Query: {q}", style="dim")
|
||||
return q
|
||||
|
||||
@query_app.command(name="new-apikey", help="Create query to create a new API key")
|
||||
def create_query_new_apikey():
|
||||
"""Create query to create a new API key"""
|
||||
secret = utils.generate_secret()
|
||||
secret_hash = utils.hash_secret(secret)
|
||||
id = uuid.uuid4()
|
||||
q = f"""INSERT INTO api_keys (id, key, enabled, created_at, updated_at) VALUES ('{id}', '{secret_hash}', true, NOW(), NOW())"""
|
||||
console.print(f"Query: {q}", style="dim")
|
||||
key = base64.b64encode(f"{id}:{secret}".encode()).decode()
|
||||
console.print(f"Key: {key}", style="dim")
|
||||
return key
|
||||
61
secnex/app/tenant.py
Normal file
61
secnex/app/tenant.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import typer
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.prompt import Prompt
|
||||
|
||||
from secnex.kit.config.config import Config
|
||||
|
||||
from secnex.kit.clients.tenant import TenantClient
|
||||
from secnex.kit.clients.user import UserClient
|
||||
|
||||
console = Console()
|
||||
|
||||
tenant_app = typer.Typer()
|
||||
|
||||
@tenant_app.command(name="create", help="Create a new tenant")
|
||||
def create(owner: Optional[str] = typer.Option(None, "--owner", "-o", help="The owner of the tenant")):
|
||||
"""Create a new tenant"""
|
||||
config = Config("default")
|
||||
tenant_client = TenantClient(config)
|
||||
name = Prompt.ask("Enter the name of the tenant")
|
||||
tenant_id, status_code, success = tenant_client.create(name)
|
||||
user_id = None
|
||||
if owner is not None and tenant_id is not None:
|
||||
user_client = UserClient(config)
|
||||
success, status_code = user_client.add_user_to_tenant(tenant_id, owner)
|
||||
if not success:
|
||||
console.print(f"Failed to add user to tenant! Please check the logs for more information.", style="bold red")
|
||||
return
|
||||
console.print(f"Tenant created successfully!", style="bold green")
|
||||
console.print(f"User added to tenant successfully!", style="bold green")
|
||||
|
||||
@tenant_app.command(name="ls", help="List all tenants")
|
||||
def ls():
|
||||
"""List all tenants"""
|
||||
config = Config("default")
|
||||
tenant_client = TenantClient(config)
|
||||
tenants = tenant_client.get_all()
|
||||
count_tenants = len(tenants)
|
||||
console.print(f"Found {count_tenants} tenants")
|
||||
if count_tenants == 0:
|
||||
return
|
||||
|
||||
table = Table(title="Tenants")
|
||||
table.add_column("ID", style="bold green")
|
||||
table.add_column("Name", style="bold yellow")
|
||||
table.add_column("Enabled", style="bold purple")
|
||||
table.add_column("Allow Self Registration", style="bold blue")
|
||||
for t in tenants:
|
||||
id = t.id
|
||||
name = t.name
|
||||
enabled = t.enabled
|
||||
allow_self_registration = t.allow_self_registration
|
||||
table.add_row(
|
||||
id,
|
||||
name,
|
||||
str(enabled),
|
||||
str(allow_self_registration),
|
||||
)
|
||||
console.print(table)
|
||||
55
secnex/app/user.py
Normal file
55
secnex/app/user.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import typer
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.prompt import Prompt
|
||||
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import base64
|
||||
|
||||
import secnex.utils as utils
|
||||
|
||||
from secnex.kit.config.config import Config
|
||||
from secnex.kit.clients.user import UserClient
|
||||
|
||||
console = Console()
|
||||
|
||||
user_app = typer.Typer()
|
||||
|
||||
@user_app.command(name="ls", help="List all users")
|
||||
def ls():
|
||||
"""List all users"""
|
||||
config = Config("default")
|
||||
user_client = UserClient(config)
|
||||
users = user_client.get_all()
|
||||
count_users = len(users)
|
||||
console.print(f"Found {count_users} users")
|
||||
if count_users == 0:
|
||||
return
|
||||
table = Table(title="Users")
|
||||
table.add_column("ID", style="bold green")
|
||||
table.add_column("First Name", style="bold yellow")
|
||||
table.add_column("Last Name", style="bold purple")
|
||||
table.add_column("Email", style="bold blue")
|
||||
table.add_column("Tenant ID", style="bold red")
|
||||
for u in users:
|
||||
table.add_row(u.id, u.first_name, u.last_name, u.email, u.tenant_id)
|
||||
console.print(table)
|
||||
|
||||
@user_app.command(name="tenant", help="Set the tenant for a user")
|
||||
def set_tenant(id: Optional[str] = typer.Option(None, "--id", "-i", help="The ID of the user"), tenant: Optional[str] = typer.Option(None, "--tenant", "-t", help="The tenant to set for the user")):
|
||||
"""Set the tenant for a user"""
|
||||
if id is None:
|
||||
id = Prompt.ask("Enter the ID of the user")
|
||||
if tenant is None:
|
||||
tenant = Prompt.ask("Enter the ID of the tenant to set for the user")
|
||||
config = Config("default")
|
||||
user_client = UserClient(config)
|
||||
success, status_code = user_client.add_user_to_tenant(tenant, id)
|
||||
if not success:
|
||||
console.print(f"Failed to set tenant for user! Please check the logs for more information.", style="bold red")
|
||||
return
|
||||
console.print(f"Tenant set for user successfully!", style="bold green")
|
||||
23
secnex/app/utils.py
Normal file
23
secnex/app/utils.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import typer
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.prompt import Prompt
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import secnex.utils as u
|
||||
|
||||
console = Console()
|
||||
|
||||
utils_app = typer.Typer()
|
||||
|
||||
@utils_app.command(name="secret", help="Generate a new secret")
|
||||
def generate_secret() -> str:
|
||||
"""Generate a new secret"""
|
||||
secret = u.generate_secret()
|
||||
console.print(f"Secret: {secret}")
|
||||
console.print(f"Hash: {u.hash_secret(secret)}")
|
||||
return secret
|
||||
13
secnex/cli.py
Normal file
13
secnex/cli.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import typer
|
||||
|
||||
from secnex.app import auth_app, application_app, config_app, query_app, tenant_app, utils_app, user_app
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
app.add_typer(config_app, name="config")
|
||||
app.add_typer(auth_app, name="auth")
|
||||
app.add_typer(query_app, name="query")
|
||||
app.add_typer(tenant_app, name="tenant")
|
||||
app.add_typer(application_app, name="app")
|
||||
app.add_typer(utils_app, name="utils")
|
||||
app.add_typer(user_app, name="user")
|
||||
0
secnex/config/config.py
Normal file
0
secnex/config/config.py
Normal file
176
secnex/kit/auth/auth.py
Normal file
176
secnex/kit/auth/auth.py
Normal file
@@ -0,0 +1,176 @@
|
||||
from rich.console import Console
|
||||
|
||||
import os
|
||||
import json
|
||||
import secrets
|
||||
import webbrowser as browser
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from threading import Thread
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
import keyring
|
||||
import keyring.errors
|
||||
|
||||
from secnex.kit.auth.session import Session
|
||||
from secnex.kit.config.config import Config
|
||||
|
||||
console = Console()
|
||||
|
||||
class RedirectHandler(BaseHTTPRequestHandler):
|
||||
def __init__(self, *args, auth_instance=None, **kwargs):
|
||||
self.auth_instance = auth_instance
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def do_GET(self):
|
||||
"""Handle the redirect from the OAuth provider"""
|
||||
if self.auth_instance is None:
|
||||
self.send_response(500)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"<html><body><h1>Error</h1><p>Auth instance not available.</p></body></html>")
|
||||
return
|
||||
|
||||
parsed_url = urlparse(self.path)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
code = query_params.get("code", [None])[0]
|
||||
state = query_params.get("state", [None])[0]
|
||||
if code and state:
|
||||
self.auth_instance.received_code = code
|
||||
self.auth_instance.received_state = state
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"<html><body><h1>Login successful!</h1><p>You can close this window.</p></body></html>")
|
||||
if self.auth_instance.server:
|
||||
self.auth_instance.server.shutdown()
|
||||
else:
|
||||
self.send_response(400)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"<html><body><h1>Error</h1><p>Missing code or state parameter.</p></body></html>")
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Suppress default logging"""
|
||||
pass
|
||||
|
||||
class Auth:
|
||||
SERVICE_NAME = "secnex-cli"
|
||||
|
||||
def __init__(self, config: Config) -> None:
|
||||
self.config = config
|
||||
self.received_code = None
|
||||
self.received_state = None
|
||||
self.server = None
|
||||
self.session = None
|
||||
|
||||
def _get_keyring_username(self) -> str:
|
||||
"""Get the username for keyring storage"""
|
||||
return f"{self.config.name}_{self.config.client_id}"
|
||||
|
||||
def save_token(self, token: str) -> None:
|
||||
"""Save access token to keyring"""
|
||||
try:
|
||||
username = self._get_keyring_username()
|
||||
keyring.set_password(self.SERVICE_NAME, username, token)
|
||||
console.print(f"Access token saved securely", style="bold green")
|
||||
except Exception as e:
|
||||
console.print(f"Failed to save token to keyring: {e}", style="bold red")
|
||||
|
||||
def get_token(self) -> Optional[str]:
|
||||
"""Get access token from keyring"""
|
||||
try:
|
||||
username = self._get_keyring_username()
|
||||
token = keyring.get_password(self.SERVICE_NAME, username)
|
||||
return token
|
||||
except Exception as e:
|
||||
console.print(f"Failed to get token from keyring: {e}", style="bold red")
|
||||
return None
|
||||
|
||||
def delete_token(self) -> None:
|
||||
"""Delete access token from keyring"""
|
||||
try:
|
||||
username = self._get_keyring_username()
|
||||
keyring.delete_password(self.SERVICE_NAME, username)
|
||||
console.print(f"Access token deleted", style="bold green")
|
||||
except keyring.errors.PasswordDeleteError:
|
||||
# Token doesn't exist, which is fine
|
||||
pass
|
||||
except Exception as e:
|
||||
console.print(f"Failed to delete token from keyring: {e}", style="bold red")
|
||||
|
||||
def exchange_code_for_token(self, code: str, redirect_uri: str) -> Optional[Session]:
|
||||
"""Exchange authorization code for access token"""
|
||||
token_url = f"{self.config.ui}/token"
|
||||
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": self.config.client_id,
|
||||
"client_secret": self.config.client_secret,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(token_url, data=data)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
if access_token:
|
||||
self.session = Session(access_token)
|
||||
console.print(f"Successfully obtained access token!", style="bold green")
|
||||
# Save token to keyring
|
||||
self.save_token(access_token)
|
||||
return self.session
|
||||
else:
|
||||
console.print(f"Token response missing access_token", style="bold red")
|
||||
return None
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
console.print(f"Failed to exchange code for token! Please check the logs for more information.", style="bold red")
|
||||
return None
|
||||
|
||||
def login(self) -> None:
|
||||
"""Login to the SecNex API"""
|
||||
response_type = "code"
|
||||
scope = "openid profile email"
|
||||
state = secrets.token_hex(10)
|
||||
redirect_uri = "http://localhost:8001"
|
||||
url = f"{self.config.ui}/authorize?client_id={self.config.client_id}&response_type={response_type}&scope={scope}&state={state}&redirect_uri={redirect_uri}"
|
||||
|
||||
# Start HTTP server on port 8001
|
||||
def create_handler(*args, **kwargs):
|
||||
return RedirectHandler(*args, auth_instance=self, **kwargs)
|
||||
|
||||
self.server = HTTPServer(("localhost", 8001), create_handler)
|
||||
server_thread = Thread(target=self.server.serve_forever, daemon=True)
|
||||
server_thread.start()
|
||||
|
||||
console.print(f"Waiting for redirect on http://localhost:8001...", style="bold yellow")
|
||||
console.print(f"Opening browser to: {url}", style="bold green")
|
||||
browser.open(url)
|
||||
|
||||
# Wait for the redirect (with timeout)
|
||||
timeout = 300 # 5 minutes
|
||||
start_time = time.time()
|
||||
while self.received_code is None and (time.time() - start_time) < timeout:
|
||||
time.sleep(0.1)
|
||||
|
||||
if self.received_code:
|
||||
console.print(f"Received authorization code!", style="bold green")
|
||||
|
||||
# Exchange code for token
|
||||
console.print(f"Exchanging code for token...", style="bold yellow")
|
||||
session = self.exchange_code_for_token(self.received_code, redirect_uri)
|
||||
|
||||
if session:
|
||||
console.print(f"Login successful!", style="bold green")
|
||||
else:
|
||||
console.print(f"Login failed: Could not obtain access token. Please try again.", style="bold red")
|
||||
else:
|
||||
console.print(f"Timeout waiting for authorization code. Please try again.", style="bold red")
|
||||
12
secnex/kit/auth/interactive.py
Normal file
12
secnex/kit/auth/interactive.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import webbrowser as browser
|
||||
|
||||
import secrets
|
||||
import requests
|
||||
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
class Interactive:
|
||||
def __init__(self, base_url: str = "http://localhost:3000", redirect_url: str = "http://localhost:8001") -> None:
|
||||
self.base_url = base_url
|
||||
self.redirect_url = redirect_url
|
||||
11
secnex/kit/auth/session.py
Normal file
11
secnex/kit/auth/session.py
Normal file
@@ -0,0 +1,11 @@
|
||||
class Session:
|
||||
def __init__(self, session: str) -> None:
|
||||
self.session = session
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.session
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, Session):
|
||||
return False
|
||||
return self.session == other.session
|
||||
56
secnex/kit/clients/application.py
Normal file
56
secnex/kit/clients/application.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import requests
|
||||
|
||||
from typing import Optional
|
||||
from datetime import datetime#
|
||||
|
||||
import secnex.utils as utils
|
||||
from secnex.kit.config.config import Config
|
||||
|
||||
from secnex.kit.models.application import Application
|
||||
|
||||
class ApplicationClient:
|
||||
def __init__(self, config: Config) -> None:
|
||||
self.config = config
|
||||
|
||||
def create(self, name: str, tenant_id: str, secret: str) -> tuple[Optional[str], int, bool]:
|
||||
"""Create a new application"""
|
||||
url = f"{self.config.host}/apps"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer 1234567890",
|
||||
}
|
||||
response = requests.post(url, headers=headers, json={"name": name, "tenant": tenant_id, "secret": secret})
|
||||
result = response.json()
|
||||
success = False
|
||||
app_id = None
|
||||
if "id" in result["body"]:
|
||||
success = True
|
||||
app_id = result["body"]["id"]
|
||||
return app_id, response.status_code, success
|
||||
|
||||
def get_all(self) -> tuple[Optional[list[Application]], int, bool]:
|
||||
"""Get all applications"""
|
||||
url = f"{self.config.host}/apps"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer 1234567890",
|
||||
}
|
||||
response = requests.get(url, headers=headers)
|
||||
result = response.json()
|
||||
success = False
|
||||
applications = []
|
||||
if "applications" in result["body"]:
|
||||
success = True
|
||||
applications = [Application(**application) for application in result["body"]["applications"]]
|
||||
return applications, response.status_code, success
|
||||
|
||||
def remove(self, id: str) -> tuple[bool, int]:
|
||||
"""Remove an application"""
|
||||
url = f"{self.config.host}/apps/{id}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer 1234567890",
|
||||
}
|
||||
response = requests.delete(url, headers=headers)
|
||||
success = response.status_code == 200
|
||||
return success, response.status_code
|
||||
0
secnex/kit/clients/key.py
Normal file
0
secnex/kit/clients/key.py
Normal file
40
secnex/kit/clients/tenant.py
Normal file
40
secnex/kit/clients/tenant.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import requests
|
||||
|
||||
from typing import Optional
|
||||
from datetime import datetime#
|
||||
|
||||
from secnex.kit.config.config import Config
|
||||
|
||||
from secnex.kit.models.tenant import Tenant
|
||||
|
||||
class TenantClient:
|
||||
def __init__(self, config: Config) -> None:
|
||||
self.config = config
|
||||
|
||||
def create(self, name: str) -> tuple[Optional[str], int, bool]:
|
||||
"""Create a new tenant"""
|
||||
url = f"{self.config.host}/tenants"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer 1234567890",
|
||||
}
|
||||
response = requests.post(url, headers=headers, json={"name": name})
|
||||
result = response.json()
|
||||
success = False
|
||||
tenant_id = None
|
||||
if "id" in result["body"]:
|
||||
success = True
|
||||
tenant_id = result["body"]["id"]
|
||||
return tenant_id, response.status_code, success
|
||||
|
||||
def get_all(self) -> list[Tenant]:
|
||||
"""Get all tenants"""
|
||||
url = f"{self.config.host}/tenants"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer 1234567890",
|
||||
}
|
||||
response = requests.get(url, headers=headers)
|
||||
result = response.json()
|
||||
tenants = result["body"]["tenants"]
|
||||
return [Tenant(**tenant) for tenant in tenants]
|
||||
38
secnex/kit/clients/user.py
Normal file
38
secnex/kit/clients/user.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import requests
|
||||
|
||||
from typing import Optional
|
||||
from datetime import datetime#
|
||||
|
||||
from secnex.kit.config.config import Config
|
||||
|
||||
from secnex.kit.models.user import User
|
||||
|
||||
class UserClient:
|
||||
def __init__(self, config: Config) -> None:
|
||||
self.config = config
|
||||
|
||||
def get_all(self) -> list[User]:
|
||||
"""Get all users"""
|
||||
url = f"{self.config.host}/users"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer 1234567890",
|
||||
}
|
||||
response = requests.get(url, headers=headers)
|
||||
result = response.json()
|
||||
users = result["body"]["users"]
|
||||
return [User(**u) for u in users]
|
||||
|
||||
def add_user_to_tenant(self, tenant_id: str, user_id: str) -> tuple[bool, int]:
|
||||
"""Add a user to a tenant"""
|
||||
url = f"{self.config.host}/users/{user_id}/tenant"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer 1234567890",
|
||||
}
|
||||
data = {
|
||||
"tenant": tenant_id,
|
||||
}
|
||||
response = requests.put(url, headers=headers, json=data)
|
||||
success = response.status_code == 200
|
||||
return success, response.status_code
|
||||
73
secnex/kit/config/config.py
Normal file
73
secnex/kit/config/config.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import os
|
||||
import json
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt
|
||||
|
||||
console = Console()
|
||||
|
||||
class Config:
|
||||
def __init__(self, name: str, path: str = os.path.expanduser("~/.secnex")) -> None:
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.config = self.__load_config()
|
||||
if self.config is None:
|
||||
host = Prompt.ask("Enter the host of the SecNex API", default="http://localhost:3003")
|
||||
ui = Prompt.ask("Enter the UI to use", default="http://localhost:3000")
|
||||
client_id = Prompt.ask("Enter the ID of your client application")
|
||||
client_secret = Prompt.ask("Enter the secret of your client application")
|
||||
tenant_id = Prompt.ask("Enter the ID of your tenant")
|
||||
self.config = {
|
||||
"host": host,
|
||||
"ui": ui,
|
||||
"client": {
|
||||
"id": client_id,
|
||||
"secret": client_secret,
|
||||
"tenant": tenant_id,
|
||||
}
|
||||
}
|
||||
self.__save_config()
|
||||
|
||||
@property
|
||||
def host(self) -> Optional[str]:
|
||||
if self.config is not None:
|
||||
return self.config["host"]
|
||||
return None
|
||||
|
||||
@property
|
||||
def ui(self) -> Optional[str]:
|
||||
if self.config is not None:
|
||||
return self.config["ui"]
|
||||
return None
|
||||
|
||||
@property
|
||||
def client_id(self) -> Optional[str]:
|
||||
if self.config is not None:
|
||||
return self.config["client"]["id"]
|
||||
return None
|
||||
|
||||
@property
|
||||
def client_secret(self) -> Optional[str]:
|
||||
if self.config is not None:
|
||||
return self.config["client"]["secret"]
|
||||
return None
|
||||
|
||||
@property
|
||||
def tenant(self) -> Optional[str]:
|
||||
if self.config is not None:
|
||||
return self.config["client"]["tenant"]
|
||||
return None
|
||||
|
||||
def __load_config(self) -> dict | None:
|
||||
path = os.path.join(self.path, f"{self.name}.json")
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
def __save_config(self) -> None:
|
||||
path = os.path.join(self.path, f"{self.name}.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(self.config, f)
|
||||
28
secnex/kit/models/application.py
Normal file
28
secnex/kit/models/application.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
class Application:
|
||||
def __init__(self, id: str, name: str, secret: str, tenant_id: str, expires_at: datetime | str | None, created_at: datetime | str, updated_at: datetime | str, deleted_at: datetime | str | None) -> None:
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.secret = secret
|
||||
self.tenant_id = tenant_id
|
||||
self.expires_at = expires_at
|
||||
self.created_at = self._parse_datetime(created_at)
|
||||
self.updated_at = self._parse_datetime(updated_at)
|
||||
self.deleted_at = self._parse_datetime(deleted_at) if deleted_at is not None else None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} - {self.id} - {self.secret} - {self.tenant_id} - {self.expires_at} - {self.created_at} - {self.updated_at} - {self.deleted_at}"
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime(value: Any) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return value
|
||||
33
secnex/kit/models/key.py
Normal file
33
secnex/kit/models/key.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
class ApiKey:
|
||||
def __init__(self,
|
||||
id: str,
|
||||
key: str,
|
||||
enabled: bool,
|
||||
created_at: datetime | str | None,
|
||||
updated_at: datetime | str | None,
|
||||
deleted_at: datetime | str | None,
|
||||
) -> None:
|
||||
self.id = id
|
||||
self.key = key
|
||||
self.enabled = enabled
|
||||
self.created_at = self._parse_datetime(created_at)
|
||||
self.updated_at = self._parse_datetime(updated_at)
|
||||
self.deleted_at = self._parse_datetime(deleted_at) if deleted_at is not None else None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.id} - {self.key} - {self.enabled} - {self.created_at} - {self.updated_at} - {self.deleted_at}"
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime(value: Any) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return value
|
||||
39
secnex/kit/models/tenant.py
Normal file
39
secnex/kit/models/tenant.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
class Tenant:
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
name: str,
|
||||
enabled: bool,
|
||||
allow_self_registration: bool,
|
||||
allow_self_registration_domains: Optional[list[str]],
|
||||
created_at: datetime | str,
|
||||
updated_at: datetime | str,
|
||||
deleted_at: datetime | str | None,
|
||||
) -> None:
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.enabled = enabled
|
||||
self.allow_self_registration = allow_self_registration
|
||||
self.allow_self_registration_domains = allow_self_registration_domains or []
|
||||
self.created_at = self._parse_datetime(created_at)
|
||||
self.updated_at = self._parse_datetime(updated_at)
|
||||
self.deleted_at = self._parse_datetime(deleted_at) if deleted_at is not None else None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} - {self.id} - {self.enabled} - {self.allow_self_registration} - {self.allow_self_registration_domains} - {self.created_at} - {self.updated_at} - {self.deleted_at}"
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime(value: Any) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return value
|
||||
return value
|
||||
47
secnex/kit/models/user.py
Normal file
47
secnex/kit/models/user.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
class User:
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
username: str,
|
||||
email: str,
|
||||
password: str,
|
||||
verified: bool,
|
||||
external_id: Optional[str],
|
||||
tenant_id: Optional[str],
|
||||
created_at: datetime | str,
|
||||
updated_at: datetime | str,
|
||||
deleted_at: datetime | str | None,
|
||||
) -> None:
|
||||
self.id = id
|
||||
self.first_name = first_name
|
||||
self.last_name = last_name
|
||||
self.username = username
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.verified = verified
|
||||
self.external_id = external_id
|
||||
self.tenant_id = tenant_id
|
||||
self.created_at = self._parse_datetime(created_at)
|
||||
self.updated_at = self._parse_datetime(updated_at)
|
||||
self.deleted_at = self._parse_datetime(deleted_at) if deleted_at is not None else None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.first_name} {self.last_name} - {self.email} - {self.id} - {self.tenant_id} - {self.created_at} - {self.updated_at} - {self.deleted_at}"
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime(value: Any) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return value
|
||||
return value
|
||||
1
secnex/utils/__init__.py
Normal file
1
secnex/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .secret import generate_secret, hash_secret
|
||||
10
secnex/utils/secret.py
Normal file
10
secnex/utils/secret.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import secrets
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
|
||||
def generate_secret(length: int = 32) -> str:
|
||||
return secrets.token_hex(length)
|
||||
|
||||
def hash_secret(secret: str) -> str:
|
||||
ph = PasswordHasher()
|
||||
return ph.hash(secret)
|
||||
Reference in New Issue
Block a user