devops-copilot 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.
- app/__init__.py +1 -0
- app/cli/__init__.py +1 -0
- app/cli/config.py +41 -0
- app/cli/main.py +251 -0
- app/core/config.py +19 -0
- app/core/database/__init__.py +24 -0
- app/core/security.py +18 -0
- app/main.py +31 -0
- app/modules/__init__.py +1 -0
- app/modules/auth/__init__.py +9 -0
- app/modules/auth/router.py +101 -0
- app/modules/auth/schema.py +34 -0
- app/modules/auth/service.py +91 -0
- app/modules/chat/__init__.py +1 -0
- app/modules/chat/agent.py +443 -0
- app/modules/chat/models.py +48 -0
- app/modules/chat/router.py +362 -0
- app/modules/chat/schema.py +39 -0
- app/modules/guardrails/__init__.py +1 -0
- app/modules/guardrails/schema.py +22 -0
- app/modules/guardrails/service.py +121 -0
- app/modules/knowledge/__init__.py +1 -0
- app/modules/knowledge/service.py +123 -0
- app/modules/servers/__init__.py +1 -0
- app/modules/servers/models.py +30 -0
- app/modules/servers/router.py +67 -0
- app/modules/servers/schema.py +45 -0
- app/modules/servers/service.py +164 -0
- devops_copilot-0.1.0.dist-info/METADATA +81 -0
- devops_copilot-0.1.0.dist-info/RECORD +33 -0
- devops_copilot-0.1.0.dist-info/WHEEL +5 -0
- devops_copilot-0.1.0.dist-info/entry_points.txt +2 -0
- devops_copilot-0.1.0.dist-info/top_level.txt +1 -0
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This makes the app folder a Python package
|
app/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This makes the app/cli folder a Python package
|
app/cli/config.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
|
|
6
|
+
CONFIG_DIR = Path.home() / ".config" / "devops-copilot"
|
|
7
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
8
|
+
OLD_CONFIG_DIR = Path.home() / ".config" / "devops-rag"
|
|
9
|
+
OLD_CONFIG_FILE = OLD_CONFIG_DIR / "config.json"
|
|
10
|
+
|
|
11
|
+
def load_config() -> Optional[Dict[str, str]]:
|
|
12
|
+
"""Loads CLI settings from local configuration file with fallback/migration from old path."""
|
|
13
|
+
if not CONFIG_FILE.exists():
|
|
14
|
+
if OLD_CONFIG_FILE.exists():
|
|
15
|
+
try:
|
|
16
|
+
# Automatically migrate old config to the new directory
|
|
17
|
+
with open(OLD_CONFIG_FILE, "r") as f:
|
|
18
|
+
old_data = json.load(f)
|
|
19
|
+
save_config(old_data["server_url"], old_data["token"])
|
|
20
|
+
except Exception:
|
|
21
|
+
pass
|
|
22
|
+
else:
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
with open(CONFIG_FILE, "r") as f:
|
|
26
|
+
return json.load(f)
|
|
27
|
+
except Exception:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
def save_config(server_url: str, token: str) -> None:
|
|
31
|
+
"""Saves CLI settings to the local config.json file in the user's home config directory."""
|
|
32
|
+
try:
|
|
33
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
config_data = {
|
|
35
|
+
"server_url": server_url.rstrip("/"),
|
|
36
|
+
"token": token
|
|
37
|
+
}
|
|
38
|
+
with open(CONFIG_FILE, "w") as f:
|
|
39
|
+
json.dump(config_data, f, indent=4)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
raise RuntimeError(f"Failed to save local config: {str(e)}")
|
app/cli/main.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
import httpx
|
|
5
|
+
import websockets
|
|
6
|
+
import typer
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from app.cli.config import load_config, save_config
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(name="devops-copilot", help="AI-driven DevOps Copilot CLI Client")
|
|
12
|
+
|
|
13
|
+
@app.command("login")
|
|
14
|
+
def login(
|
|
15
|
+
server_url: Optional[str] = typer.Option(None, help="The base URL of the FastAPI backend server"),
|
|
16
|
+
username: Optional[str] = typer.Option(None, help="Your administrator username")
|
|
17
|
+
):
|
|
18
|
+
"""
|
|
19
|
+
Authenticate with the central DevOps-Copilot backend and retrieve an access token.
|
|
20
|
+
Saves configurations locally in ~/.config/devops-copilot/config.json.
|
|
21
|
+
"""
|
|
22
|
+
if not server_url:
|
|
23
|
+
server_url = typer.prompt("Enter DevOps Agent server URL (e.g. http://localhost:8000)")
|
|
24
|
+
if not username:
|
|
25
|
+
username = typer.prompt("Enter admin username")
|
|
26
|
+
password = typer.prompt("Enter password", hide_input=True)
|
|
27
|
+
|
|
28
|
+
server_url_clean = server_url.rstrip("/")
|
|
29
|
+
token_url = f"{server_url_clean}/api/v1/auth/token"
|
|
30
|
+
|
|
31
|
+
typer.echo(f"Logging in to {server_url_clean}...")
|
|
32
|
+
try:
|
|
33
|
+
response = httpx.post(
|
|
34
|
+
token_url,
|
|
35
|
+
data={"username": username, "password": password},
|
|
36
|
+
timeout=10.0
|
|
37
|
+
)
|
|
38
|
+
try:
|
|
39
|
+
response_json = response.json()
|
|
40
|
+
except (ValueError, TypeError):
|
|
41
|
+
response_json = None
|
|
42
|
+
|
|
43
|
+
if response.status_code == 200:
|
|
44
|
+
if response_json:
|
|
45
|
+
token = response_json.get("access_token")
|
|
46
|
+
save_config(server_url_clean, token)
|
|
47
|
+
typer.secho("Success! Authenticated and configured locally.", fg=typer.colors.GREEN, bold=True)
|
|
48
|
+
else:
|
|
49
|
+
typer.secho("Authentication Failed: Server returned non-JSON response.", fg=typer.colors.RED, err=True)
|
|
50
|
+
raise typer.Exit(code=1)
|
|
51
|
+
else:
|
|
52
|
+
detail = response_json.get("detail") if response_json else f"HTTP Status {response.status_code}: {response.text[:200]}"
|
|
53
|
+
typer.secho(f"Authentication Failed: {detail}", fg=typer.colors.RED, err=True)
|
|
54
|
+
raise typer.Exit(code=1)
|
|
55
|
+
except typer.Exit:
|
|
56
|
+
raise
|
|
57
|
+
except Exception as e:
|
|
58
|
+
typer.secho(f"Connection Error: {str(e)}", fg=typer.colors.RED, err=True)
|
|
59
|
+
raise typer.Exit(code=1)
|
|
60
|
+
|
|
61
|
+
async def run_chat_loop(server_url: str, token: str, session_id: int):
|
|
62
|
+
"""Asynchronous WebSocket loop that handles message passing and streams with auto-reconnect."""
|
|
63
|
+
ws_proto = "wss" if server_url.startswith("https") else "ws"
|
|
64
|
+
host_port = server_url.split("://")[-1]
|
|
65
|
+
ws_url = f"{ws_proto}://{host_port}/api/v1/chat/ws?token={token}"
|
|
66
|
+
|
|
67
|
+
reconnect_delay = 1.0
|
|
68
|
+
max_reconnect_delay = 16.0
|
|
69
|
+
|
|
70
|
+
while True:
|
|
71
|
+
try:
|
|
72
|
+
async with websockets.connect(ws_url) as ws:
|
|
73
|
+
reconnect_delay = 1.0 # Reset delay on success
|
|
74
|
+
typer.secho(f"\nConnected to DevOps AI Agent (Session #{session_id})!", fg=typer.colors.GREEN, bold=True)
|
|
75
|
+
typer.secho("Type your prompt and press Enter. Type 'exit' to quit.\n", fg=typer.colors.CYAN)
|
|
76
|
+
|
|
77
|
+
approval_sent = False
|
|
78
|
+
while True:
|
|
79
|
+
if not approval_sent:
|
|
80
|
+
# Read user input from terminal
|
|
81
|
+
user_input = typer.prompt("You")
|
|
82
|
+
if user_input.lower() in ("exit", "quit"):
|
|
83
|
+
typer.secho("Exiting chat session. Goodbye!", fg=typer.colors.YELLOW)
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
if not user_input.strip():
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Send chat message to server using websockets API (send stringified JSON)
|
|
90
|
+
await ws.send(json.dumps({
|
|
91
|
+
"type": "message",
|
|
92
|
+
"session_id": session_id,
|
|
93
|
+
"content": user_input
|
|
94
|
+
}))
|
|
95
|
+
|
|
96
|
+
# Listen to streamed server events
|
|
97
|
+
typer.secho("Agent: ", fg=typer.colors.MAGENTA, nl=False)
|
|
98
|
+
|
|
99
|
+
approval_sent = False # Reset state for this turn
|
|
100
|
+
|
|
101
|
+
while True:
|
|
102
|
+
event = await ws.recv()
|
|
103
|
+
event_data = json.loads(event)
|
|
104
|
+
event_type = event_data.get("type")
|
|
105
|
+
|
|
106
|
+
if event_type == "token":
|
|
107
|
+
# Print thinking tokens as they arrive
|
|
108
|
+
sys.stdout.write(event_data.get("data", ""))
|
|
109
|
+
sys.stdout.flush()
|
|
110
|
+
|
|
111
|
+
elif event_type == "stdout":
|
|
112
|
+
# Stream command output in gray
|
|
113
|
+
sys.stdout.write("\n") # Newline to separate from previous tokens
|
|
114
|
+
typer.secho(event_data.get("data", ""), fg=typer.colors.BRIGHT_BLACK, nl=False)
|
|
115
|
+
sys.stdout.flush()
|
|
116
|
+
|
|
117
|
+
elif event_type == "stderr":
|
|
118
|
+
# Stream stderr in red
|
|
119
|
+
sys.stdout.write("\n")
|
|
120
|
+
typer.secho(event_data.get("data", ""), fg=typer.colors.RED, nl=False)
|
|
121
|
+
sys.stdout.flush()
|
|
122
|
+
|
|
123
|
+
elif event_type == "approval_required":
|
|
124
|
+
# Handle human-in-the-loop validation request
|
|
125
|
+
action_id = event_data.get("action_id")
|
|
126
|
+
server_name = event_data.get("server_name")
|
|
127
|
+
command = event_data.get("command")
|
|
128
|
+
|
|
129
|
+
typer.secho(f"\n\n[Action Approval Required]", fg=typer.colors.YELLOW, bold=True)
|
|
130
|
+
typer.secho(f"Target Server: {server_name}", fg=typer.colors.YELLOW)
|
|
131
|
+
typer.secho(f"Command to Run: {command}", fg=typer.colors.YELLOW, bold=True)
|
|
132
|
+
|
|
133
|
+
approve = typer.confirm("Approve execution of this command?")
|
|
134
|
+
|
|
135
|
+
if approve:
|
|
136
|
+
typer.secho("Approving command execution...", fg=typer.colors.GREEN)
|
|
137
|
+
await ws.send(json.dumps({
|
|
138
|
+
"type": "approve",
|
|
139
|
+
"action_id": action_id
|
|
140
|
+
}))
|
|
141
|
+
else:
|
|
142
|
+
typer.secho("Rejecting command execution.", fg=typer.colors.RED)
|
|
143
|
+
await ws.send(json.dumps({
|
|
144
|
+
"type": "reject",
|
|
145
|
+
"action_id": action_id
|
|
146
|
+
}))
|
|
147
|
+
approval_sent = True
|
|
148
|
+
|
|
149
|
+
elif event_type == "finished":
|
|
150
|
+
# Output generation is completed
|
|
151
|
+
# Print newline and final summary response if not fully printed
|
|
152
|
+
sys.stdout.write("\n\n")
|
|
153
|
+
break
|
|
154
|
+
|
|
155
|
+
elif event_type == "error":
|
|
156
|
+
typer.secho(f"\nError: {event_data.get('data')}", fg=typer.colors.RED, err=True)
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
except (websockets.exceptions.ConnectionClosed, ConnectionRefusedError, OSError) as e:
|
|
160
|
+
typer.secho(f"\nConnection lost ({str(e)}). Reconnecting in {reconnect_delay}s...", fg=typer.colors.YELLOW)
|
|
161
|
+
await asyncio.sleep(reconnect_delay)
|
|
162
|
+
reconnect_delay = min(reconnect_delay * 2, max_reconnect_delay)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
typer.secho(f"\nWebSocket Error: {str(e)}", fg=typer.colors.RED, err=True)
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
@app.command("chat")
|
|
168
|
+
def chat():
|
|
169
|
+
"""
|
|
170
|
+
Start an interactive chat session with the AI DevOps agent in the terminal.
|
|
171
|
+
Allows selecting historical threads or spawning new sessions.
|
|
172
|
+
"""
|
|
173
|
+
config = load_config()
|
|
174
|
+
if not config:
|
|
175
|
+
typer.secho("Error: No configuration found. Please run 'devops-copilot login' first.", fg=typer.colors.RED, err=True)
|
|
176
|
+
raise typer.Exit(code=1)
|
|
177
|
+
|
|
178
|
+
server_url = config["server_url"]
|
|
179
|
+
token = config["token"]
|
|
180
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
181
|
+
|
|
182
|
+
# 1. Fetch existing chat sessions
|
|
183
|
+
typer.echo("Fetching chat sessions from server...")
|
|
184
|
+
try:
|
|
185
|
+
response = httpx.get(f"{server_url}/api/v1/chat/sessions", headers=headers, timeout=10.0)
|
|
186
|
+
if response.status_code == 401:
|
|
187
|
+
typer.secho("Session expired. Please run 'devops-copilot login' again.", fg=typer.colors.RED, err=True)
|
|
188
|
+
raise typer.Exit(code=1)
|
|
189
|
+
response.raise_for_status()
|
|
190
|
+
try:
|
|
191
|
+
sessions = response.json()
|
|
192
|
+
except (ValueError, TypeError):
|
|
193
|
+
typer.secho(f"Failed to fetch sessions: Server returned non-JSON response (HTTP {response.status_code}).", fg=typer.colors.RED, err=True)
|
|
194
|
+
raise typer.Exit(code=1)
|
|
195
|
+
except typer.Exit:
|
|
196
|
+
raise
|
|
197
|
+
except Exception as e:
|
|
198
|
+
typer.secho(f"Failed to fetch sessions: {str(e)}", fg=typer.colors.RED, err=True)
|
|
199
|
+
raise typer.Exit(code=1)
|
|
200
|
+
|
|
201
|
+
# 2. Let the user choose or spawn a new session
|
|
202
|
+
session_id = None
|
|
203
|
+
if sessions:
|
|
204
|
+
typer.echo("\nAvailable chat sessions:")
|
|
205
|
+
session_ids = []
|
|
206
|
+
for s in sessions:
|
|
207
|
+
typer.echo(f" Session ID: {s['id']} | Title: {s['title']}")
|
|
208
|
+
session_ids.append(s['id'])
|
|
209
|
+
|
|
210
|
+
choice = typer.prompt("\nSelect Session ID, or type 'new' to start a new chat")
|
|
211
|
+
|
|
212
|
+
if choice.lower() != "new":
|
|
213
|
+
try:
|
|
214
|
+
val = int(choice)
|
|
215
|
+
if val in session_ids:
|
|
216
|
+
session_id = val
|
|
217
|
+
else:
|
|
218
|
+
typer.secho("Invalid Session ID.", fg=typer.colors.RED)
|
|
219
|
+
raise typer.Exit(code=1)
|
|
220
|
+
except ValueError:
|
|
221
|
+
typer.secho("Invalid input format.", fg=typer.colors.RED)
|
|
222
|
+
raise typer.Exit(code=1)
|
|
223
|
+
|
|
224
|
+
if session_id is None:
|
|
225
|
+
# Create a new session
|
|
226
|
+
title = typer.prompt("Enter title for the new chat session")
|
|
227
|
+
try:
|
|
228
|
+
res = httpx.post(
|
|
229
|
+
f"{server_url}/api/v1/chat/sessions",
|
|
230
|
+
headers=headers,
|
|
231
|
+
json={"title": title},
|
|
232
|
+
timeout=10.0
|
|
233
|
+
)
|
|
234
|
+
res.raise_for_status()
|
|
235
|
+
try:
|
|
236
|
+
new_sess = res.json()
|
|
237
|
+
session_id = new_sess["id"]
|
|
238
|
+
except (ValueError, TypeError, KeyError):
|
|
239
|
+
typer.secho(f"Failed to create new session: Server returned invalid format.", fg=typer.colors.RED, err=True)
|
|
240
|
+
raise typer.Exit(code=1)
|
|
241
|
+
except typer.Exit:
|
|
242
|
+
raise
|
|
243
|
+
except Exception as e:
|
|
244
|
+
typer.secho(f"Failed to create new session: {str(e)}", fg=typer.colors.RED, err=True)
|
|
245
|
+
raise typer.Exit(code=1)
|
|
246
|
+
|
|
247
|
+
# 3. Enter the async WebSocket execution loop
|
|
248
|
+
asyncio.run(run_chat_loop(server_url, token, session_id))
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
app()
|
app/core/config.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
2
|
+
|
|
3
|
+
class Settings(BaseSettings):
|
|
4
|
+
DATABASE_URL: str = "sqlite+aiosqlite:///data/devops_rag.db"
|
|
5
|
+
|
|
6
|
+
JWT_SECRET_KEY: str = "supersecretkey_change_me_in_production"
|
|
7
|
+
JWT_ALGORITHM: str = "HS256"
|
|
8
|
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
|
9
|
+
|
|
10
|
+
# Key for encrypting target server credentials (SSH passwords/private keys)
|
|
11
|
+
ENCRYPTION_KEY: str = "h-0rAgfuXQnNinQ9yZevWRvRNv9_nWdixhHG_DZGmoE="
|
|
12
|
+
|
|
13
|
+
# OpenRouter API configurations for AI Agent
|
|
14
|
+
OPENROUTER_API_KEY: str | None = None
|
|
15
|
+
OPENROUTER_MODEL: str = "google/gemini-2.5-flash"
|
|
16
|
+
|
|
17
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
18
|
+
|
|
19
|
+
settings = Settings()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
2
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
3
|
+
from sqlalchemy.orm import sessionmaker
|
|
4
|
+
from sqlmodel import SQLModel
|
|
5
|
+
from typing import AsyncGenerator
|
|
6
|
+
from app.core.config import settings
|
|
7
|
+
|
|
8
|
+
# create async engine
|
|
9
|
+
engine = create_async_engine(settings.DATABASE_URL, echo=True)
|
|
10
|
+
|
|
11
|
+
# create async session maker
|
|
12
|
+
async_session_maker = sessionmaker(
|
|
13
|
+
engine, class_=AsyncSession, expire_on_commit=False
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# create async db and tables
|
|
17
|
+
async def create_db_and_tables():
|
|
18
|
+
async with engine.begin() as conn:
|
|
19
|
+
await conn.run_sync(SQLModel.metadata.create_all)
|
|
20
|
+
|
|
21
|
+
# create async session dependency
|
|
22
|
+
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
23
|
+
async with async_session_maker() as session:
|
|
24
|
+
yield session
|
app/core/security.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from cryptography.fernet import Fernet
|
|
2
|
+
from app.core.config import settings
|
|
3
|
+
|
|
4
|
+
# Initialize symmetric Fernet encryption with key from config
|
|
5
|
+
fernet = Fernet(settings.ENCRYPTION_KEY.encode())
|
|
6
|
+
|
|
7
|
+
def encrypt_data(data: str) -> str:
|
|
8
|
+
"""
|
|
9
|
+
Encrypt sensitive data (e.g. passwords, SSH keys) using AES-128/256.
|
|
10
|
+
Returns a URL-safe base64-encoded string.
|
|
11
|
+
"""
|
|
12
|
+
return fernet.encrypt(data.encode()).decode()
|
|
13
|
+
|
|
14
|
+
def decrypt_data(encrypted_data: str) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Decrypt base64-encoded cipher text back into plain text.
|
|
17
|
+
"""
|
|
18
|
+
return fernet.decrypt(encrypted_data.encode()).decode()
|
app/main.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
|
|
4
|
+
from app.core.database import create_db_and_tables
|
|
5
|
+
from app.modules.auth.router import router as auth_router
|
|
6
|
+
from app.modules.servers.router import router as servers_router
|
|
7
|
+
from app.modules.guardrails.service import init_and_seed_db
|
|
8
|
+
from app.modules.chat.router import router as chat_router
|
|
9
|
+
|
|
10
|
+
@asynccontextmanager
|
|
11
|
+
async def lifespan(app: FastAPI):
|
|
12
|
+
# Create DB tables on startup
|
|
13
|
+
await create_db_and_tables()
|
|
14
|
+
# Initialize and seed ChromaDB local vector store
|
|
15
|
+
await init_and_seed_db()
|
|
16
|
+
yield
|
|
17
|
+
|
|
18
|
+
app = FastAPI(
|
|
19
|
+
title="DevOps-Copilot API",
|
|
20
|
+
description="An AI-driven DevOps management copilot for bare-metal servers.",
|
|
21
|
+
version="0.1.0",
|
|
22
|
+
lifespan=lifespan
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
app.include_router(auth_router, prefix="/api/v1/auth", tags=["Authentication"])
|
|
26
|
+
app.include_router(servers_router, prefix="/api/v1/servers", tags=["Servers"])
|
|
27
|
+
app.include_router(chat_router, prefix="/api/v1/chat", tags=["Chat & Agent"])
|
|
28
|
+
|
|
29
|
+
@app.get("/health", tags=["Health"])
|
|
30
|
+
def health_check():
|
|
31
|
+
return {"status": "ok"}
|
app/modules/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This makes modules folder a Python package
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from sqlmodel import SQLModel, Field
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
# user model
|
|
5
|
+
class User(SQLModel, table=True):
|
|
6
|
+
id: Optional[int] = Field(default=None, primary_key=True)
|
|
7
|
+
username: str = Field(index=True, unique=True, nullable=False)
|
|
8
|
+
hashed_password: str = Field(nullable=False)
|
|
9
|
+
is_active: bool = Field(default=True)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
2
|
+
from fastapi.security import OAuth2PasswordRequestForm
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
from sqlmodel import select
|
|
5
|
+
|
|
6
|
+
from app.core.database import get_session
|
|
7
|
+
from app.modules.auth import User
|
|
8
|
+
from app.modules.auth.schema import UserRegister, UserResponse, Token
|
|
9
|
+
from app.modules.auth.service import (
|
|
10
|
+
hash_password,
|
|
11
|
+
verify_password,
|
|
12
|
+
create_access_token,
|
|
13
|
+
get_current_user
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
# Register API
|
|
19
|
+
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
20
|
+
async def register(
|
|
21
|
+
user_in: UserRegister,
|
|
22
|
+
session: AsyncSession = Depends(get_session)
|
|
23
|
+
):
|
|
24
|
+
"""
|
|
25
|
+
Register the Master Administrator account.
|
|
26
|
+
|
|
27
|
+
This is a personal app, meaning registration is strictly restricted and locked down
|
|
28
|
+
after the first successful registration has occurred.
|
|
29
|
+
"""
|
|
30
|
+
# check if any user already exists in the database
|
|
31
|
+
any_user_statement = select(User)
|
|
32
|
+
any_user_result = await session.exec(any_user_statement)
|
|
33
|
+
if any_user_result.first() is not None:
|
|
34
|
+
raise HTTPException(
|
|
35
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
36
|
+
detail="Registration is disabled. Admin user already exists."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# checking if username is already taken
|
|
40
|
+
statement = select(User).where(User.username == user_in.username)
|
|
41
|
+
result = await session.exec(statement)
|
|
42
|
+
existing_user = result.first()
|
|
43
|
+
|
|
44
|
+
if existing_user:
|
|
45
|
+
raise HTTPException(
|
|
46
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
47
|
+
detail="Username already registered"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# hashing the password and creating a new user object
|
|
51
|
+
hashed_pw = hash_password(user_in.password)
|
|
52
|
+
new_user = User(username=user_in.username, hashed_password=hashed_pw)
|
|
53
|
+
|
|
54
|
+
# saving in database (asynchronously)
|
|
55
|
+
session.add(new_user)
|
|
56
|
+
await session.commit()
|
|
57
|
+
await session.refresh(new_user)
|
|
58
|
+
|
|
59
|
+
return new_user
|
|
60
|
+
|
|
61
|
+
# Token API
|
|
62
|
+
@router.post("/token", response_model=Token)
|
|
63
|
+
async def login(
|
|
64
|
+
form_data: OAuth2PasswordRequestForm = Depends(),
|
|
65
|
+
session: AsyncSession = Depends(get_session)
|
|
66
|
+
):
|
|
67
|
+
"""
|
|
68
|
+
OAuth2-compatible token exchange endpoint.
|
|
69
|
+
|
|
70
|
+
Accepts form data containing username and password, authenticates credentials,
|
|
71
|
+
and returns a stateless JWT token.
|
|
72
|
+
"""
|
|
73
|
+
# finding user in database
|
|
74
|
+
statement = select(User).where(User.username == form_data.username)
|
|
75
|
+
result = await session.exec(statement)
|
|
76
|
+
user = result.first()
|
|
77
|
+
|
|
78
|
+
# checking username and password
|
|
79
|
+
if not user or not verify_password(form_data.password, user.hashed_password):
|
|
80
|
+
raise HTTPException(
|
|
81
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
82
|
+
detail="Incorrect username or password",
|
|
83
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# creating token for user
|
|
87
|
+
access_token = create_access_token(data={"sub": user.username})
|
|
88
|
+
|
|
89
|
+
return {"access_token": access_token, "token_type": "bearer"}
|
|
90
|
+
|
|
91
|
+
# Get User API
|
|
92
|
+
@router.get("/me", response_model=UserResponse)
|
|
93
|
+
async def read_users_me(
|
|
94
|
+
current_user: User = Depends(get_current_user)
|
|
95
|
+
):
|
|
96
|
+
"""
|
|
97
|
+
Retrieve currently logged-in administrator profile details.
|
|
98
|
+
|
|
99
|
+
Requires a valid JWT Bearer token in the Authorization header.
|
|
100
|
+
"""
|
|
101
|
+
return current_user
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
class UserRegister(BaseModel):
|
|
4
|
+
"""
|
|
5
|
+
DTO payload containing registration credentials for the single administrator.
|
|
6
|
+
Enforces pattern restrictions on username and a minimum length of 8 characters for password.
|
|
7
|
+
"""
|
|
8
|
+
username: str = Field(..., min_length=3, max_length=50, pattern=r"^[a-zA-Z0-9_]*$")
|
|
9
|
+
password: str = Field(..., min_length=8)
|
|
10
|
+
|
|
11
|
+
class UserResponse(BaseModel):
|
|
12
|
+
"""
|
|
13
|
+
Safe database projection of user information (excludes hashed password details).
|
|
14
|
+
"""
|
|
15
|
+
id: int
|
|
16
|
+
username: str
|
|
17
|
+
is_active: bool
|
|
18
|
+
|
|
19
|
+
class Config:
|
|
20
|
+
# Enable ORM attribute resolution mapping
|
|
21
|
+
from_attributes = True
|
|
22
|
+
|
|
23
|
+
class Token(BaseModel):
|
|
24
|
+
"""
|
|
25
|
+
Authentication token response payload containing the JWT string.
|
|
26
|
+
"""
|
|
27
|
+
access_token: str
|
|
28
|
+
token_type: str
|
|
29
|
+
|
|
30
|
+
class TokenData(BaseModel):
|
|
31
|
+
"""
|
|
32
|
+
Parsed JWT token container payload containing the username claim.
|
|
33
|
+
"""
|
|
34
|
+
username: str | None = None
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import bcrypt
|
|
2
|
+
import jwt
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from fastapi import Depends, HTTPException, status
|
|
5
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
from sqlmodel import select
|
|
8
|
+
|
|
9
|
+
from app.core.config import settings
|
|
10
|
+
from app.core.database import get_session
|
|
11
|
+
from app.modules.auth import User
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# authentication
|
|
15
|
+
# 1. Define OAuth2PasswordBearer
|
|
16
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/token")
|
|
17
|
+
|
|
18
|
+
# 2. Hash password
|
|
19
|
+
def hash_password(password: str) -> str:
|
|
20
|
+
salt = bcrypt.gensalt()
|
|
21
|
+
hashed = bcrypt.hashpw(password.encode("utf-8"), salt)
|
|
22
|
+
return hashed.decode("utf-8")
|
|
23
|
+
|
|
24
|
+
# 2.1. Verify password
|
|
25
|
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
26
|
+
# compare password with hashed password in database
|
|
27
|
+
return bcrypt.checkpw(
|
|
28
|
+
plain_password.encode("utf-8"),
|
|
29
|
+
hashed_password.encode("utf-8")
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# 3. Create access token
|
|
33
|
+
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
|
34
|
+
to_encode = data.copy()
|
|
35
|
+
if expires_delta:
|
|
36
|
+
expire = datetime.now(timezone.utc) + expires_delta
|
|
37
|
+
else:
|
|
38
|
+
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
39
|
+
|
|
40
|
+
# 3.1. add exp to token
|
|
41
|
+
to_encode.update({"exp": expire})
|
|
42
|
+
|
|
43
|
+
# 3.2. encode token
|
|
44
|
+
encoded_jwt = jwt.encode(
|
|
45
|
+
to_encode,
|
|
46
|
+
settings.JWT_SECRET_KEY,
|
|
47
|
+
algorithm=settings.JWT_ALGORITHM
|
|
48
|
+
)
|
|
49
|
+
return encoded_jwt
|
|
50
|
+
|
|
51
|
+
# 4. Authentication
|
|
52
|
+
# We use Depends to get the token from the request and pass it to the function.
|
|
53
|
+
async def get_current_user(
|
|
54
|
+
token: str = Depends(oauth2_scheme),
|
|
55
|
+
session: AsyncSession = Depends(get_session)
|
|
56
|
+
) -> User:
|
|
57
|
+
credentials_exception = HTTPException(
|
|
58
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
59
|
+
detail="Could not validate credentials",
|
|
60
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
# decode token
|
|
64
|
+
payload = jwt.decode(
|
|
65
|
+
token,
|
|
66
|
+
settings.JWT_SECRET_KEY,
|
|
67
|
+
algorithms=[settings.JWT_ALGORITHM]
|
|
68
|
+
)
|
|
69
|
+
# get username from token
|
|
70
|
+
username: str = payload.get("sub")
|
|
71
|
+
if username is None:
|
|
72
|
+
raise credentials_exception
|
|
73
|
+
except jwt.PyJWTError:
|
|
74
|
+
raise credentials_exception
|
|
75
|
+
|
|
76
|
+
# 4.1. find user in database by username
|
|
77
|
+
statement = select(User).where(User.username == username)
|
|
78
|
+
result = await session.exec(statement)
|
|
79
|
+
user = result.first()
|
|
80
|
+
|
|
81
|
+
if user is None:
|
|
82
|
+
raise credentials_exception
|
|
83
|
+
|
|
84
|
+
# 4.2. check if user is active
|
|
85
|
+
if not user.is_active:
|
|
86
|
+
raise HTTPException(
|
|
87
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
88
|
+
detail="Inactive user"
|
|
89
|
+
)
|
|
90
|
+
# return user
|
|
91
|
+
return user
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This makes the chat folder a Python package
|