Skip to content

Commit e9d2d12

Browse files
committed
updated auth to allow API keys
1 parent 9464219 commit e9d2d12

File tree

4 files changed

+158
-7
lines changed

4 files changed

+158
-7
lines changed

auth/auth.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import os
2+
import secrets
23
from functools import wraps
34
from typing import Callable
45

56
from authlib.integrations.flask_client import OAuth
6-
from flask import Blueprint, Flask, abort, redirect, session, url_for
7+
from flask import Blueprint, Flask, abort, jsonify, redirect, request, session, url_for
8+
from werkzeug.security import check_password_hash, generate_password_hash
79
from werkzeug.wrappers import Response
810

11+
from schema import APIKey, db
12+
913
oauth = OAuth()
1014

1115

@@ -86,3 +90,107 @@ def logout() -> Response:
8690
# if no id token, just clear session and redirect to index
8791
session.clear()
8892
return redirect(url_for("index"))
93+
94+
95+
def create_api_key(owner: str) -> dict:
96+
"""Create a new API key"""
97+
key = secrets.token_urlsafe(32)
98+
api_key = APIKey(generate_password_hash(key), owner.strip().lower())
99+
db.session.add(api_key)
100+
db.session.commit()
101+
return {
102+
"id": api_key.id,
103+
"key": key,
104+
"owner": api_key.owner,
105+
"active": api_key.active,
106+
"created_at": api_key.created_at.isoformat(),
107+
}
108+
109+
110+
def get_api_keys() -> list[dict]:
111+
"""Get all API keys"""
112+
api_keys = APIKey.query.all()
113+
return [key.to_dict() for key in api_keys]
114+
115+
116+
def get_api_key(key_id: int) -> dict | None:
117+
"""Get an API key by its ID"""
118+
api_key = APIKey.query.get(key_id)
119+
if not api_key:
120+
return None
121+
return api_key.to_dict()
122+
123+
124+
def disable_api_key(key_id: int) -> dict | None:
125+
"""Disable an API key by its ID"""
126+
api_key = APIKey.query.get(key_id)
127+
if not api_key:
128+
return None
129+
api_key.deactivate()
130+
return api_key.to_dict()
131+
132+
133+
def is_valid_api_key(api_key: str) -> bool:
134+
"""Check if an API key is valid"""
135+
for key in APIKey.query.filter_by(active=True).all():
136+
if check_password_hash(key.key, api_key):
137+
return True
138+
return False
139+
140+
141+
def valid_api_auth(f: Callable) -> Callable:
142+
"""decorater to check if valid API auth"""
143+
144+
@wraps(f)
145+
def decorated_function(*args: object, **kwargs: object) -> Response:
146+
# check if exec
147+
if is_exec():
148+
return f(*args, **kwargs)
149+
# check if valid API key
150+
# american spelling for convention
151+
api_key = request.headers.get("Authorization")
152+
if not api_key or not is_valid_api_key(api_key):
153+
return abort(403, "Invalid API key or not authorised.")
154+
return f(*args, **kwargs)
155+
156+
return decorated_function
157+
158+
159+
auth_api_bp = Blueprint("auth_api", __name__, url_prefix="/api/auth")
160+
161+
162+
@auth_api_bp.route("/create", methods=["POST"])
163+
@is_exec_wrapper
164+
def create_api_key_api() -> tuple[Response, int]:
165+
"""Create a new API key"""
166+
data = request.get_json()
167+
if not data or "owner" not in data:
168+
return jsonify({"error": "Owner is required"}), 400
169+
return jsonify(create_api_key(data["owner"])), 201
170+
171+
172+
@auth_api_bp.route("/<int:key_id>", methods=["GET"])
173+
@is_exec_wrapper
174+
def get_api_key_api(key_id: int) -> tuple[Response, int]:
175+
"""Get an API key by its ID"""
176+
api_key = get_api_key(key_id)
177+
if not api_key:
178+
return jsonify({"error": "API key not found"}), 404
179+
return jsonify(api_key), 200
180+
181+
182+
@auth_api_bp.route("/disable/<int:key_id>", methods=["POST"])
183+
@is_exec_wrapper
184+
def disable_api_key_api(key_id: int) -> tuple[Response, int]:
185+
"""Disable an API key by its ID"""
186+
api_key = disable_api_key(key_id)
187+
if not api_key:
188+
return jsonify({"error": "API key not found"}), 404
189+
return jsonify(api_key), 200
190+
191+
192+
@auth_api_bp.route("/keys", methods=["GET"])
193+
@is_exec_wrapper
194+
def get_api_keys_api() -> tuple[Response, int]:
195+
"""Get all API keys"""
196+
return jsonify(get_api_keys()), 200

