Change to oidc and new foreign id format
ci/woodpecker/manual/woodpecker Pipeline was successful Details

This commit is contained in:
Madis Mägi 2023-07-30 03:17:03 +03:00
parent d0fa0b1928
commit a28189a306
14 changed files with 390 additions and 198 deletions

18
.dockerignore Normal file
View File

@ -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

114
deployment.yaml Normal file
View File

@ -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

49
inventory-app/api.py Normal file
View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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/<item_id>/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/<item_id>/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/<item_id>/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)

View File

@ -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/<full_name>")
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/<member_id>/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)

100
inventory-app/oidc.py Normal file
View File

@ -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

View File

@ -16,7 +16,10 @@
</thead>
<tbody>
{% for item in items %}
<tr>
<tr {% if item | check_foreign_key_format %}
style="background-color: coral;"
{% endif %}
>
<td>
{% if item.shortener %}
<a href="http://k6.ee/{{ item.shortener.slug }}">{{ item.shortener.slug }}</a>

View File

@ -2,7 +2,7 @@
{% block content %}
<div class="container">
<h6>Please log in to see more details</h6>
<h6>Please <a href="/login">log in</a> to see more details</h6>
{% include "inventory_filter.html" %}
<table>
<thead>
@ -15,7 +15,11 @@
</thead>
<tbody>
{% for item in items %}
<tr>
<tr {% if item | check_foreign_key_format %}
style="background-color: coral;"
{% endif %}
>
<td>
{% if item.shortener %}
<a href="http://k6.ee/{{ item.shortener.slug }}">{{ item.shortener.slug }}</a>

8
minio.yml Normal file
View File

@ -0,0 +1,8 @@
---
apiVersion: codemowers.cloud/v1beta1
kind: MinioBucketClaim
metadata:
name: inventory-app
spec:
capacity: 1Gi
class: shared

View File

@ -13,3 +13,5 @@ sepa
Flask-WTF
prometheus-flask-exporter
pymongo
pyjwt[crypto]
kubernetes

18
serviceaccount.yml Normal file
View File

@ -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

20
skaffold.yaml Normal file
View File

@ -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'