diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8793167 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.kpt-pipeline/ +.git/ +.gitignore +deployment.yaml +LICENSE +README.md +skaffold.yaml + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +**/*.suo +**/*.ntvs* +**/*.njsproj +**/*.sln +**/*.sw? +**/*.kpt-pipeline diff --git a/deployment.yaml b/deployment.yaml new file mode 100644 index 0000000..8f1383f --- /dev/null +++ b/deployment.yaml @@ -0,0 +1,114 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: inventory-app +spec: + replicas: 1 + selector: + matchLabels: + app: inventory-app + template: + metadata: + labels: + app: inventory-app + spec: + enableServiceLinks: false + imagePullSecrets: + - name: regcred + serviceAccountName: oidc-gateway + containers: + - name: inventory-app + image: inventory-app + env: + - name: PYTHONUNBUFFERED + value: "1" + - name: RECAPTCHA_PUBLIC_KEY + value: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI + - name: RECAPTCHA_PRIVATE_KEY + value: 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe + - name: MEMBERS_HOST + value: "https://members.k-space.ee" + - name: INVENTORY_ASSETS_BASE_URL + value: "https://minio.codemowers.eu:9000" + - name: MONGO_URI + valueFrom: + secretKeyRef: + name: mongodb-application-readwrite + key: connectionString.standard + - name: AWS_ENDPOINT_URL + valueFrom: + secretKeyRef: + name: miniobucket-inventory-app-owner-secrets + key: MINIO_URI + - name: SECRET_KEY + value: "bad_secret" + - name: ENVIRONMENT_TYPE + value: "DEV" + - name: MY_POD_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + envFrom: + - secretRef: + name: oidc-client-inventory-app-owner-secrets + ports: + - containerPort: 5000 + name: metrics +--- +apiVersion: v1 +kind: Service +metadata: + name: inventory-app + labels: + app: inventory-app +spec: + selector: + app: inventory-app + ports: + - protocol: TCP + port: 5000 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: inventory-app + annotations: + kubernetes.io/ingress.class: shared + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + external-dns.alpha.kubernetes.io/target: traefik.codemowers.ee +spec: + rules: + - host: inventory-app-72zn4.codemowers.ee + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: inventory-app + port: + number: 5000 + tls: + - hosts: + - "*.codemowers.ee" +--- +apiVersion: codemowers.io/v1alpha1 +kind: OIDCGWClient +metadata: + name: inventory-app +spec: + uri: 'https://inventory-app-72zn4.codemowers.ee' + redirectUris: + - 'https://inventory-app-72zn4.codemowers.ee/login-callback' + grantTypes: + - 'authorization_code' + responseTypes: + - 'code' + availableScopes: + - 'openid' + - 'profile' + tokenEndpointAuthMethod: 'client_secret_basic' + pkce: false + diff --git a/inventory-app/api.py b/inventory-app/api.py new file mode 100644 index 0000000..3f3f12b --- /dev/null +++ b/inventory-app/api.py @@ -0,0 +1,49 @@ +import const +from pymongo import MongoClient +from flask import Blueprint, abort, g, make_response, redirect, render_template, request, jsonify +from common import CustomForm, build_query, flatten, format_name, spam +from kubernetes import client, config + +page_api = Blueprint("api", __name__) +db = MongoClient(const.MONGO_URI).get_default_database() + +def get_users(): + config.load_incluster_config() + api_instance = client.CustomObjectsApi() + ret = api_instance.list_namespaced_custom_object("codemowers.io", "v1alpha1", "default", "oidcgatewayusers") + resp = [] + for item in ret["items"]: + resp.append(item) + return resp + +@page_api.route("/users") +def view_users(): + resp = get_users() + print(resp) + return jsonify(resp) + +@page_api.route("/cards") +def get_group_cards(): + group = request.args.get("group", False) + if not group: + return "must specify group in parameter", 400 + print(group) + gu = list(filter(lambda u: any(g["name"] == group for g in u["status"]["groups"]), get_users())) + keys = list(map(lambda u: u["metadata"]["name"], gu)) + print(keys) + flt = { + "token.uid_hash": {"$exists": True}, + "inventory.owner.foreign_id": {"$in": keys} + } + prj = { + "inventory.owner": True, + "token.uid_hash": True + } + found = [] + for obj in db.inventory.find(flt, prj): + del obj["_id"] + if obj["inventory"] and obj["inventory"]["owner"] and type(obj["inventory"]["owner"]["foreign_id"]) != str: + del obj["inventory"] + found.append(obj) + return jsonify(list(found)) + diff --git a/inventory-app/common.py b/inventory-app/common.py index 8c95387..52f6cfc 100644 --- a/inventory-app/common.py +++ b/inventory-app/common.py @@ -26,13 +26,14 @@ def inventory_fetch(owner=True, item_type=None): def wrapper(f): @wraps(f) def decorated_function(*args, **kwargs): + user = read_user() d = { "_id": ObjectId(kwargs.pop("item_id")) } if item_type: d["type"] = item_type if owner: - d["inventory.owner.foreign_id"] = g.user["_id"] + d["inventory.owner.foreign_id"] = user["username"] kwargs["item"] = db.inventory.find_one(d) return f(*args, **kwargs) return decorated_function diff --git a/inventory-app/decorators.py b/inventory-app/decorators.py deleted file mode 100644 index 5de84d5..0000000 --- a/inventory-app/decorators.py +++ /dev/null @@ -1,105 +0,0 @@ -import secrets -import string -from datetime import datetime -from functools import wraps - -from bson.objectid import ObjectId -from flask import abort, g, make_response, redirect, request, session -from pymongo import MongoClient - -import const - -db = MongoClient(const.MONGO_URI).get_default_database() - - -def generate_password(length): - letters = string.ascii_letters + string.digits + string.punctuation - pw = secrets.choice(string.ascii_lowercase) - pw += secrets.choice(string.ascii_uppercase) - pw += secrets.choice(string.digits) - pw += secrets.choice(string.punctuation) - pw += "".join(secrets.choice(letters) for i in range(length - 4)) - return "".join(secrets.SystemRandom().sample(pw, length)) - -def check_login(): - token = db.token.find_one({"cookie": request.cookies.get("LOGINLINKCOOKIE", "")}) - if not token: - cookie = generate_password(50) - res = db.token.update_one({ - "token": request.args.get("token"), - "cookie": {"$exists": False} - }, { - "$set": { - "used": datetime.utcnow(), - "cookie": cookie - } - }) - if res.matched_count >= 1: - return (1, cookie) - else: - return (2, cookie) - - db.token.update_one({ - "_id": token["_id"] - }, { - "$set": { - "last_seen": datetime.utcnow(), - "user_agent": request.headers.get("User-Agent"), - "remote_addr": request.remote_addr - } - }) - - g.user = db.member.find_one({"_id": ObjectId(token["member_id"])}) - g.token = token - return (0, None); - -def has_login(): - r, _ = check_login() - return r == 0 - -def login_redirect(): - session['target_path'] = request.path - return redirect("/login") - -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - r, cookie = check_login() - if r == 1: - resp = make_response() - resp.set_cookie("LOGINLINKCOOKIE", cookie) - resp.headers["location"] = request.path - return resp, 302 - elif r == 2: - return login_redirect() - return f(*args, **kwargs) - return decorated_function - - -def rget(d, attr): - if not d: - return None - if "." in attr: - pre, post = attr.split(".", 1) - return rget(d.get(pre), post) - else: - return d.get(attr) - - -def required(path, msg=None, status=403): - def ff(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if not rget(g.user, path): - abort(status, description=msg) - return f(*args, **kwargs) - return decorated_function - return ff - - -def board_member_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - _f = required("access.board", "K-SPACE MTÜ board member required") - return _f(f)(*args, **kwargs) - return decorated_function diff --git a/inventory-app/inventory.py b/inventory-app/inventory.py index b377873..524d026 100644 --- a/inventory-app/inventory.py +++ b/inventory-app/inventory.py @@ -12,7 +12,7 @@ from wtforms.validators import Length import const from common import CustomForm, build_query, flatten, format_name, spam -from decorators import has_login, login_redirect, login_required +from oidc import page_oidc, login_required, read_user page_inventory = Blueprint("inventory", __name__) db = MongoClient(const.MONGO_URI).get_default_database() @@ -21,9 +21,9 @@ db = MongoClient(const.MONGO_URI).get_default_database() def view_inventory_view(item_id): template = "inventory_view.html" item = db.inventory.find_one({ "_id": ObjectId(item_id) }) - if not has_login(): + if not read_user(): if not item["inventory"].get("public"): - return login_redirect() + return do_login() template = "inventory_view_public.html" base_url = const.INVENTORY_ASSETS_BASE_URL photo_url = "%s/kspace-inventory/%s" % (base_url, item_id) @@ -355,7 +355,7 @@ def view_inventory(slug=None): q = {"type": {"$ne": "token"}} template = "inventory.html" public_view = False - if not has_login(): + if not read_user(): q.update({"inventory.public": True}) template = "inventory_public.html" public_view = True @@ -409,28 +409,29 @@ def view_inventory(slug=None): { "$match": q2 }, { "$sort": { sort_field_final : 1 if sort_direction == "asc" else -1 } } ]) - return render_template(template, **locals()) @page_inventory.route("/m/inventory//claim", methods=["POST"]) @login_required def view_inventory_claim(item_id): + user = read_user() db.inventory.update_one({ "_id": ObjectId(item_id), "inventory.owner.foreign_id": None }, { "$set": { - "inventory.owner.foreign_id": ObjectId(g.user["_id"]), - "inventory.owner.display_name": g.user["full_name"], + "inventory.owner.foreign_id": user["username"], + "inventory.owner.display_name": user["name"], }, }) - return redirect("/m/user/%s" % g.user["_id"]) + return redirect("/m/inventory/%s/view" % item_id) @page_inventory.route("/m/inventory//use", methods=["POST"]) @login_required def view_inventory_use(item_id): + user = read_user() item = db.inventory.find_one({ "_id": ObjectId(item_id), "inventory.usable": True, @@ -443,25 +444,26 @@ def view_inventory_use(item_id): "_id": ObjectId(item["_id"]) }, { "$set": { - "inventory.user.foreign_id": ObjectId(g.user["_id"]), - "inventory.user.display_name": g.user["full_name"], + "inventory.user.foreign_id": ObjectId(user["username"]), + "inventory.user.display_name": user["name"], }, }) - name = format_name(item) - msg = "%s has started using %s" % (g.user["full_name"], name) + item_name = format_name(item) + msg = "%s has started using %s" % (user["name"], item_name) if item.get("shortener") and item["shortener"].get("slug"): msg += ("\nk6.ee/%s" % item["shortener"]["slug"]) spam(msg) - return redirect("/m/user/%s" % g.user["_id"]) + return redirect("/m/inventory/%s/view" % item_id) @page_inventory.route("/m/inventory//vacate", methods=["POST"]) @login_required def view_inventory_vacate(item_id): + user = read_user() item = db.inventory.find_one({ "_id": ObjectId(item_id), "inventory.usable": True, - "inventory.user.foreign_id": ObjectId(g.user["_id"]) + "inventory.user.foreign_id": ObjectId(user["username"]) }) if not item: return abort(404) @@ -473,9 +475,9 @@ def view_inventory_vacate(item_id): "inventory.user": "" }, }) - name = format_name(item) - msg = "%s has stopped using %s" % (g.user["full_name"], name) + item_name = format_name(item) + msg = "%s has stopped using %s" % (user["name"], item_name) if item.get("shortener") and item["shortener"].get("slug"): msg += ("\nk6.ee/%s" % item["shortener"]["slug"]) spam(msg) - return redirect("/m/user/%s" % g.user["_id"]) + return redirect("/m/inventory/%s/view" % item_id) diff --git a/inventory-app/main.py b/inventory-app/main.py index 8ab098c..e1a413d 100755 --- a/inventory-app/main.py +++ b/inventory-app/main.py @@ -46,8 +46,19 @@ from wtforms.validators import DataRequired import const from common import CustomForm, devenv, flatten, format_name, spam -from decorators import board_member_required, generate_password, login_required from inventory import page_inventory +from oidc import page_oidc, login_required, read_user +from api import page_api + +def check_foreign_key_format(item): + try: + if type(item["inventory"]["owner"]["foreign_id"]) == ObjectId: + return True + if type(item["inventory"]["user"]["foreign_id"]) == ObjectId: + return True + except: + pass + return False def render_markdown(text): if not text: @@ -94,6 +105,7 @@ jinja2.filters.FILTERS['owner_link'] = render_owner_link jinja2.filters.FILTERS['user_link'] = render_user_link jinja2.filters.FILTERS['is_list'] = is_list jinja2.filters.FILTERS['quote_plus'] = lambda u: urllib.parse.quote_plus(u) +jinja2.filters.FILTERS['check_foreign_key_format'] = check_foreign_key_format env = Environment(loader=FileSystemLoader('templates/')) @@ -110,6 +122,8 @@ class ReverseProxied(object): app = Flask(__name__) app.wsgi_app = ReverseProxied(app.wsgi_app) app.register_blueprint(page_inventory) +app.register_blueprint(page_oidc) +app.register_blueprint(page_api) metrics = PrometheusMetrics(app, group_by="path") app.config['SECRET_KEY'] = const.SECRET_KEY @@ -172,82 +186,26 @@ def name_check(form, field): if any(c.isdigit() for c in field.data): raise ValidationError("Name must not contain numbers") -@dev_only -@app.route("/dev_login") -@app.route("/dev_login/") -def dev_login(full_name=None): - allowed_users = ["Mickey Mouse", "Donald Duck"] - if not full_name or full_name not in allowed_users: - full_name = "Mickey Mouse" - member = mongodb.member.find_one({"full_name": full_name}) - - token = generate_password(20) - d = { - "member_id": member["_id"], - "full_name": member["full_name"], - "token": token, - "method": "link" - } - mongodb.token.insert_one(d) - url = "%sm/profile?token=%s" % (request.url_root, urllib.parse.quote_plus(token)) - return redirect(url, code=302) - -@app.route("/m/user//login") -@metrics.do_not_track() -@login_required -def view_send_login_link(member_id): - user = g.user - member = mongodb.member.find_one({"_id": ObjectId(member_id), "enabled": True}) - if not member: - raise - send_login_link(member, - provider = g.user["full_name"]) - return redirect("/m/user/%s" % member_id) - - -class LoginLinkForm(CustomForm): - email = EmailField('Email address', validators = [validators.DataRequired(), validators.Email()]) - recaptcha = RecaptchaField() - -@app.route("/login/address", methods=["POST"]) -def request_login_link(): - form = LoginLinkForm(request.form) - if not form.validate_on_submit(): - return abort(400) - sleep(secrets.SystemRandom().randint(10, 40)) - member = mongodb.member.find_one({"mail": form.email.data, "enabled": True}) - if member: - send_login_link(member) - return render_template("login_link_request.html", **locals()) +# just to manually enter login_required annotation @app.route("/login") -def view_login(): - form = LoginLinkForm() - return render_template("login.html", **locals()) +@login_required +def login_dummy(): + return redirect("/m/inventory") +@app.route("/") +def index(): + return redirect("/m/inventory") -@app.route("/login/authelia") -def view_login_authelia(): - redirect_location = session.pop('target_path', "/m/profile") - username = request.headers.get("Remote-User") - if not username: - raise ValueError("Ding dong") +@app.route("/me") +@login_required +def view_profile(): + user = read_user() + return f"Hello {user['name']}" - member = mongodb.member.find_one({"ad.username": username, "enabled":True}) - if not member: - return render_template("login_error.html") - d = { - "member_id": member["_id"], - "full_name": member["full_name"], - "method": "authelia", - "cookie": generate_password(50) - } - - mongodb.token.insert_one(d) - resp = make_response() - resp.set_cookie("LOGINLINKCOOKIE", d["cookie"]) - resp.headers['location'] = redirect_location - return resp, 302 +@app.route("/hello") +def view_hello(): + return "Hello!" class MultiCheckboxField(SelectMultipleField): widget = widgets.ListWidget(prefix_label=False) diff --git a/inventory-app/oidc.py b/inventory-app/oidc.py new file mode 100644 index 0000000..080d375 --- /dev/null +++ b/inventory-app/oidc.py @@ -0,0 +1,100 @@ +import os +import const +import jwt +import base64 +import requests +from pymongo import MongoClient +from flask import Blueprint, abort, g, make_response, redirect, render_template, request, Flask, request, url_for, session +from common import CustomForm, build_query, flatten, format_name, spam +from functools import wraps + +page_oidc = Blueprint("oidc", __name__) +db = MongoClient(const.MONGO_URI).get_default_database() +metadata = requests.get("https://auth.codemowers.eu/.well-known/openid-configuration").json() + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not read_user(): + print("doing login redirect") + session["original_url"] = request.full_path + return do_login() + return f(*args, **kwargs) + return decorated_function + +def do_login(): + url = add_url_params(metadata["authorization_endpoint"], { + "client_id": os.getenv("OIDC_CLIENT_ID"), + "redirect_uri": url_for("oidc.login_callback", _external=True, _scheme='https'), + "response_type": "code", + "scope": "openid profile", + }) + return redirect(url) + +def add_url_params(url, params): + req = requests.models.PreparedRequest() + req.prepare_url(url, params) + return req.url + +@page_oidc.route('/login-callback') +def login_callback(): + code = request.args.get('code') + r = requests.post(metadata["token_endpoint"], { + "code": code, + "grant_type": "authorization_code", + "redirect_uri": url_for("oidc.login_callback", _external=True, _scheme='https'), + "client_id": os.getenv("OIDC_CLIENT_ID"), + "client_secret": os.getenv("OIDC_CLIENT_SECRET"), + }).json() + if "error" in r: + return "failed to fetch tokens", 500 + if not validate_id_token(r["id_token"]) or not read_user(r["access_token"]): + return "tokens validation failed", 500 + print("authenticated") + session["id_token"] = r["id_token"] + session["access_token"] = r["access_token"] + return redirect(session.pop("original_url", "/")) + +@page_oidc.route("/logout") +def logout(): + token = session.pop("access_token", "asdf") + session.clear() + s = os.getenv("OIDC_CLIENT_ID") + ":" + os.getenv("OIDC_CLIENT_SECRET") + r = requests.post( + url = metadata["revocation_endpoint"], + headers = {"Authorization": "Basic " + base64.b64encode(s.encode()).decode()}, + data = {"token": token, "token_type_hint": "access_token"} + ) + if r.status_code != 200: + return "oops", 500 + return redirect("/") + +def read_user(token=None): + token = token or session.get("access_token", False) + if not token: + return False + r = requests.get(url = metadata["userinfo_endpoint"], headers = { + "Authorization": "Bearer " + token + }) + if r.status_code == 200: + return r.json() + else: + return False + +def validate_id_token(token=None): + token = token or session.get("id_token", False) + if not token: + return False + jwks_client = jwt.PyJWKClient(metadata["jwks_uri"]) + signing_key = jwks_client.get_signing_key_from_jwt(token) + try: + return jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + audience=os.getenv("OIDC_CLIENT_ID"), + options={"verify_exp": True}, + ) + except jwt.InvalidTokenError as e: + return False + diff --git a/inventory-app/templates/inventory.html b/inventory-app/templates/inventory.html index 98a64c9..1df44ad 100644 --- a/inventory-app/templates/inventory.html +++ b/inventory-app/templates/inventory.html @@ -16,7 +16,10 @@ {% for item in items %} - + {% if item.shortener %} {{ item.shortener.slug }} diff --git a/inventory-app/templates/inventory_public.html b/inventory-app/templates/inventory_public.html index aac080a..85111e5 100644 --- a/inventory-app/templates/inventory_public.html +++ b/inventory-app/templates/inventory_public.html @@ -2,7 +2,7 @@ {% block content %}
-
Please log in to see more details
+
Please log in to see more details
{% include "inventory_filter.html" %} @@ -15,7 +15,11 @@ {% for item in items %} - + +
{% if item.shortener %} {{ item.shortener.slug }} diff --git a/minio.yml b/minio.yml new file mode 100644 index 0000000..508840a --- /dev/null +++ b/minio.yml @@ -0,0 +1,8 @@ +--- +apiVersion: codemowers.cloud/v1beta1 +kind: MinioBucketClaim +metadata: + name: inventory-app +spec: + capacity: 1Gi + class: shared diff --git a/requirements.txt b/requirements.txt index 1982127..0b71610 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,5 @@ sepa Flask-WTF prometheus-flask-exporter pymongo +pyjwt[crypto] +kubernetes diff --git a/serviceaccount.yml b/serviceaccount.yml new file mode 100644 index 0000000..b08fd3d --- /dev/null +++ b/serviceaccount.yml @@ -0,0 +1,18 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: oidc-gateway-madis +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: oidc-gateway + namespace: hard2k1ll-72zn4 +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: oidc-gateway diff --git a/skaffold.yaml b/skaffold.yaml new file mode 100644 index 0000000..a4f567e --- /dev/null +++ b/skaffold.yaml @@ -0,0 +1,20 @@ +apiVersion: skaffold/v3 +kind: Config +metadata: + name: inventory-app + +deploy: + kubectl: {} + +manifests: + rawYaml: + - deployment.yaml + +build: + artifacts: + - image: inventory-app + sync: + manual: + - src: "inventory-app/**/*.{py,html}" + dest: . + strip: 'inventory-app'