events/api.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
import pytz
66
import requests
77
from flask import Blueprint, Response, jsonify, request
8-
from schema import Event, Tag, Week, db
98

10-
from auth.auth import is_exec_wrapper
9+
from auth.auth import valid_api_auth
10+
from schema import Event, Tag, Week, db
1111

1212
# bind endpoints to /api/events/...
1313
events_api_bp = Blueprint("events", __name__, url_prefix="/api/events")
@@ -108,7 +108,7 @@ def get_datetime_from_string(date_str: str) -> datetime | str:
108108

109109

110110
@events_api_bp.route("/create", methods=["POST"])
111-
@is_exec_wrapper
111+
@valid_api_auth
112112
def create_event_api() -> tuple[Response, int]: # noqa: PLR0911
113113
"""Create a new event"""
114114
data = request.get_json()
@@ -295,7 +295,7 @@ def get_week_from_date(date: datetime) -> Week | None: # noqa: PLR0912
295295

296296

297297
@events_api_bp.route("/create_repeat", methods=["POST"])
298-
@is_exec_wrapper
298+
@valid_api_auth
299299
def create_repeat_event_api() -> tuple[Response, int]: # noqa: PLR0911
300300
"""Create a bunch of events at once"""
301301
data = request.get_json()
@@ -420,7 +420,7 @@ def get_week_by_date(date_str: str) -> tuple[Response, int]:
420420

421421

422422
@events_api_bp.route("/<int:event_id>", methods=["PATCH"])
423-
@is_exec_wrapper
423+
@valid_api_auth
424424
def edit_event(event_id: int) -> tuple[Response, int]: # noqa: PLR0911, PLR0912
425425
"""Edit an existing event"""
426426
event = Event.query.get(event_id)
@@ -488,7 +488,7 @@ def edit_event(event_id: int) -> tuple[Response, int]: # noqa: PLR0911, PLR0912
488488

489489

490490
@events_api_bp.route("/<int:event_id>", methods=["DELETE"])
491-
@is_exec_wrapper
491+
@valid_api_auth
492492
def delete_event(event_id: int) -> tuple[Response, int]:
493493
"""Delete an existing event"""
494494
event = Event.query.get(event_id)

fulcrum.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from auth.auth import auth_bp, configure_oauth, is_exec, is_logged_in
88
from events.api import events_api_bp
9+
from schema import initialise_db
910

1011
# if .env file exists, load it
1112
if Path(".env").exists():
@@ -15,6 +16,9 @@
1516
app = Flask(__name__)
1617
app.secret_key = os.getenv("SECRET_KEY")
1718

19+
# initialise database
20+
initialise_db(app)
21+
1822
# setup oauth and add routes
1923
configure_oauth(app)
2024
app.register_blueprint(auth_bp)

events/schema.py renamed to schema.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ def initialise_db(app: Flask) -> None:
1616

1717
db.init_app(app)
1818

19+
# create the database tables if they don't exist
20+
with app.app_context():
21+
db.create_all()
22+
1923

2024
class Event(db.Model):
2125
"""Model for an event"""
@@ -173,3 +177,38 @@ def __repr__(self) -> str:
173177
db.Column("event_id", db.Integer, db.ForeignKey("events.id"), primary_key=True),
174178
db.Column("tag_id", db.Integer, db.ForeignKey("tags.id"), primary_key=True),
175179
)
180+
181+
182+
class APIKey(db.Model):
183+
"""Model for an API key"""
184+
185+
__tablename__ = "api_keys"
186+
187+
id = db.Column(db.Integer, primary_key=True)
188+
key = db.Column(db.String, unique=True, nullable=False)
189+
owner = db.Column(db.String, nullable=False)
190+
created_at = db.Column(db.DateTime, nullable=False)
191+
active = db.Column(db.Boolean, default=True)
192+
193+
def __init__(self, key: str, owner: str) -> None:
194+
self.key = key
195+
self.owner = owner
196+
self.created_at = datetime.datetime.now(pytz.timezone("Europe/London"))
197+
self.active = True
198+
199+
def __repr__(self) -> str:
200+
return f"<APIKey {self.key} (ID: {self.id}) owned by {self.owner}>"
201+
202+
def to_dict(self) -> dict:
203+
return {
204+
"id": self.id,
205+
"key": self.key,
206+
"owner": self.owner,
207+
"created_at": self.created_at.isoformat(),
208+
"active": self.active,
209+
}
210+
211+
def deactivate(self) -> None:
212+
"""Deactivate the API key"""
213+
self.active = False
214+
db.session.commit()

0 commit comments

Comments
 (0)