first commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/venv/
|
||||||
|
/data/
|
||||||
|
**/__pycache__/
|
||||||
16
app/app.py
Normal file
16
app/app.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from flask import Flask
|
||||||
|
from blueprints.main import main_bp
|
||||||
|
from blueprints.api import api_bp
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
app.register_blueprint(main_bp)
|
||||||
|
app.register_blueprint(api_bp, url_prefix="/api")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = create_app()
|
||||||
|
app.run(debug=True)
|
||||||
24
app/blueprints/api.py
Normal file
24
app/blueprints/api.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import os
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
from flask_httpauth import HTTPTokenAuth
|
||||||
|
|
||||||
|
from utils.token_store import ApiKeyStore
|
||||||
|
|
||||||
|
api_bp = Blueprint("api", __name__)
|
||||||
|
auth = HTTPTokenAuth(scheme="Bearer")
|
||||||
|
|
||||||
|
STORE_PATH = os.getenv("API_TOKEN_FILE", "data/api_tokens.yaml")
|
||||||
|
store = ApiKeyStore(STORE_PATH, bcrypt_rounds=12)
|
||||||
|
|
||||||
|
@auth.verify_token
|
||||||
|
def verify_token(token: str):
|
||||||
|
return store.verify(token)
|
||||||
|
|
||||||
|
@auth.error_handler
|
||||||
|
def auth_error(status):
|
||||||
|
return jsonify({"error": "Unauthorized"}), status
|
||||||
|
|
||||||
|
@api_bp.route("/health", methods=["GET"])
|
||||||
|
@auth.login_required
|
||||||
|
def health():
|
||||||
|
return jsonify({"healthy": True})
|
||||||
7
app/blueprints/main.py
Normal file
7
app/blueprints/main.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
main_bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
|
@main_bp.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html")
|
||||||
10
app/templates/index.html
Normal file
10
app/templates/index.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Hello World</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Hello, World!</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
218
app/utils/token_store.py
Normal file
218
app/utils/token_store.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# token_store.py
|
||||||
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from typing import List, Optional, Dict
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ApiKey:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
hash: str # bcrypt hash (utf-8 str)
|
||||||
|
created_at: str # ISO8601-ish seconds precision
|
||||||
|
active: bool = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def now_iso() -> str:
|
||||||
|
# keep it simple; no tz handling needed for a local file
|
||||||
|
return time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyStore:
|
||||||
|
"""
|
||||||
|
Minimal API key manager:
|
||||||
|
- YAML file on disk
|
||||||
|
- stores bcrypt hashes only
|
||||||
|
- mint/list/get/deactivate/delete/rotate/verify
|
||||||
|
|
||||||
|
File format:
|
||||||
|
---
|
||||||
|
version: 1
|
||||||
|
keys:
|
||||||
|
- id: <uuid4>
|
||||||
|
label: "build bot"
|
||||||
|
hash: "$2b$12$..."
|
||||||
|
created_at: "2025-10-15T10:22:00"
|
||||||
|
active: true
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: str, bcrypt_rounds: int = 12):
|
||||||
|
self.path = path
|
||||||
|
self.bcrypt_rounds = bcrypt_rounds
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._data: Dict[str, object] = {"version": 1, "keys": []}
|
||||||
|
self._load_if_exists()
|
||||||
|
|
||||||
|
# ---------- public API ----------
|
||||||
|
|
||||||
|
def mint(self, label: str = "") -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Create a new API token.
|
||||||
|
Returns a dict with plaintext `token` (show it once!) and its `id`.
|
||||||
|
"""
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
hashed = bcrypt.hashpw(token.encode("utf-8"),
|
||||||
|
bcrypt.gensalt(rounds=self.bcrypt_rounds)).decode("utf-8")
|
||||||
|
key = ApiKey(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
label=label,
|
||||||
|
hash=hashed,
|
||||||
|
created_at=ApiKey.now_iso(),
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
keys = self._keys()
|
||||||
|
keys.append(key)
|
||||||
|
self._commit()
|
||||||
|
return {"id": key.id, "token": token}
|
||||||
|
|
||||||
|
def list(self, include_inactive: bool = True) -> List[Dict[str, object]]:
|
||||||
|
keys = self._keys()
|
||||||
|
out = []
|
||||||
|
for k in keys:
|
||||||
|
if include_inactive or k.active:
|
||||||
|
d = asdict(k)
|
||||||
|
d.pop("hash") # don’t leak hashes in listing
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def get(self, key_id: str) -> Optional[Dict[str, object]]:
|
||||||
|
k = self._find(key_id)
|
||||||
|
if not k:
|
||||||
|
return None
|
||||||
|
d = asdict(k)
|
||||||
|
d.pop("hash")
|
||||||
|
return d
|
||||||
|
|
||||||
|
def deactivate(self, key_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
k = self._find(key_id)
|
||||||
|
if not k:
|
||||||
|
return False
|
||||||
|
if not k.active:
|
||||||
|
return True
|
||||||
|
k.active = False
|
||||||
|
self._commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def activate(self, key_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
k = self._find(key_id)
|
||||||
|
if not k:
|
||||||
|
return False
|
||||||
|
if k.active:
|
||||||
|
return True
|
||||||
|
k.active = True
|
||||||
|
self._commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete(self, key_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
keys = self._keys()
|
||||||
|
before = len(keys)
|
||||||
|
keys[:] = [k for k in keys if k.id != key_id]
|
||||||
|
changed = len(keys) != before
|
||||||
|
if changed:
|
||||||
|
self._commit()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def rotate(self, key_id: str) -> Optional[Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
Mint a brand-new token for an existing key ID (keeps label).
|
||||||
|
The old hash is replaced. Returns {"id", "token"} or None if not found.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
k = self._find(key_id)
|
||||||
|
if not k:
|
||||||
|
return None
|
||||||
|
new_token = secrets.token_urlsafe(32)
|
||||||
|
new_hash = bcrypt.hashpw(new_token.encode("utf-8"),
|
||||||
|
bcrypt.gensalt(rounds=self.bcrypt_rounds)).decode("utf-8")
|
||||||
|
k.hash = new_hash
|
||||||
|
k.created_at = ApiKey.now_iso()
|
||||||
|
k.active = True
|
||||||
|
self._commit()
|
||||||
|
return {"id": k.id, "token": new_token}
|
||||||
|
|
||||||
|
def verify(self, token: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check whether a plaintext token matches any ACTIVE key.
|
||||||
|
"""
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
token_b = token.encode("utf-8")
|
||||||
|
# no lock needed for read-only access; file commits are atomic
|
||||||
|
for k in self._keys():
|
||||||
|
if not k.active:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if bcrypt.checkpw(token_b, k.hash.encode("utf-8")):
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
# malformed hash in file—treat as non-match
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---------- internals ----------
|
||||||
|
|
||||||
|
def _load_if_exists(self) -> None:
|
||||||
|
if not os.path.exists(self.path):
|
||||||
|
return
|
||||||
|
with open(self.path, "r", encoding="utf-8") as f:
|
||||||
|
raw = yaml.safe_load(f) or {}
|
||||||
|
version = raw.get("version", 1)
|
||||||
|
if version != 1:
|
||||||
|
raise ValueError(f"Unsupported token store version: {version}")
|
||||||
|
keys_raw = raw.get("keys", [])
|
||||||
|
keys: List[ApiKey] = []
|
||||||
|
for item in keys_raw:
|
||||||
|
keys.append(ApiKey(
|
||||||
|
id=str(item["id"]),
|
||||||
|
label=str(item.get("label", "")),
|
||||||
|
hash=str(item["hash"]),
|
||||||
|
created_at=str(item.get("created_at", ApiKey.now_iso())),
|
||||||
|
active=bool(item.get("active", True)),
|
||||||
|
))
|
||||||
|
self._data = {"version": 1, "keys": keys}
|
||||||
|
|
||||||
|
def _keys(self) -> List[ApiKey]:
|
||||||
|
return self._data["keys"] # type: ignore[return-value]
|
||||||
|
|
||||||
|
def _commit(self) -> None:
|
||||||
|
"""
|
||||||
|
Write YAML atomically to avoid partial writes.
|
||||||
|
"""
|
||||||
|
directory = os.path.dirname(os.path.abspath(self.path)) or "."
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
|
||||||
|
# Convert dataclasses to serializable dicts
|
||||||
|
payload = {
|
||||||
|
"version": 1,
|
||||||
|
"keys": [asdict(k) for k in self._keys()],
|
||||||
|
# optional: a tiny meta line to help with diffs
|
||||||
|
"_meta": {"updated": ApiKey.now_iso(), "count": len(self._keys())},
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp_fd, tmp_path = tempfile.mkstemp(prefix=".apikeystore.", dir=directory)
|
||||||
|
try:
|
||||||
|
with io.open(tmp_fd, "w", encoding="utf-8") as tmp:
|
||||||
|
yaml.safe_dump(payload, tmp, sort_keys=False)
|
||||||
|
os.replace(tmp_path, self.path) # atomic on POSIX & Windows
|
||||||
|
finally:
|
||||||
|
# If os.replace throws, ensure temp file is gone
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
try:
|
||||||
|
os.remove(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
11
mint.py
Normal file
11
mint.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from app.utils.token_store import ApiKeyStore
|
||||||
|
|
||||||
|
store = ApiKeyStore("data/api_tokens.yaml")
|
||||||
|
# res = store.mint(label="local dev")
|
||||||
|
# print("NEW TOKEN (save this now):")
|
||||||
|
# print(res) # {"id": "...uuid...", "token": "...plaintext..."}
|
||||||
|
|
||||||
|
print("List keys (no hashes):")
|
||||||
|
print(store.list(include_inactive=True))
|
||||||
|
|
||||||
|
# print("Verify works?", store.verify(res["token"])) # True
|
||||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
bcrypt==5.0.0
|
||||||
|
blinker==1.9.0
|
||||||
|
click==8.3.0
|
||||||
|
Flask==3.1.2
|
||||||
|
Flask-HTTPAuth==4.8.0
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.6
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
PyYAML==6.0.3
|
||||||
|
Werkzeug==3.1.3
|
||||||
1
run_flask.sh
Executable file
1
run_flask.sh
Executable file
@@ -0,0 +1 @@
|
|||||||
|
python app/app.py
|
||||||
Reference in New Issue
Block a user