Compare commits

..

17 Commits

Author SHA1 Message Date
b06757fb03 Updated gitignore
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline failed
2024-10-05 09:56:02 +01:00
cc3d279228 Update woodpecker file 2024-10-05 09:55:25 +01:00
6e21ebf600 add arg checks to all API endpoints 2024-10-04 23:23:34 +01:00
aedd56c223 Add webargs for API 2024-10-04 22:41:40 +01:00
36f6d8e8d4 Add Sentry 2024-10-04 21:55:37 +01:00
0c46114d9e Additional style improvements 2024-10-04 21:10:23 +01:00
4119c30ba8 Updated IDP styling 2024-10-04 20:52:47 +01:00
16990fb3a6 Turn IDP into a proper ES module 2024-10-04 20:29:13 +01:00
0a6a75b08e Turn IDP into a proper ES module 2024-10-04 20:28:17 +01:00
1be4f33d8c Add IDP linting 2024-10-04 20:08:59 +01:00
4f6b652660 Add web linting 2024-10-04 20:00:08 +01:00
64b124b37e Add ruff python checks/formats 2024-10-04 19:54:24 +01:00
cbf07017e2 bumped Python version 2024-10-04 19:50:07 +01:00
565daa6647 de-chaliced API 2024-10-04 19:45:54 +01:00
f8ebd209cf Updated IDP deps 2024-10-04 17:12:42 +01:00
0f47a123ae Updated API deps and code changes 2024-10-04 15:11:10 +01:00
607b469625 Updated web deps 2024-10-04 14:52:57 +01:00
45 changed files with 7898 additions and 3350 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.sw*
.DS_Store

View File

@ -1,4 +1,4 @@
pipeline: steps:
buildweb: buildweb:
group: build group: build
image: node image: node
@ -44,4 +44,5 @@ pipeline:
- s3cmd -c /root/.s3cfg sync --no-mime-magic --guess-mime-type dist/* s3://sso.tools - s3cmd -c /root/.s3cfg sync --no-mime-magic --guess-mime-type dist/* s3://sso.tools
- 'curl -X POST -H "AccessKey: $BUNNY_KEY" https://api.bunny.net/pullzone/782714/purgeCache' - 'curl -X POST -H "AccessKey: $BUNNY_KEY" https://api.bunny.net/pullzone/782714/purgeCache'
branches: main when:
branch: main

View File

@ -1,4 +1,4 @@
FROM python:3.9-slim-buster FROM python:3.12-slim-bookworm
# set work directory # set work directory
WORKDIR /app WORKDIR /app

View File

@ -7,7 +7,7 @@ This directory contains the source code for the primary API for the SSO Tools we
Firstly, create and enter a virtual environment: Firstly, create and enter a virtual environment:
```shell ```shell
virtualenv -p python3 .venv # You only need this the first time virtualenv -p python3.12 .venv # You only need this the first time
source .venv/bin/activate source .venv/bin/activate
``` ```

223
api/api/accounts.py Normal file
View File

@ -0,0 +1,223 @@
import datetime
import jwt
import bcrypt
import os
from bson.objectid import ObjectId
from util import database, mail, errors
jwt_secret = os.environ.get("JWT_SECRET")
def create(data):
email = data.get("email")
first_name = data.get("firstName")
last_name = data.get("lastName")
password = data.get("password")
if not email or len(email) < 6:
raise errors.BadRequest("Your name or email is too short or invalid.")
if not password or len(password) < 8:
raise errors.BadRequest("Your password should be at least 8 characters.")
email = email.lower()
idps_to_claim = data.get("idpsToClaim", []) or []
idps_to_claim = list(map(lambda i: ObjectId(i), idps_to_claim))
db = database.get_db()
existingUser = db.users.find_one({"email": email})
if existingUser:
raise errors.BadRequest("An account with this email already exists.")
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
new_user = {
"firstName": first_name,
"lastName": last_name,
"email": email,
"password": hashed_password,
"createdAt": datetime.datetime.utcnow(),
}
result = db.users.insert_one(new_user)
new_user["_id"] = result.inserted_id
if len(idps_to_claim):
db.idps.update_many(
{"_id": {"$in": idps_to_claim}, "user": {"$exists": False}},
{"$set": {"user": new_user["_id"]}},
)
mail.send(
{
"to": os.environ.get("ADMIN_EMAIL"),
"subject": "{} signup".format(os.environ.get("APP_NAME")),
"text": "A new user signed up with email {0}".format(email),
}
)
return {"token": generate_access_token(new_user["_id"])}
def enrol(data):
if not data or "token" not in data or "password" not in data:
raise errors.BadRequest("Invalid request")
token = data.get("token")
password = data.get("password")
if not token:
raise errors.BadRequest("Invalid token")
if not password or len(password) < 8:
raise errors.BadRequest("Your password should be at least 8 characters.")
try:
db = database.get_db()
id = jwt.decode(data["token"], jwt_secret)["sub"]
user = db.users.find_one({"_id": ObjectId(id), "tokens.enrolment": token})
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
db.users.update_one(
{"_id": user["_id"]},
{"$set": {"password": hashed_password}, "$unset": {"tokens.enrolment": ""}},
)
return {"token": generate_access_token(user["_id"])}
except Exception as e:
print(e)
raise errors.BadRequest(
"Unable to enrol your account. Your token may be invalid or expired."
)
def login(data):
email = data.get("email")
password = data.get("password")
idps_to_claim = data.get("idpsToClaim", []) or []
idps_to_claim = list(map(lambda i: ObjectId(i), idps_to_claim))
db = database.get_db()
user = db.users.find_one({"email": email.lower()})
try:
if user and bcrypt.checkpw(password.encode("utf-8"), user["password"]):
if len(idps_to_claim):
db.idps.update_many(
{"_id": {"$in": idps_to_claim}, "user": {"$exists": False}},
{"$set": {"user": user["_id"]}},
)
return {"token": generate_access_token(user["_id"])}
else:
raise errors.BadRequest("Your email or password is incorrect.")
except Exception as e:
print(e)
raise errors.BadRequest("Your email or password is incorrect.")
def logout(user):
db = database.get_db()
db.users.update_one(
{"_id": user["_id"]}, {"$pull": {"tokens.login": user["currentToken"]}}
)
return {"loggedOut": True}
def update_password(user, data):
if not data:
raise errors.BadRequest("Invalid request")
if "newPassword" not in data:
raise errors.BadRequest("Invalid request")
if len(data["newPassword"]) < 8:
raise errors.BadRequest("New password is too short")
db = database.get_db()
if "currentPassword" in data:
if not bcrypt.checkpw(
data["currentPassword"].encode("utf-8"), user["password"]
):
raise errors.BadRequest("Incorrect password")
elif "token" in data:
try:
id = jwt.decode(data["token"], jwt_secret, algorithms=["HS256"])["sub"]
user = db.users.find_one(
{"_id": ObjectId(id), "tokens.passwordReset": data["token"]}
)
if not user:
raise Exception
except Exception as e:
print(e)
raise errors.BadRequest(
"There was a problem updating your password. Your token may be invalid or out of date"
)
else:
raise errors.BadRequest("Current password or reset token is required")
if not user:
raise errors.BadRequest("Unable to change your password")
hashed_password = bcrypt.hashpw(
data["newPassword"].encode("utf-8"), bcrypt.gensalt()
)
db.users.update_one(
{"_id": user["_id"]},
{"$set": {"password": hashed_password}, "$unset": {"tokens.passwordReset": ""}},
)
return {"passwordUpdated": True}
def delete(user, data):
password = data.get("password")
if not password or not bcrypt.checkpw(password.encode("utf-8"), user["password"]):
raise errors.BadRequest("Incorrect password")
db = database.get_db()
for idp in db.idps.find({"user": user["_id"]}):
db.idpSps.delete_many({"idp": idp["_id"]})
db.idpUsers.delete_many({"idp": idp["_id"]})
db.idpAttributes.delete_many({"idp": idp["_id"]})
db.idps.delete_many({"user": user["_id"]})
db.users.delete_one({"_id": user["_id"]})
return {"deletedUser": user["_id"]}
def generate_access_token(user_id):
payload = {
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=30),
"iat": datetime.datetime.utcnow(),
"sub": str(user_id),
}
token = jwt.encode(payload, jwt_secret, algorithm="HS256")
db = database.get_db()
db.users.update_one({"_id": user_id}, {"$addToSet": {"tokens.login": token}})
return token
def get_user_context(token):
if not token:
return None
try:
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
id = payload["sub"]
if id:
db = database.get_db()
user = db.users.find_one({"_id": ObjectId(id), "tokens.login": token})
db.users.update_one(
{"_id": user["_id"]}, {"$set": {"lastSeenAt": datetime.datetime.now()}}
)
user["currentToken"] = token
return user
except Exception as e:
print(e)
return None
def reset_password(data):
if not data or "email" not in data:
raise errors.BadRequest("Invalid request")
if len(data["email"]) < 5:
raise errors.BadRequest("Your email is too short")
db = database.get_db()
user = db.users.find_one({"email": data["email"].lower()})
if user:
payload = {
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=1),
"iat": datetime.datetime.utcnow(),
"sub": str(user["_id"]),
}
token = jwt.encode(payload, jwt_secret, algorithm="HS256")
mail.send(
{
"to_user": user,
"subject": "Reset your password",
"text": "Dear {0},\n\nA password reset email was recently requested for your SSO Tools account. If this was you and you want to continue, please follow the link below:\n\n{1}\n\nThis link will expire after 24 hours.\n\nIf this was not you, then someone may be trying to gain access to your account. We recommend using a strong and unique password for your account.".format(
user["firstName"], "https://sso.tools/password/reset?token=" + token
),
}
)
db.users.update_one(
{"_id": user["_id"]}, {"$set": {"tokens.passwordReset": token}}
)
return {"passwordResetEmailSent": True}

502
api/api/idps.py Normal file
View File

@ -0,0 +1,502 @@
from uuid import uuid4
import bcrypt
import re
import random
import string
from OpenSSL import crypto
import pymongo
from bson.objectid import ObjectId
from util import database, errors, util
forbidden_codes = [
"",
"app",
"my",
"www",
"support",
"mail",
"email",
"dashboard",
"ssotools",
"myidp",
]
def create_self_signed_cert(name):
# create a key pair
k = crypto.PKey()
k.generate_key(crypto.TYPE_RSA, 1024)
# create a self-signed cert
cert = crypto.X509()
cert.get_subject().C = "GB"
cert.get_subject().O = "SSO Tools"
cert.get_subject().OU = name
cert.gmtime_adj_notBefore(0)
cert.gmtime_adj_notAfter(20 * 365 * 24 * 60 * 60)
cert.set_issuer(cert.get_subject())
cert.set_pubkey(k)
cert.sign(k, "sha256")
dumped = crypto.dump_certificate(crypto.FILETYPE_PEM, cert)
dumped_key = crypto.dump_privatekey(crypto.FILETYPE_PEM, k)
return {"cert": dumped, "key": dumped_key}
def can_manage_idp(user, idp):
if not idp:
return False
if not user:
return not idp.get("user")
if user:
return (
util.has_permission(user, "root")
or idp.get("user") == user["_id"]
or not idp.get("user")
)
def create(user, data):
db = database.get_db()
if not data or not data.get("code") or not data.get("name"):
return errors.BadRequest("Name and issuer are required")
code = data.get("code").lower().strip()
if not len(code) or code in forbidden_codes or db.idps.find_one({"code": code}):
raise errors.BadRequest("The issuer is invalid or is already in use")
if not re.match(r"^([a-zA-Z0-9\-_]+)$", code):
raise errors.BadRequest(
"The IdP issuer is invalid. Please just use letters, numbers, hyphens, and underscores."
)
x509 = create_self_signed_cert(data["name"])
idp = {
"name": data.get("name"),
"code": code,
"saml": {
"certificate": x509["cert"].decode("utf-8"),
"privateKey": x509["key"].decode("utf-8"),
},
}
if user:
idp["user"] = user["_id"]
result = db.idps.insert_one(idp)
idp["_id"] = result.inserted_id
create_user(
user,
idp["_id"],
{
"email": "joe@example.com",
"firstName": "Joe",
"lastName": "Bloggs",
"password": "password",
},
)
create_user(
user,
idp["_id"],
{
"email": "jane@example.com",
"firstName": "Jane",
"lastName": "Doe",
"password": "password",
},
)
create_attribute(
user,
idp["_id"],
{"name": "Group", "defaultValue": "staff", "samlMapping": "group"},
)
return idp
def get(user, include):
db = database.get_db()
if include:
include = list(map(lambda i: ObjectId(i), include.split(",")))
else:
include = []
if user:
query = {
"$or": [
{"user": user["_id"]},
{"_id": {"$in": include}, "user": {"$exists": False}},
]
}
else:
query = {"_id": {"$in": include}, "user": {"$exists": False}}
idps = list(db.idps.find(query, {"name": 1, "code": 1}))
return {"idps": idps}
def get_one(user, id):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp:
raise errors.NotFound("The IdP could not be found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't view this IdP")
idp["users"] = list(
db.idpUsers.find(
{"idp": idp["_id"]}, {"firstName": 1, "lastName": 1, "email": 1}
)
)
return idp
def update(user, id, data):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't edit this IdP")
if "code" in data:
code = data.get("code").lower().strip()
if (
not len(code)
or code in forbidden_codes
or db.idps.find_one({"code": code, "_id": {"$ne": id}})
):
raise errors.BadRequest("This code is invalid or is already in use")
if not re.match(r"^([a-zA-Z0-9\-_]+)$", code):
raise errors.BadRequest(
"The IdP issuer is invalid. Please just use letters, numbers, hyphens, and underscores."
)
else:
code = idp["code"]
update_data = {"name": data.get("name", idp["name"]), "code": code}
db.idps.update_one({"_id": id}, {"$set": update_data})
return get_one(user, id)
def delete(user, id):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't delete this IdP")
db.idps.delete_one({"_id": id})
return {"deletedIDP": id}
#### SPs
def create_sp(user, id, data):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't manage this IdP")
sp = {
"name": data.get("name"),
"type": "saml",
"entityId": data.get("entityId"),
"serviceUrl": data.get("serviceUrl"),
"callbackUrl": data.get("callbackUrl"),
"logoutUrl": data.get("logoutUrl"),
"logoutCallbackUrl": data.get("logoutCallbackUrl"),
"oauth2ClientId": str(uuid4()),
"oauth2ClientSecret": str(
"".join(random.choices(string.ascii_uppercase + string.digits, k=32))
),
"oauth2RedirectUri": data.get("oauth2RedirectUri"),
"idp": id,
}
result = db.idpSps.insert_one(sp)
sp["_id"] = result.inserted_id
return sp
def update_sp(user, id, sp_id, data):
id = ObjectId(id)
sp_id = ObjectId(sp_id)
db = database.get_db()
existing = db.idpSps.find_one(sp_id)
if not existing:
return errors.NotFound("SP not found")
idp = db.idps.find_one(existing["idp"])
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't update this IdP")
update_data = {
"name": data.get("name"),
"entityId": data.get("entityId"),
"serviceUrl": data.get("serviceUrl"),
"callbackUrl": data.get("callbackUrl"),
"logoutUrl": data.get("logoutUrl"),
"logoutCallbackUrl": data.get("logoutCallbackUrl"),
"oauth2RedirectUri": data.get("oauth2RedirectUri"),
}
db.idpSps.update_one({"_id": sp_id}, {"$set": update_data})
return db.idpSps.find_one(
{"_id": sp_id},
{
"name": 1,
"entityId": 1,
"serviceUrl": 1,
"callbackUrl": 1,
"logoutUrl": 1,
"logoutCallbackUrl": 1,
},
)
def get_sps(user, id):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't update this IdP")
sps = list(db.idpSps.find({"idp": id}))
return {"sps": sps}
def delete_sp(user, id, sp_id):
id = ObjectId(id)
sp_id = ObjectId(sp_id)
db = database.get_db()
existing = db.idpSps.find_one(sp_id)
if not existing:
return errors.NotFound("SP not found")
idp = db.idps.find_one(existing["idp"])
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't update this IdP")
db.idpSps.delete_one({"_id": sp_id})
return {"deletedIDPSP": sp_id}
#### Users
def create_user(user, id, data):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't update this IdP")
password = data["password"]
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
new_user = {
"firstName": data.get("firstName"),
"lastName": data.get("lastName"),
"email": data["email"],
"password": hashed_password,
"attributes": data.get("attributes", {}),
"idp": id,
}
result = db.idpUsers.insert_one(new_user)
new_user["_id"] = result.inserted_id
del new_user["password"]
return new_user
def update_user(user, id, user_id, data):
id = ObjectId(id)
user_id = ObjectId(user_id)
db = database.get_db()
existing = db.idpUsers.find_one(user_id)
if not existing:
return errors.NotFound("User not found")
idp = db.idps.find_one(existing["idp"])
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't update this IdP")
update_data = {
"firstName": data.get("firstName", existing.get("firstName")),
"lastName": data.get("lastName", existing.get("lastName")),
"email": data.get("email", existing.get("email")),
"attributes": data.get("attributes", existing.get("attributes", {})),
}
if data.get("password"):
hashed_password = bcrypt.hashpw(
data["password"].encode("utf-8"), bcrypt.gensalt()
)
update_data["password"] = hashed_password
db.idpUsers.update_one({"_id": user_id}, {"$set": update_data})
return db.idpUsers.find_one(
{"_id": user_id}, {"firstName": 1, "lastName": 1, "email": 1, "attributes": 1}
)
def get_users(user, id):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't manage this IdP")
users = list(
db.idpUsers.find(
{"idp": id}, {"firstName": 1, "lastName": 1, "email": 1, "attributes": 1}
)
)
return {"users": users}
def delete_user(user, id, user_id):
id = ObjectId(id)
user_id = ObjectId(user_id)
db = database.get_db()
existing = db.idpUsers.find_one(user_id)
if not existing:
return errors.NotFound("User not found")
idp = db.idps.find_one(existing["idp"])
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't update this IdP")
db.idpUsers.delete_one({"_id": user_id})
return {"deletedUser": user_id}
#### Attributes
def create_attribute(user, id, data):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't update this IdP")
if not data or "name" not in data:
return errors.BadRequest("Attribute name is required")
new_attr = {
"name": data.get("name"),
"defaultValue": data.get("defaultValue"),
"samlMapping": data.get("samlMapping"),
"idp": id,
}
result = db.idpAttributes.insert_one(new_attr)
new_attr["_id"] = result.inserted_id
return new_attr
def update_attribute(user, id, attr_id, data):
id = ObjectId(id)
attr_id = ObjectId(attr_id)
db = database.get_db()
existing = db.idpAttributes.find_one(attr_id)
if not existing:
return errors.NotFound("Attribute not found")
idp = db.idps.find_one(existing["idp"])
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't update this IdP")
update_data = {
"name": data.get("name", existing.get("name")),
"defaultValue": data.get("defaultValue", existing.get("defaultValue")),
"samlMapping": data.get("samlMapping", existing.get("samlMapping")),
}
db.idpAttributes.update_one({"_id": attr_id}, {"$set": update_data})
return db.idpAttributes.find_one({"_id": attr_id})
def get_attributes(user, id):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't manage this IdP")
attributes = list(db.idpAttributes.find({"idp": id}))
return {"attributes": attributes}
def delete_attribute(user, id, attr_id):
id = ObjectId(id)
attr_id = ObjectId(attr_id)
db = database.get_db()
existing = db.idpAttributes.find_one(attr_id)
if not existing:
return errors.NotFound("Attribute not found")
idp = db.idps.find_one(existing["idp"])
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't update this IdP")
db.idpAttributes.delete_one({"_id": attr_id})
return {"deletedAttribute": attr_id}
def get_logs(user, id):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't access this IdP")
logs = list(
db.requests.find({"idp": id}).sort("createdAt", pymongo.DESCENDING).limit(30)
)
sps = list(db.idpSps.find({"idp": id}, {"name": 1}))
for log in logs:
if log.get("data", {}).get("assertion", {}).get("key"):
log["data"]["assertion"]["key"] = "REDACTED"
for sp in sps:
if log["sp"] == sp["_id"]:
log["spName"] = sp["name"]
break
return {"logs": logs}
def get_oauth_logs(user, id):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp:
return errors.NotFound("IDP not found")
if not can_manage_idp(user, idp):
raise errors.Forbidden("You can't access this IdP")
logs = list(
db.oauthRequests.find({"idp": id})
.sort("createdAt", pymongo.DESCENDING)
.limit(30)
)
sps = list(db.idpSps.find({"idp": id}, {"name": 1}))
for log in logs:
for sp in sps:
if log["sp"] == sp["_id"]:
log["spName"] = sp["name"]
break
return {"logs": logs}

33
api/api/root.py Normal file
View File

@ -0,0 +1,33 @@
from util import database, errors, util
def get_users(user):
if not util.has_permission(user, "root"):
raise errors.Forbidden("Not allowed")
db = database.get_db()
users = list(
db.users.find(
{},
{
"firstName": 1,
"lastName": 1,
"email": 1,
"createdAt": 1,
"lastSeenAt": 1,
},
)
.sort("lastSeenAt", -1)
.limit(50)
)
idps = list(
db.idps.find(
{"user": {"$in": list(map(lambda u: u["_id"], users))}},
{"user": 1, "code": 1, "name": 1, "createdAt": 1},
)
)
for u in users:
u["idps"] = []
for i in idps:
if i["user"] == u["_id"]:
u["idps"].append(i)
return {"users": users}

60
api/api/users.py Normal file
View File

@ -0,0 +1,60 @@
from bson.objectid import ObjectId
from util import database, errors, mail
def me(user):
return {
"_id": user["_id"],
"firstName": user.get("firstName"),
"lastName": user.get("lastName"),
"email": user.get("email"),
"subscriptions": user.get("subscriptions", []),
"permissions": user.get("permissions", {}),
}
def get(user, id):
db = database.get_db()
if str(user["_id"]) != id:
raise errors.Forbidden("Not allowed")
return db.users.find_one({"_id": ObjectId(id)}, {"firstName": 1, "lastName": 1})
def update(user, id, data):
if not data:
raise errors.BadRequest("Invalid request")
db = database.get_db()
if str(user["_id"]) != id:
raise errors.Forbidden("Not allowed")
update = {}
if data.get("email") and data["email"] != user.get("email"):
email = data["email"].lower()
existing_user = db.users.find_one({"_id": {"$ne": user["_id"]}, "email": email})
if existing_user:
raise errors.BadRequest("This new email address is already in use")
mail_content = "Dear {0},\n\nThis email is to let you know that the email address for your SSO Tools account has been changed to: {1}.\n\nIf this was not you, and/or you believe your account has been compromised, please login as soon as possible and change your account password.".format(
user["firstName"], email
)
mail.send(
{
"to_user": user,
"subject": "SSOTools Email Address Changed",
"text": mail_content,
}
)
mail.send(
{
"to": email,
"subject": "SSOTools Email Address Changed",
"text": mail_content,
}
)
update["email"] = email
if "firstName" in data:
update["firstName"] = data["firstName"]
if "lastName" in data:
update["lastName"] = data["lastName"]
if update:
db.users.update_one({"_id": ObjectId(id)}, {"$set": update})
return get(user, id)

View File

@ -1,148 +1,349 @@
import sentry_sdk
from flask import Flask, jsonify, request from flask import Flask, jsonify, request
from flask_cors import CORS from flask_cors import CORS
from flask_limiter import Limiter from flask_limiter import Limiter
import werkzeug import werkzeug
from chalicelib.util import util from webargs import fields, validate
from chalicelib.api import accounts, users, idps, root from webargs.flaskparser import use_args
from util import util
from api import accounts, users, idps, root
sentry_sdk.init(
dsn="https://e5b27a6e04405e77b14f51631b2c96f3@o4508066290532352.ingest.de.sentry.io/4508066432876624",
traces_sample_rate=1.0,
profiles_sample_rate=1.0,
)
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
limiter = Limiter(app, default_limits=['20 per minute'], key_func=util.limit_by_user) limiter = Limiter(util.limit_by_user, app=app, default_limits=["20 per minute"])
@app.errorhandler(werkzeug.exceptions.TooManyRequests) @app.errorhandler(werkzeug.exceptions.TooManyRequests)
def handle_429(e): def handle_429(e):
return jsonify({'message': 'You\'re making too many requests. Please wait for a few minutes before trying again.', 'Allowed limit': e.description}), 429 return jsonify(
{
"message": "You're making too many requests. Please wait for a few minutes before trying again.",
"Allowed limit": e.description,
}
), 429
@app.errorhandler(werkzeug.exceptions.BadRequest) @app.errorhandler(werkzeug.exceptions.BadRequest)
def handle_bad_request(e): def handle_bad_request(e):
return jsonify({'message': e.description}), 400 return jsonify({"message": e.description}), 400
@app.errorhandler(werkzeug.exceptions.Unauthorized) @app.errorhandler(werkzeug.exceptions.Unauthorized)
def handle_not_authorized(e): def handle_not_authorized(e):
return jsonify({'message': e.description}), 401 return jsonify({"message": e.description}), 401
@app.errorhandler(werkzeug.exceptions.Forbidden) @app.errorhandler(werkzeug.exceptions.Forbidden)
def handle_forbidden(e): def handle_forbidden(e):
return jsonify({'message': e.description}), 403 return jsonify({"message": e.description}), 403
@app.errorhandler(werkzeug.exceptions.NotFound) @app.errorhandler(werkzeug.exceptions.NotFound)
def handle_not_found(e): def handle_not_found(e):
return jsonify({'message': e.description}), 404 return jsonify({"message": e.description}), 404
@app.errorhandler(werkzeug.exceptions.UnprocessableEntity)
def handle_unprocessable_entity(e):
validation_errors = e.data.get("messages")
message = ""
def build_message(message, d):
if not d:
return message
for key in d:
if isinstance(d[key], dict):
message += f"""{str(key)}: """
return build_message(message, d[key])
elif isinstance(d[key], list):
message += f"""{str(key)}: {', '.join(d[key])}\n"""
return message
if validation_errors:
message = build_message("", validation_errors.get("json"))
return jsonify(
{
"message": message,
"validations": validation_errors,
}
), 422
# ACCOUNTS # ACCOUNTS
@app.route('/accounts', methods=['POST'])
@limiter.limit('5 per minute', key_func=util.limit_by_client, methods=['POST'])
def register():
return util.jsonify(accounts.create(request.json))
@app.route('/accounts/enrol', methods=['POST']) @app.route("/accounts", methods=["POST"])
def enrol(): @limiter.limit("5 per minute", key_func=util.limit_by_client, methods=["POST"])
return util.jsonify(accounts.enrol(request.json)) @use_args(
{
"email": fields.Email(required=True),
"firstName": fields.Str(required=True, validate=validate.Length(min=2)),
"lastName": fields.Str(required=True, validate=validate.Length(min=2)),
"password": fields.Str(required=True, validate=validate.Length(min=8)),
"idpsToClaim": fields.List(fields.Str(), missing=[], allow_none=True),
}
)
def register(args):
return util.jsonify(accounts.create(args))
@app.route('/accounts/sessions', methods=['POST'])
@limiter.limit('5 per minute', key_func=util.limit_by_client, methods=['POST'])
def login():
return util.jsonify(accounts.login(request.json))
@app.route('/accounts/sessions', methods=['DELETE']) @app.route("/accounts/enrol", methods=["POST"])
@limiter.limit("5 per minute", key_func=util.limit_by_client, methods=["POST"])
@use_args({"token": fields.Str(), "password": fields.Str(required=True)})
def enrol(args):
return util.jsonify(accounts.enrol(args))
@app.route("/accounts/sessions", methods=["POST"])
@limiter.limit("5 per minute", key_func=util.limit_by_client, methods=["POST"])
@use_args(
{
"email": fields.Email(required=True),
"password": fields.Str(required=True),
"idpsToClaim": fields.List(fields.Str(), missing=[], allow_none=True),
}
)
def login(args):
return util.jsonify(accounts.login(args))
@app.route("/accounts/sessions", methods=["DELETE"])
def logout(): def logout():
return util.jsonify(accounts.logout(util.get_user())) return util.jsonify(accounts.logout(util.get_user()))
@app.route('/accounts', methods=['DELETE'])
def delete_account():
body = request.json
return util.jsonify(accounts.delete(util.get_user(), body.get('password')))
@app.route('/accounts/password', methods=['PUT']) @app.route("/accounts", methods=["DELETE"])
@limiter.limit('5 per minute', key_func=util.limit_by_user, methods=['PUT']) @use_args(
def password(): {
body = request.json "password": fields.Str(required=True),
return util.jsonify(accounts.update_password(util.get_user(required=False), body)) }
)
def delete_account(args):
return util.jsonify(accounts.delete(util.get_user(), args))
@app.route("/accounts/password", methods=["PUT"])
@limiter.limit("5 per minute", key_func=util.limit_by_user, methods=["PUT"])
@use_args(
{
"newPassword": fields.Str(required=True, validate=validate.Length(min=8)),
"currentPassword": fields.Str(alow_none=True),
"token": fields.Str(allow_none=True),
}
)
def password(args):
return util.jsonify(accounts.update_password(util.get_user(required=False), args))
@app.route("/accounts/password/reset", methods=["POST"])
@limiter.limit("5 per minute", key_func=util.limit_by_client, methods=["POST"])
@use_args(
{
"email": fields.Email(required=True),
}
)
def reset_password(args):
return util.jsonify(accounts.reset_password(args))
@app.route('/accounts/password/reset', methods=['POST'])
@limiter.limit('5 per minute', key_func=util.limit_by_client, methods=['POST'])
def reset_password():
body = request.json
return util.jsonify(accounts.reset_password(body))
# Users # Users
@app.route('/users/me', methods=['GET'])
def users_me():
return util.jsonify(users.me(util.get_user()))
@app.route('/users/<id>', methods=['PUT']) @app.route("/users/me", methods=["GET"])
def user_route(id): def users_me():
return util.jsonify(users.update(util.get_user(), id, request.json)) return util.jsonify(users.me(util.get_user()))
@app.route("/users/<id>", methods=["PUT"])
@use_args(
{
"firstName": fields.Str(validate=validate.Length(min=2)),
"lastName": fields.Str(validate=validate.Length(min=2)),
"email": fields.Email(),
}
)
def user_route(args, id):
return util.jsonify(users.update(util.get_user(), id, args))
# IDPs # IDPs
@app.route('/idps', methods=['GET', 'POST'])
def idps_route():
if request.method == 'POST':
return util.jsonify(idps.create(util.get_user(required=False), request.json))
if request.method == 'GET':
params = request.args or {}
return util.jsonify(idps.get(util.get_user(required=False), params.get('include')))
@app.route('/idps/<id>', methods=['GET', 'PUT', 'DELETE']) @app.route("/idps", methods=["GET"])
@use_args({"include": fields.Str(missing="")}, location="query")
def idps_route_get(args):
return util.jsonify(idps.get(util.get_user(required=False), args.get("include")))
@app.route("/idps", methods=["POST"])
@use_args(
{
"code": fields.Str(required=True, validate=validate.Length(min=2)),
"name": fields.Str(required=True, validate=validate.Length(min=2)),
}
)
def idps_route_post(args):
return util.jsonify(idps.create(util.get_user(required=False), args))
@app.route("/idps/<id>", methods=["GET", "DELETE"])
def idp_route(id): def idp_route(id):
if request.method == 'GET': if request.method == "GET":
return util.jsonify(idps.get_one(util.get_user(required=False), id)) return util.jsonify(idps.get_one(util.get_user(required=False), id))
if request.method == 'PUT': if request.method == "DELETE":
return util.jsonify(idps.update(util.get_user(required=False), id, request.json)) return util.jsonify(idps.delete(util.get_user(required=False), id))
if request.method == 'DELETE':
return util.jsonify(idps.delete(util.get_user(required=False), id))
@app.route('/idps/<id>/sps', methods=['GET', 'POST'])
def idp_sps_route(id): @app.route("/idps/<id>", methods=["PUT"])
if request.method == 'GET': @use_args(
{
"code": fields.Str(validate=validate.Length(min=2)),
"name": fields.Str(validate=validate.Length(min=2)),
}
)
def idp_route_put(args, id):
return util.jsonify(idps.update(util.get_user(required=False), id, args))
@app.route("/idps/<id>/sps", methods=["GET"])
def idp_sps_route_get(id):
return util.jsonify(idps.get_sps(util.get_user(required=False), id)) return util.jsonify(idps.get_sps(util.get_user(required=False), id))
if request.method == 'POST':
return util.jsonify(idps.create_sp(util.get_user(required=False), id, request.json))
@app.route('/idps/<id>/sps/<sp_id>', methods=['PUT', 'DELETE'])
@app.route("/idps/<id>/sps", methods=["POST"])
@use_args(
{
"name": fields.Str(required=True, validate=validate.Length(min=2)),
"entityId": fields.Str(),
"serviceUrl": fields.Str(),
"callbackUrl": fields.Str(),
"logoutUrl": fields.Str(),
"logoutCallbackUrl": fields.Str(),
"oauth2RedirectUri": fields.Str(),
}
)
def idp_sps_route_post(args, id):
return util.jsonify(idps.create_sp(util.get_user(required=False), id, args))
@app.route("/idps/<id>/sps/<sp_id>", methods=["PUT"])
@use_args(
{
"name": fields.Str(required=True, validate=validate.Length(min=2)),
"entityId": fields.Str(),
"serviceUrl": fields.Str(),
"callbackUrl": fields.Str(),
"logoutUrl": fields.Str(),
"logoutCallbackUrl": fields.Str(),
"oauth2RedirectUri": fields.Str(),
}
)
def idp_sp_route_put(args, id, sp_id):
return util.jsonify(idps.update_sp(util.get_user(required=False), id, sp_id, args))
@app.route("/idps/<id>/sps/<sp_id>", methods=["DELETE"])
def idp_sp_route(id, sp_id): def idp_sp_route(id, sp_id):
if request.method == 'DELETE':
return util.jsonify(idps.delete_sp(util.get_user(required=False), id, sp_id)) return util.jsonify(idps.delete_sp(util.get_user(required=False), id, sp_id))
if request.method == 'PUT':
return util.jsonify(idps.update_sp(util.get_user(required=False), id, sp_id, request.json))
@app.route('/idps/<id>/users', methods=['GET', 'POST'])
def idp_users_route(id): @app.route("/idps/<id>/users", methods=["GET"])
if request.method == 'GET': def idp_users_route_get(id):
return util.jsonify(idps.get_users(util.get_user(required=False), id)) return util.jsonify(idps.get_users(util.get_user(required=False), id))
if request.method == 'POST':
return util.jsonify(idps.create_user(util.get_user(required=False), id, request.json))
@app.route('/idps/<id>/users/<user_id>', methods=['PUT', 'DELETE'])
def idp_user_route(id, user_id): @app.route("/idps/<id>/users", methods=["POST"])
if request.method == 'DELETE': @use_args(
{
"email": fields.Email(required=True),
"firstName": fields.Str(),
"lastName": fields.Str(),
"password": fields.Str(required=True, validate=validate.Length(min=3)),
"attributes": fields.Dict(),
}
)
def idp_users_route_post(args, id):
return util.jsonify(idps.create_user(util.get_user(required=False), id, args))
@app.route("/idps/<id>/users/<user_id>", methods=["PUT"])
@use_args(
{
"email": fields.Email(required=True),
"firstName": fields.Str(),
"lastName": fields.Str(),
"password": fields.Str(),
"attributes": fields.Dict(),
}
)
def idp_user_route_put(args, id, user_id):
return util.jsonify(
idps.update_user(util.get_user(required=False), id, user_id, args)
)
@app.route("/idps/<id>/users/<user_id>", methods=["DELETE"])
def idp_user_route_delete(id, user_id):
return util.jsonify(idps.delete_user(util.get_user(required=False), id, user_id)) return util.jsonify(idps.delete_user(util.get_user(required=False), id, user_id))
if request.method == 'PUT':
return util.jsonify(idps.update_user(util.get_user(required=False), id, user_id, request.json))
@app.route('/idps/<id>/attributes', methods=['GET', 'POST'])
def idp_attributes_route(id): @app.route("/idps/<id>/attributes", methods=["GET"])
if request.method == 'GET': def idp_attributes_route_get(id):
return util.jsonify(idps.get_attributes(util.get_user(required=False), id)) return util.jsonify(idps.get_attributes(util.get_user(required=False), id))
if request.method == 'POST':
return util.jsonify(idps.create_attribute(util.get_user(required=False), id, request.json))
@app.route('/idps/<id>/attributes/<attr_id>', methods=['PUT', 'DELETE'])
def idp_attribute_route(id, attr_id):
if request.method == 'DELETE':
return util.jsonify(idps.delete_attribute(util.get_user(required=False), id, attr_id))
if request.method == 'PUT':
return util.jsonify(idps.update_attribute(util.get_user(required=False), id, attr_id, request.json))
@app.route('/idps/<id>/saml2/logs', methods=['GET']) @app.route("/idps/<id>/attributes", methods=["POST"])
@use_args(
{
"name": fields.Str(required=True, validate=validate.Length(min=2)),
"defaultValue": fields.Str(),
"samlMapping": fields.Str(),
}
)
def idp_attributes_route_post(args, id):
return util.jsonify(idps.create_attribute(util.get_user(required=False), id, args))
@app.route("/idps/<id>/attributes/<attr_id>", methods=["PUT"])
@use_args(
{
"name": fields.Str(required=True, validate=validate.Length(min=2)),
"defaultValue": fields.Str(),
"samlMapping": fields.Str(),
}
)
def idp_attribute_route_put(args, id, attr_id):
return util.jsonify(
idps.update_attribute(util.get_user(required=False), id, attr_id, args)
)
@app.route("/idps/<id>/attributes/<attr_id>", methods=["DELETE"])
def idp_attribute_route_delete(id, attr_id):
return util.jsonify(
idps.delete_attribute(util.get_user(required=False), id, attr_id)
)
@app.route("/idps/<id>/saml2/logs", methods=["GET"])
def idp_saml_logs_route(id): def idp_saml_logs_route(id):
return util.jsonify(idps.get_logs(util.get_user(required=False), id)) return util.jsonify(idps.get_logs(util.get_user(required=False), id))
@app.route('/idps/<id>/oauth2/logs', methods=['GET'])
@app.route("/idps/<id>/oauth2/logs", methods=["GET"])
def idp_oauth_logs_route(id): def idp_oauth_logs_route(id):
return util.jsonify(idps.get_oauth_logs(util.get_user(required=False), id)) return util.jsonify(idps.get_oauth_logs(util.get_user(required=False), id))
# Root # Root
@app.route('/root/users', methods=['GET'])
@app.route("/root/users", methods=["GET"])
def root_users(): def root_users():
return util.jsonify(root.get_users(util.get_user())) return util.jsonify(root.get_users(util.get_user()))

View File

@ -1,162 +0,0 @@
import datetime, jwt, bcrypt, os
from bson.objectid import ObjectId
from chalicelib.util import database, mail, errors
jwt_secret = os.environ.get('JWT_SECRET')
def create(data):
email = data.get('email')
first_name = data.get('firstName')
last_name = data.get('lastName')
password = data.get('password')
if not email or len(email) < 6: raise errors.BadRequest('Your name or email is too short or invalid.')
if not password or len(password) < 8: raise errors.BadRequest('Your password should be at least 8 characters.')
email = email.lower()
idps_to_claim = data.get('idpsToClaim', []) or []
idps_to_claim = list(map(lambda i: ObjectId(i), idps_to_claim))
db = database.get_db()
existingUser = db.users.find_one({'email': email})
if existingUser: raise errors.BadRequest('An account with this email already exists.')
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
new_user = {
'firstName': first_name,
'lastName': last_name,
'email': email,
'password': hashed_password,
'createdAt': datetime.datetime.utcnow()
}
result = db.users.insert_one(new_user)
new_user['_id'] = result.inserted_id
if len(idps_to_claim):
db.idps.update({'_id': {'$in': idps_to_claim}, 'user': {'$exists': False}}, {'$set': {'user': new_user['_id']}}, multi=True)
mail.send({
'to': os.environ.get('ADMIN_EMAIL'),
'subject': '{} signup'.format(os.environ.get('APP_NAME')),
'text': 'A new user signed up with email {0}'.format(email)
})
return {'token': generate_access_token(new_user['_id'])}
def enrol(data):
if not data or 'token' not in data or 'password' not in data: raise errors.BadRequest('Invalid request')
token = data.get('token')
password = data.get('password')
if not token: raise errors.BadRequest('Invalid token')
if not password or len(password) < 8: raise errors.BadRequest('Your password should be at least 8 characters.')
try:
db = database.get_db()
id = jwt.decode(data['token'], jwt_secret)['sub']
user = db.users.find_one({'_id': ObjectId(id), 'tokens.enrolment': token})
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
db.users.update({'_id': user['_id']}, {'$set': {'password': hashed_password}, '$unset': {'tokens.enrolment': ''}})
return {'token': generate_access_token(user['_id'])}
except Exception as e:
print(e)
raise errors.BadRequest('Unable to enrol your account. Your token may be invalid or expired.')
def login(data):
email = data.get('email')
password = data.get('password')
idps_to_claim = data.get('idpsToClaim', []) or []
idps_to_claim = list(map(lambda i: ObjectId(i), idps_to_claim))
db = database.get_db()
user = db.users.find_one({'email': email.lower()})
try:
if user and bcrypt.checkpw(password.encode("utf-8"), user['password']):
if len(idps_to_claim):
db.idps.update({'_id': {'$in': idps_to_claim}, 'user': {'$exists': False}}, {'$set': {'user': user['_id']}}, multi=True)
return {'token': generate_access_token(user['_id'])}
else:
raise errors.BadRequest('Your email or password is incorrect.')
except Exception as e:
print(e)
raise errors.BadRequest('Your email or password is incorrect.')
def logout(user):
db = database.get_db()
db.users.update({'_id': user['_id']}, {'$pull': {'tokens.login': user['currentToken']}})
return {'loggedOut': True}
def update_password(user, data):
if not data: raise errors.BadRequest('Invalid request')
if 'newPassword' not in data: raise errors.BadRequest('Invalid request')
if len(data['newPassword']) < 8: raise errors.BadRequest('New password is too short')
db = database.get_db()
if 'currentPassword' in data:
if not bcrypt.checkpw(data['currentPassword'].encode('utf-8'), user['password']):
raise errors.BadRequest('Incorrect password')
elif 'token' in data:
try:
id = jwt.decode(data['token'], jwt_secret, algorithms=['HS256'])['sub']
user = db.users.find_one({'_id': ObjectId(id), 'tokens.passwordReset': data['token']})
if not user: raise Exception
except Exception as e:
print(e)
raise errors.BadRequest('There was a problem updating your password. Your token may be invalid or out of date')
else:
raise errors.BadRequest('Current password or reset token is required')
if not user: raise errors.BadRequest('Unable to change your password')
hashed_password = bcrypt.hashpw(data['newPassword'].encode("utf-8"), bcrypt.gensalt())
db.users.update({'_id': user['_id']}, {'$set': {'password': hashed_password}, '$unset': {'tokens.passwordReset': ''}})
return {'passwordUpdated': True}
def delete(user, password):
if not password or not bcrypt.checkpw(password.encode('utf-8'), user['password']):
raise errors.BadRequest('Incorrect password')
db = database.get_db()
for idp in db.idps.find({'user': user['_id']}):
db.idpSps.remove({'idp': idp['_id']})
db.idpUsers.remove({'idp': idp['_id']})
db.idpAttributes.remove({'idp': idp['_id']})
db.idps.remove({'user': user['_id']})
db.users.remove({'_id': user['_id']})
return {'deletedUser': user['_id']}
def generate_access_token(user_id):
payload = {
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=30),
'iat': datetime.datetime.utcnow(),
'sub': str(user_id)
}
token = jwt.encode(payload, jwt_secret, algorithm='HS256')
db = database.get_db()
db.users.update({'_id': user_id}, {'$addToSet': {'tokens.login': token}})
return token
def get_user_context(token):
if not token: return None
try:
payload = jwt.decode(token, jwt_secret, algorithms=['HS256'])
id = payload['sub']
if id:
db = database.get_db()
user = db.users.find_one({'_id': ObjectId(id), 'tokens.login': token})
db.users.update({'_id': user['_id']}, {'$set': {'lastSeenAt': datetime.datetime.now()}})
user['currentToken'] = token
return user
except Exception as e:
print(e)
return None
def reset_password(data):
if not data or not 'email' in data: raise errors.BadRequest('Invalid request')
if len(data['email']) < 5: raise errors.BadRequest('Your email is too short')
db = database.get_db()
user = db.users.find_one({'email': data['email'].lower()})
if user:
payload = {
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=1),
'iat': datetime.datetime.utcnow(),
'sub': str(user['_id'])
}
token = jwt.encode(payload, jwt_secret, algorithm='HS256')
mail.send({
'to_user': user,
'subject': 'Reset your password',
'text': 'Dear {0},\n\nA password reset email was recently requested for your SSO Tools account. If this was you and you want to continue, please follow the link below:\n\n{1}\n\nThis link will expire after 24 hours.\n\nIf this was not you, then someone may be trying to gain access to your account. We recommend using a strong and unique password for your account.'.format(user['firstName'], 'https://sso.tools/password/reset?token=' + token)
})
db.users.update({'_id': user['_id']}, {'$set': {'tokens.passwordReset': token}})
return {'passwordResetEmailSent': True}

View File

@ -1,348 +0,0 @@
from uuid import uuid4
import bcrypt, re, random, string
from OpenSSL import crypto
import pymongo
from bson.objectid import ObjectId
from chalicelib.util import database, errors, util
forbidden_codes = ['', 'app', 'my', 'www', 'support', 'mail', 'email', 'dashboard', 'ssotools', 'myidp']
def create_self_signed_cert(name):
# create a key pair
k = crypto.PKey()
k.generate_key(crypto.TYPE_RSA, 1024)
# create a self-signed cert
cert = crypto.X509()
cert.get_subject().C = "GB"
cert.get_subject().O = "SSO Tools"
cert.get_subject().OU = name
cert.gmtime_adj_notBefore(0)
cert.gmtime_adj_notAfter(20*365*24*60*60)
cert.set_issuer(cert.get_subject())
cert.set_pubkey(k)
cert.sign(k, 'sha256')
dumped = crypto.dump_certificate(crypto.FILETYPE_PEM, cert)
dumped_key = crypto.dump_privatekey(crypto.FILETYPE_PEM, k)
return {'cert': dumped, 'key': dumped_key}
def can_manage_idp(user, idp):
if not idp: return False
if not user: return not idp.get('user')
if user: return util.has_permission(user, 'root') or idp.get('user') == user['_id'] or not idp.get('user')
def create(user, data):
db = database.get_db()
if not data or not data.get('code') or not data.get('name'): return errors.BadRequest('Name and issuer are required')
code = data.get('code').lower().strip()
if not len(code) or code in forbidden_codes or db.idps.find_one({'code': code}):
raise errors.BadRequest('The issuer is invalid or is already in use')
if not re.match(r'^([a-zA-Z0-9\-_]+)$', code): raise errors.BadRequest('The IdP issuer is invalid. Please just use letters, numbers, hyphens, and underscores.')
x509 = create_self_signed_cert(data['name'])
idp = {
'name': data.get('name'),
'code': code,
'saml': {
'certificate': x509['cert'].decode('utf-8'),
'privateKey': x509['key'].decode('utf-8')
}
}
if user: idp['user'] = user['_id']
result = db.idps.insert_one(idp)
idp['_id'] = result.inserted_id
create_user(user, idp['_id'], {'email': 'joe@example.com', 'firstName': 'Joe', 'lastName': 'Bloggs', 'password': 'password'})
create_user(user, idp['_id'], {'email': 'jane@example.com', 'firstName': 'Jane', 'lastName': 'Doe', 'password': 'password'})
create_attribute(user, idp['_id'], {'name': 'Group', 'defaultValue': 'staff', 'samlMapping': 'group'})
return idp
def get(user, include):
db = database.get_db()
if include: include = list(map(lambda i: ObjectId(i), include.split(',')))
else: include = []
if user: query = {'$or': [{'user': user['_id']}, {'_id': {'$in': include}, 'user': {'$exists': False}}]}
else: query = {'_id': {'$in': include}, 'user': {'$exists': False}}
idps = list(db.idps.find(query, {'name': 1, 'code': 1}))
return {'idps': idps}
def get_one(user, id):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp: raise errors.NotFound('The IdP could not be found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t view this IdP')
idp['users'] = list(db.idpUsers.find({'idp': idp['_id']}, {'firstName': 1, 'lastName': 1, 'email': 1}))
return idp
def update(user, id, data):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t edit this IdP')
if 'code' in data:
code = data.get('code').lower().strip()
if not len(code) or code in forbidden_codes or db.idps.find_one({'code': code, '_id': {'$ne': id}}):
raise errors.BadRequest('This code is invalid or is already in use')
if not re.match(r'^([a-zA-Z0-9\-_]+)$', code): raise errors.BadRequest('The IdP issuer is invalid. Please just use letters, numbers, hyphens, and underscores.')
else: code = idp['code']
update_data = {
'name': data.get('name', idp['name']),
'code': code
}
db.idps.update_one({'_id': id}, {'$set': update_data})
return get_one(user, id)
def delete(user, id):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t delete this IdP')
db.idps.remove({'_id': id })
return {'deletedIDP': id}
#### SPs
def create_sp(user, id, data):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t manage this IdP')
sp = {
'name': data.get('name'),
'type': 'saml',
'entityId': data.get('entityId'),
'serviceUrl': data.get('serviceUrl'),
'callbackUrl': data.get('callbackUrl'),
'logoutUrl': data.get('logoutUrl'),
'logoutCallbackUrl': data.get('logoutCallbackUrl'),
'oauth2ClientId': str(uuid4()),
'oauth2ClientSecret': str(''.join(random.choices(string.ascii_uppercase + string.digits, k=32))),
'oauth2RedirectUri': data.get('oauth2RedirectUri'),
'idp': id
}
result = db.idpSps.insert_one(sp)
sp['_id'] = result.inserted_id
return sp
def update_sp(user, id, sp_id, data):
id = ObjectId(id)
sp_id = ObjectId(sp_id)
db = database.get_db()
existing = db.idpSps.find_one(sp_id)
if not existing: return errors.NotFound('SP not found')
idp = db.idps.find_one(existing['idp'])
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t update this IdP')
update_data = {
'name': data.get('name'),
'entityId': data.get('entityId'),
'serviceUrl': data.get('serviceUrl'),
'callbackUrl': data.get('callbackUrl'),
'logoutUrl': data.get('logoutUrl'),
'logoutCallbackUrl': data.get('logoutCallbackUrl'),
'oauth2RedirectUri': data.get('oauth2RedirectUri'),
}
db.idpSps.update({'_id': sp_id}, {'$set': update_data})
return db.idpSps.find_one({'_id': sp_id}, {'name': 1, 'entityId': 1, 'serviceUrl': 1, 'callbackUrl': 1, 'logoutUrl': 1, 'logoutCallbackUrl': 1})
def get_sps(user, id):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t update this IdP')
sps = list(db.idpSps.find({'idp': id}))
return {'sps': sps}
def delete_sp(user, id, sp_id):
id = ObjectId(id)
sp_id = ObjectId(sp_id)
db = database.get_db()
existing = db.idpSps.find_one(sp_id)
if not existing: return errors.NotFound('SP not found')
idp = db.idps.find_one(existing['idp'])
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t update this IdP')
db.idpSps.remove({'_id': sp_id})
return {'deletedIDPSP': sp_id}
#### Users
def create_user(user, id, data):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t update this IdP')
password = data['password']
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
new_user = {
'firstName': data.get('firstName'),
'lastName': data.get('lastName'),
'email': data['email'],
'password': hashed_password,
'attributes': data.get('attributes', {}),
'idp': id
}
result = db.idpUsers.insert_one(new_user)
new_user['_id'] = result.inserted_id
del new_user['password']
return new_user
def update_user(user, id, user_id, data):
id = ObjectId(id)
user_id = ObjectId(user_id)
db = database.get_db()
existing = db.idpUsers.find_one(user_id)
if not existing: return errors.NotFound('User not found')
idp = db.idps.find_one(existing['idp'])
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t update this IdP')
update_data = {
'firstName': data.get('firstName', existing.get('firstName')),
'lastName': data.get('lastName', existing.get('lastName')),
'email': data.get('email', existing.get('email')),
'attributes': data.get('attributes', existing.get('attributes', {}))
}
if data.get('password'):
hashed_password = bcrypt.hashpw(data['password'].encode("utf-8"), bcrypt.gensalt())
update_data['password'] = hashed_password
db.idpUsers.update({'_id': user_id}, {'$set': update_data})
return db.idpUsers.find_one({'_id': user_id}, {'firstName': 1, 'lastName': 1, 'email': 1, 'attributes': 1})
def get_users(user, id):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t manage this IdP')
users = list(db.idpUsers.find({'idp': id}, {'firstName': 1, 'lastName': 1, 'email': 1, 'attributes': 1}))
return {'users': users}
def delete_user(user, id, user_id):
id = ObjectId(id)
user_id = ObjectId(user_id)
db = database.get_db()
existing = db.idpUsers.find_one(user_id)
if not existing: return errors.NotFound('User not found')
idp = db.idps.find_one(existing['idp'])
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t update this IdP')
db.idpUsers.remove({'_id': user_id})
return {'deletedUser': user_id}
#### Attributes
def create_attribute(user, id, data):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t update this IdP')
if not data or 'name' not in data: return errors.BadRequest('Attribute name is required')
new_attr = {
'name': data.get('name'),
'defaultValue': data.get('defaultValue'),
'samlMapping': data.get('samlMapping'),
'idp': id
}
result = db.idpAttributes.insert_one(new_attr)
new_attr['_id'] = result.inserted_id
return new_attr
def update_attribute(user, id, attr_id, data):
id = ObjectId(id)
attr_id = ObjectId(attr_id)
db = database.get_db()
existing = db.idpAttributes.find_one(attr_id)
if not existing: return errors.NotFound('Attribute not found')
idp = db.idps.find_one(existing['idp'])
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t update this IdP')
update_data = {
'name': data.get('name', existing.get('name')),
'defaultValue': data.get('defaultValue', existing.get('defaultValue')),
'samlMapping': data.get('samlMapping', existing.get('samlMapping')),
}
db.idpAttributes.update({'_id': attr_id}, {'$set': update_data})
return db.idpAttributes.find_one({'_id': attr_id})
def get_attributes(user, id):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t manage this IdP')
attributes = list(db.idpAttributes.find({'idp': id}))
return {'attributes': attributes}
def delete_attribute(user, id, attr_id):
id = ObjectId(id)
attr_id = ObjectId(attr_id)
db = database.get_db()
existing = db.idpAttributes.find_one(attr_id)
if not existing: return errors.NotFound('Attribute not found')
idp = db.idps.find_one(existing['idp'])
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t update this IdP')
db.idpAttributes.remove({'_id': attr_id})
return {'deletedAttribute': attr_id}
def get_logs(user, id):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t access this IdP')
logs = list(db.requests.find({'idp': id}).sort('createdAt', pymongo.DESCENDING).limit(30))
sps = list(db.idpSps.find({'idp': id}, {'name': 1}))
for log in logs:
if log.get('data', {}).get('assertion', {}).get('key'):
log['data']['assertion']['key'] = 'REDACTED'
for sp in sps:
if log['sp'] == sp['_id']:
log['spName'] = sp['name']
break
return {'logs': logs}
def get_oauth_logs(user, id):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t access this IdP')
logs = list(db.oauthRequests.find({'idp': id}).sort('createdAt', pymongo.DESCENDING).limit(30))
sps = list(db.idpSps.find({'idp': id}, {'name': 1}))
for log in logs:
for sp in sps:
if log['sp'] == sp['_id']:
log['spName'] = sp['name']
break
return {'logs': logs}

View File

@ -1,13 +0,0 @@
import pymongo
from chalicelib.util import database, errors, util
def get_users(user):
if not util.has_permission(user, 'root'): raise errors.Forbidden('Not allowed')
db = database.get_db()
users = list(db.users.find({}, {'firstName': 1, 'lastName': 1, 'email': 1, 'createdAt': 1, 'lastSeenAt': 1}).sort('lastSeenAt', -1).limit(50))
idps = list(db.idps.find({'user': {'$in': list(map(lambda u: u['_id'], users))}}, {'user': 1, 'code': 1, 'name': 1, 'createdAt': 1}))
for u in users:
u['idps'] = []
for i in idps:
if i['user'] == u['_id']: u['idps'].append(i)
return {'users': users}

View File

@ -1,46 +0,0 @@
from bson.objectid import ObjectId
from chalicelib.util import database, util, errors, mail
def me(user):
db = database.get_db()
return {
'_id': user['_id'],
'firstName': user.get('firstName'),
'lastName': user.get('lastName'),
'email': user.get('email'),
'subscriptions': user.get('subscriptions', []),
'permissions': user.get('permissions', {}),
}
def get(user, id):
db = database.get_db()
if str(user['_id']) != id: raise errors.Forbidden('Not allowed')
return db.users.find_one({'_id': ObjectId(id)}, {'firstName': 1, 'lastName': 1})
def update(user, id, data):
if not data: raise errors.BadRequest('Invalid request')
db = database.get_db()
if str(user['_id']) != id: raise errors.Forbidden('Not allowed')
update = {}
if data.get('email') and data['email'] != user.get('email'):
email = data['email'].lower()
existing_user = db.users.find_one({'_id': {'$ne': user['_id']}, 'email': email})
if existing_user: raise errors.BadRequest('This new email address is already in use')
mail_content = 'Dear {0},\n\nThis email is to let you know that the email address for your SSO Tools account has been changed to: {1}.\n\nIf this was not you, and/or you believe your account has been compromised, please login as soon as possible and change your account password.'.format(user['firstName'], email)
mail.send({
'to_user': user,
'subject': 'SSOTools Email Address Changed',
'text': mail_content
})
mail.send({
'to': email,
'subject': 'SSOTools Email Address Changed',
'text': mail_content
})
update['email'] = email
if 'firstName' in data: update['firstName'] = data['firstName']
if 'lastName' in data: update['lastName'] = data['lastName']
if update:
db.users.update({'_id': ObjectId(id)}, {'$set': update})
return get(user, id)

View File

@ -1,3 +0,0 @@
import werkzeug
errors = werkzeug.exceptions

View File

@ -1,10 +0,0 @@
import os
from pymongo import MongoClient
db = None
def get_db():
global db
if not db:
db = MongoClient(os.environ.get('MONGO_URL'))[os.environ.get('MONGO_DATABASE')]
return db

View File

@ -1,27 +0,0 @@
import os
import requests
def send(data):
if 'from' not in data:
data['from'] = 'SSO Tools <no_reply@mail.sso.tools>'
if 'to_user' in data:
user = data['to_user']
data['to'] = user['firstName'] + ' <' + user['email'] + '>'
del data['to_user']
data['text'] += '\n\nFrom the team at SSO Tools\n\n\n\n--\n\nReceived this email in error? Please let us know by contacting hello@sso.tools'
base_url = os.environ.get('MAILGUN_URL')
api_key = os.environ.get('MAILGUN_KEY')
if base_url and api_key:
auth = ('api', api_key)
try:
response = requests.post(base_url, auth=auth, data=data)
response.raise_for_status()
except Exception as e:
print(e)
print('Unable to send email')
else:
print('Not sending email. Message pasted below.')
print(data)

View File

@ -1,62 +0,0 @@
import json, datetime
from flask import request
from flask_limiter.util import get_remote_address
from bson.objectid import ObjectId
from chalicelib.api import accounts
def get_user(required = True):
headers = request.headers
if not headers.get('Authorization') and required:
raise errors.Unauthorized('This resource requires authentication')
if headers.get('Authorization'):
user = accounts.get_user_context(headers.get('Authorization').replace('Bearer ', ''))
if user is None and required:
raise errors.Unauthorized('Invalid token')
return user
return None
def limit_by_client():
data = request.get_json()
if data:
if data.get('email'): return data.get('email')
if data.get('token'): return data.get('token')
return get_remote_address()
def limit_by_user():
user = get_user(required = False)
return user['_id'] if user else get_remote_address()
def has_permission(user, permission, scope='global'):
if not user or not permission: return False
return permission in user.get('permissions', {}).get(scope, [])
def filter_keys(obj, allowed_keys):
filtered = {}
for key in allowed_keys:
if key in obj:
filtered[key] = obj[key]
return filtered
def build_updater(obj, allowed_keys):
if not obj: return {}
allowed = filter_keys(obj, allowed_keys)
updater = {}
for key in allowed:
if not allowed[key]:
if '$unset' not in updater: updater['$unset'] = {}
updater['$unset'][key] = ''
else:
if '$set' not in updater: updater['$set'] = {}
updater['$set'][key] = allowed[key]
return updater
class MongoJsonEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, (datetime.datetime, datetime.date)):
return obj.isoformat()
elif isinstance(obj, ObjectId):
return str(obj)
return json.JSONEncoder.default(self, obj)
def jsonify(*args, **kwargs):
return json.dumps(dict(*args, **kwargs), cls=MongoJsonEncoder)

4
api/lint.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash
ruff format .
ruff check --fix .

1477
api/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,23 +3,29 @@ name = "ssotools-api"
version = "0.1.0" version = "0.1.0"
description = "" description = ""
authors = ["Will Webberley <will@sso.tools>"] authors = ["Will Webberley <will@sso.tools>"]
package-mode = false
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.9" python = "^3.12"
Flask = "^2.1.1" Flask = "^3.0.3"
gunicorn = "^20.1.0" gunicorn = "^23.0.0"
bcrypt = "^3.2.0" bcrypt = "^4.2.0"
dnspython = "^2.1.0" dnspython = "^2.6.1"
PyJWT = "^2.0.1" PyJWT = "^2.9.0"
pymongo = "^3.11.3" pymongo = "^4.10.1"
pyOpenSSL = "^20.0.1" pyOpenSSL = "^24.2.1"
requests = "^2.27.1" requests = "^2.32.3"
Flask-Cors = "^3.0.10" Flask-Cors = "^5.0.0"
Werkzeug = "^2.1.1" Werkzeug = "^3.0.4"
Flask-Limiter = "^2.4.5" Flask-Limiter = "^3.8.0"
sentry-sdk = {extras = ["flask"], version = "^2.15.0"}
webargs = "^8.6.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies]
ruff = "^0.6.9"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"

3
api/util/__init__.py Normal file
View File

@ -0,0 +1,3 @@
import werkzeug
errors = werkzeug.exceptions

11
api/util/database.py Normal file
View File

@ -0,0 +1,11 @@
import os
from pymongo import MongoClient
db = None
def get_db():
global db
if db is None:
db = MongoClient(os.environ.get("MONGO_URL"))[os.environ.get("MONGO_DATABASE")]
return db

28
api/util/mail.py Normal file
View File

@ -0,0 +1,28 @@
import os
import requests
def send(data):
if "from" not in data:
data["from"] = "SSO Tools <no_reply@mail.sso.tools>"
if "to_user" in data:
user = data["to_user"]
data["to"] = user["firstName"] + " <" + user["email"] + ">"
del data["to_user"]
data["text"] += (
"\n\nFrom the team at SSO Tools\n\n\n\n--\n\nReceived this email in error? Please let us know by contacting hello@sso.tools"
)
base_url = os.environ.get("MAILGUN_URL")
api_key = os.environ.get("MAILGUN_KEY")
if base_url and api_key:
auth = ("api", api_key)
try:
response = requests.post(base_url, auth=auth, data=data)
response.raise_for_status()
except Exception as e:
print(e)
print("Unable to send email")
else:
print("Not sending email. Message pasted below.")
print(data)

80
api/util/util.py Normal file
View File

@ -0,0 +1,80 @@
import json
import datetime
from flask import request
from flask_limiter.util import get_remote_address
from bson.objectid import ObjectId
from api import accounts
from util import errors
def get_user(required=True):
headers = request.headers
if not headers.get("Authorization") and required:
raise errors.Unauthorized("This resource requires authentication")
if headers.get("Authorization"):
user = accounts.get_user_context(
headers.get("Authorization").replace("Bearer ", "")
)
if user is None and required:
raise errors.Unauthorized("Invalid token")
return user
return None
def limit_by_client():
data = request.get_json()
if data:
if data.get("email"):
return data.get("email")
if data.get("token"):
return data.get("token")
return get_remote_address()
def limit_by_user():
user = get_user(required=False)
return user["_id"] if user else get_remote_address()
def has_permission(user, permission, scope="global"):
if not user or not permission:
return False
return permission in user.get("permissions", {}).get(scope, [])
def filter_keys(obj, allowed_keys):
filtered = {}
for key in allowed_keys:
if key in obj:
filtered[key] = obj[key]
return filtered
def build_updater(obj, allowed_keys):
if not obj:
return {}
allowed = filter_keys(obj, allowed_keys)
updater = {}
for key in allowed:
if not allowed[key]:
if "$unset" not in updater:
updater["$unset"] = {}
updater["$unset"][key] = ""
else:
if "$set" not in updater:
updater["$set"] = {}
updater["$set"][key] = allowed[key]
return updater
class MongoJsonEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, (datetime.datetime, datetime.date)):
return obj.isoformat()
elif isinstance(obj, ObjectId):
return str(obj)
return json.JSONEncoder.default(self, obj)
def jsonify(*args, **kwargs):
return json.dumps(dict(*args, **kwargs), cls=MongoJsonEncoder)

View File

@ -1,4 +1,4 @@
FROM node:19 FROM node:20
# Create app directory # Create app directory
WORKDIR /usr/src/app WORKDIR /usr/src/app

View File

@ -21,7 +21,7 @@ source envfile
Then you can run the service: Then you can run the service:
```shell ```shell
node index yarn start
``` ```
**Note:** The service expects to be able to connect to a local MongoDB running on port 27017. **Note:** The service expects to be able to connect to a local MongoDB running on port 27017.

View File

@ -1,19 +1,19 @@
const { MongoClient } = require('mongodb'); import { MongoClient } from 'mongodb'
const database = { const database = {
db: null, db: null,
connect: async () => { connect: async () => {
const url = process.env.MONGO_URL; const url = process.env.MONGO_URL
const dbName = process.env.MONGO_DATABASE; const dbName = process.env.MONGO_DATABASE
const client = new MongoClient(url); const client = new MongoClient(url)
await client.connect(); await client.connect()
database.db = client.db(dbName); database.db = client.db(dbName)
return database.db; return database.db
}, },
collection: async (name) => { collection: async (name) => {
const db = database.db || await database.connect(); const db = database.db || await database.connect()
return db && db.collection(name); return db && db.collection(name)
} }
}; }
module.exports = database; export default database

View File

@ -1,9 +1,10 @@
var crypto = require('crypto'); import crypto from 'crypto'
var zlib = require('zlib'); import zlib from 'zlib'
var Buffer = require('buffer').Buffer; import { Buffer } from 'buffer'
var Parser = require('xmldom').DOMParser; import { DOMParser } from 'xmldom'
var SignedXml = require('xml-crypto').SignedXml; import { SignedXml } from 'xml-crypto'
var samlp = `<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0" ID="" IssueInstant="">
const SAMLP = `<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0" ID="" IssueInstant="">
<saml:Issuer></saml:Issuer> <saml:Issuer></saml:Issuer>
<saml:Subject> <saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" /> <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" />
@ -18,224 +19,231 @@ var samlp = `<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml:AuthnContextClassRef> <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml:AuthnContextClassRef>
</saml:AuthnContext> </saml:AuthnContext>
</saml:AuthnStatement> </saml:AuthnStatement>
</saml:Assertion>`; </saml:Assertion>`
const ASSERTION_NS = 'urn:oasis:names:tc:SAML:2.0:assertion'
function pemToCert(pem) { const SAMLP_NS = 'urn:oasis:names:tc:SAML:2.0:protocol'
var cert = /-----BEGIN CERTIFICATE-----([^-]*)-----END CERTIFICATE-----/g.exec(pem.toString()); const ALGORITHMS = {
if (cert.length > 0) {
return cert[1].replace(/[\n|\r\n]/g, '');
}
return null;
}
function removeWhitespace(xml) {
return xml.replace(/\r\n/g, '').replace(/\n/g,'').replace(/>(\s*)</g, '><').trim();
}
var ASSERTION_NS = 'urn:oasis:names:tc:SAML:2.0:assertion';
var SAMLP_NS = 'urn:oasis:names:tc:SAML:2.0:protocol';
var algorithms = {
signature: { signature: {
'rsa-sha256': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', 'rsa-sha256': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
'rsa-sha1': 'http://www.w3.org/2000/09/xmldsig#rsa-sha1' 'rsa-sha1': 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'
}, },
digest: { digest: {
'sha256': 'http://www.w3.org/2001/04/xmlenc#sha256', sha256: 'http://www.w3.org/2001/04/xmlenc#sha256',
'sha1': 'http://www.w3.org/2000/09/xmldsig#sha1' sha1: 'http://www.w3.org/2000/09/xmldsig#sha1'
} }
};
exports.parseRequest = function(options, request, callback) {
options.issuer = options.issuer || 'https://idp.sso.tools';
request = decodeURIComponent(request);
var buffer = Buffer.from(request, 'base64');
const result = zlib.inflateRawSync(buffer)
var info = {};
try {
buffer = result;
var doc = new Parser().parseFromString(buffer.toString());
var rootElement = doc.documentElement;
if (rootElement.localName == 'AuthnRequest') {
info.login = {};
info.login.callbackUrl = rootElement.getAttribute('AssertionConsumerServiceURL');
info.login.destination = rootElement.getAttribute('Destination');
info.login.id = rootElement.getAttribute('ID');
info.login.forceAuthn = rootElement.getAttribute('ForceAuthn') === "true";
var issuer = rootElement.getElementsByTagNameNS(ASSERTION_NS, 'Issuer')[0];
if (issuer) {
info.login.issuer = issuer.textContent;
}
var nameIDPolicy = rootElement.getElementsByTagNameNS(SAMLP_NS, 'NameIDPolicy')[0];
if (nameIDPolicy) {
info.login.nameIdentifierFormat = nameIDPolicy.getAttribute('Format');
}
var requestedAuthnContext = rootElement.getElementsByTagNameNS(SAMLP_NS, 'RequestedAuthnContext')[0];
if (requestedAuthnContext) {
var authnContextClassRef = requestedAuthnContext.getElementsByTagNameNS(ASSERTION_NS, 'AuthnContextClassRef')[0];
if (authnContextClassRef) {
info.login.authnContextClassRef = authnContextClassRef.textContent;
}
}
} else if (rootElement.localName == 'LogoutRequest') {
info.logout = {};
info.logout.callbackUrl = options.callbackUrl;
info.logout.destination = rootElement.getAttribute('Destination');
info.logout.id = rootElement.getAttribute('ID');
const issuerElem = rootElement.getElementsByTagNameNS(ASSERTION_NS, 'Issuer')[0];
if (issuerElem) info.logout.issuer = issuerElem.textContent;
const nameIdElem = rootElement.getElementsByTagNameNS(ASSERTION_NS, 'NameID')[0];
if (nameIdElem) info.logout.nameId = nameIdElem.textContent;
info.logout.response =
'<samlp:LogoutResponse xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ' +
'xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_' + crypto.randomBytes(21).toString('hex') +
'" Version="2.0" IssueInstant="' + new Date().toISOString() + '" Destination="' + info.logout.callbackUrl + '">' +
'<saml:Issuer>' + options.issuer + '</saml:Issuer>' +
'<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>' +
'</samlp:LogoutResponse>';
}
} catch(e) { console.log(e)
}
return info
};
exports.createAssertion = function(options) {
if (!options.key)
throw new Error('Expecting a private key in pem format');
if (!options.cert)
throw new Error('Expecting a public key cert in pem format');
options.signatureAlgorithm = options.signatureAlgorithm || 'rsa-sha256';
options.digestAlgorithm = options.digestAlgorithm || 'sha256';
var doc = new Parser().parseFromString(samlp.toString());
doc.documentElement.setAttribute('ID', '_' + (options.uid || crypto.randomBytes(21).toString('hex')));
if (options.issuer) {
var issuer = doc.documentElement.getElementsByTagName('saml:Issuer');
issuer[0].textContent = options.issuer;
}
var now = new Date().toISOString();
doc.documentElement.setAttribute('IssueInstant', now);
var conditions = doc.documentElement.getElementsByTagName('saml:Conditions')[0];
var confirmationData = doc.documentElement.getElementsByTagName('saml:SubjectConfirmationData')[0];
if (options.lifetimeInSeconds) {
var expires = new Date(Date.now() + options.lifetimeInSeconds*1000).toISOString();
conditions.setAttribute('NotBefore', now);
conditions.setAttribute('NotOnOrAfter', expires);
confirmationData.setAttribute('NotOnOrAfter', expires);
}
if (options.audiences) {
var audienceRestrictionsElement = doc.createElementNS(ASSERTION_NS, 'saml:AudienceRestriction');
var audiences = options.audiences instanceof Array ? options.audiences : [options.audiences];
audiences.forEach(function (audience) {
var element = doc.createElementNS(ASSERTION_NS, 'saml:Audience');
element.textContent = audience;
audienceRestrictionsElement.appendChild(element);
});
conditions.appendChild(audienceRestrictionsElement);
}
if (options.recipient)
confirmationData.setAttribute('Recipient', options.recipient);
if (options.inResponseTo)
confirmationData.setAttribute('InResponseTo', options.inResponseTo);
if (options.attributes) {
var statement = doc.createElementNS(ASSERTION_NS, 'saml:AttributeStatement');
statement.setAttribute('xmlns:xs', 'http://www.w3.org/2001/XMLSchema');
statement.setAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance');
Object.keys(options.attributes).forEach(function(prop) {
if(typeof options.attributes[prop] === 'undefined') return;
var attributeElement = doc.createElementNS(ASSERTION_NS, 'saml:Attribute');
attributeElement.setAttribute('Name', prop);
var values = options.attributes[prop] instanceof Array ? options.attributes[prop] : [options.attributes[prop]];
values.forEach(function (value) {
var valueElement = doc.createElementNS(ASSERTION_NS, 'saml:AttributeValue');
valueElement.setAttribute('xsi:type', 'xs:anyType');
valueElement.textContent = value;
attributeElement.appendChild(valueElement);
});
if (values && values.length > 0) {
// saml:Attribute must have at least one saml:AttributeValue
statement.appendChild(attributeElement);
}
});
doc.documentElement.appendChild(statement);
}
doc.getElementsByTagName('saml:AuthnStatement')[0].setAttribute('AuthnInstant', now);
if (options.sessionExpiration) {
doc.getElementsByTagName('saml:AuthnStatement')[0].setAttribute('SessionNotOnOrAfter', options.sessionExpiration);
}
if (options.sessionIndex) {
doc.getElementsByTagName('saml:AuthnStatement')[0].setAttribute('SessionIndex', options.sessionIndex);
}
var nameID = doc.documentElement.getElementsByTagNameNS(ASSERTION_NS, 'NameID')[0];
if (options.nameIdentifier) {
nameID.textContent = options.nameIdentifier;
}
if (options.nameIdentifierFormat) {
nameID.setAttribute('Format', options.nameIdentifierFormat);
}
if( options.authnContextClassRef ) {
var authnCtxClassRef = doc.getElementsByTagName('saml:AuthnContextClassRef')[0];
authnCtxClassRef.textContent = options.authnContextClassRef;
}
var sig = exports.signDocument(doc.toString(), "//*[local-name(.)='Assertion']", options);
return sig.getSignedXml();
};
exports.signDocument = function(token, reference, options) {
options.signatureAlgorithm = options.signatureAlgorithm || 'rsa-sha256';
options.digestAlgorithm = options.digestAlgorithm || 'sha256';
token = removeWhitespace(token);
var cert = pemToCert(options.cert);
var sig = new SignedXml(null, { signatureAlgorithm: algorithms.signature[options.signatureAlgorithm], idAttribute: 'ID' });
sig.signingKey = options.key;
sig.addReference(reference,
["http://www.w3.org/2000/09/xmldsig#enveloped-signature", "http://www.w3.org/2001/10/xml-exc-c14n#"],
algorithms.digest[options.digestAlgorithm]);
sig.keyInfoProvider = {
getKeyInfo: function (key, prefix) {
return '<'+prefix+':X509Data><'+prefix+':X509Certificate>' + cert + '</'+prefix+':X509Certificate></'+prefix+':X509Data>';
}
};
sig.computeSignature(token, {prefix: 'ds', location: {action: 'after', reference : "//*[local-name(.)='Issuer']"}});
return sig;
} }
exports.createResponse = function(options) { function pemToCert (pem) {
var response = '<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0"'; const cert = /-----BEGIN CERTIFICATE-----([^-]*)-----END CERTIFICATE-----/g.exec(pem.toString())
response += ' ID="_' + crypto.randomBytes(21).toString('hex') + '"'; if (cert.length > 0) {
response += ' IssueInstant="' + options.instant + '"'; return cert[1].replace(/[\n|\r\n]/g, '')
if (options.inResponseTo) {
response += ' InResponseTo="' + options.inResponseTo + '"';
} }
if (options.destination) {
response += ' Destination="' + options.destination + '"';
}
response += '><saml:Issuer>' + options.issuer + '</saml:Issuer>';
response += '<samlp:Status><samlp:StatusCode Value="' + options.samlStatusCode + '"/>';
if (options.samlStatusMessage) {
response += '<samlp:StatusMessage>' + options.samlStatusMessage + '</samlp:StatusMessage>';
}
response += '</samlp:Status>';
response += options.assertion;
response += '</samlp:Response>';
return response; return null
}; }
function removeWhitespace (xml) {
return xml.replace(/\r\n/g, '').replace(/\n/g, '').replace(/>(\s*)</g, '><').trim()
}
const idp = {
parseRequest (options, request, callback) {
options.issuer = options.issuer || 'https://idp.sso.tools'
request = decodeURIComponent(request)
let buffer = Buffer.from(request, 'base64')
const result = zlib.inflateRawSync(buffer)
const info = {}
try {
buffer = result
const doc = new DOMParser().parseFromString(buffer.toString())
const rootElement = doc.documentElement
if (rootElement.localName === 'AuthnRequest') {
info.login = {}
info.login.callbackUrl = rootElement.getAttribute('AssertionConsumerServiceURL')
info.login.destination = rootElement.getAttribute('Destination')
info.login.id = rootElement.getAttribute('ID')
info.login.forceAuthn = rootElement.getAttribute('ForceAuthn') === 'true'
const issuer = rootElement.getElementsByTagNameNS(ASSERTION_NS, 'Issuer')[0]
if (issuer) {
info.login.issuer = issuer.textContent
}
const nameIDPolicy = rootElement.getElementsByTagNameNS(SAMLP_NS, 'NameIDPolicy')[0]
if (nameIDPolicy) {
info.login.nameIdentifierFormat = nameIDPolicy.getAttribute('Format')
}
const requestedAuthnContext = rootElement.getElementsByTagNameNS(SAMLP_NS, 'RequestedAuthnContext')[0]
if (requestedAuthnContext) {
const authnContextClassRef = requestedAuthnContext.getElementsByTagNameNS(ASSERTION_NS, 'AuthnContextClassRef')[0]
if (authnContextClassRef) {
info.login.authnContextClassRef = authnContextClassRef.textContent
}
}
} else if (rootElement.localName === 'LogoutRequest') {
info.logout = {}
info.logout.callbackUrl = options.callbackUrl
info.logout.destination = rootElement.getAttribute('Destination')
info.logout.id = rootElement.getAttribute('ID')
const issuerElem = rootElement.getElementsByTagNameNS(ASSERTION_NS, 'Issuer')[0]
if (issuerElem) info.logout.issuer = issuerElem.textContent
const nameIdElem = rootElement.getElementsByTagNameNS(ASSERTION_NS, 'NameID')[0]
if (nameIdElem) info.logout.nameId = nameIdElem.textContent
info.logout.response =
'<samlp:LogoutResponse xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ' +
'xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_' + crypto.randomBytes(21).toString('hex') +
'" Version="2.0" IssueInstant="' + new Date().toISOString() + '" Destination="' + info.logout.callbackUrl + '">' +
'<saml:Issuer>' + options.issuer + '</saml:Issuer>' +
'<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>' +
'</samlp:LogoutResponse>'
}
} catch (e) {
console.log(e)
}
return info
},
createAssertion (options) {
if (!options.key) { throw new Error('Expecting a private key in pem format') }
if (!options.cert) { throw new Error('Expecting a public key cert in pem format') }
options.signatureAlgorithm = options.signatureAlgorithm || 'rsa-sha256'
options.digestAlgorithm = options.digestAlgorithm || 'sha256'
const doc = new DOMParser().parseFromString(SAMLP.toString())
doc.documentElement.setAttribute('ID', '_' + (options.uid || crypto.randomBytes(21).toString('hex')))
if (options.issuer) {
const issuer = doc.documentElement.getElementsByTagName('saml:Issuer')
issuer[0].textContent = options.issuer
}
const now = new Date().toISOString()
doc.documentElement.setAttribute('IssueInstant', now)
const conditions = doc.documentElement.getElementsByTagName('saml:Conditions')[0]
const confirmationData = doc.documentElement.getElementsByTagName('saml:SubjectConfirmationData')[0]
if (options.lifetimeInSeconds) {
const expires = new Date(Date.now() + options.lifetimeInSeconds * 1000).toISOString()
conditions.setAttribute('NotBefore', now)
conditions.setAttribute('NotOnOrAfter', expires)
confirmationData.setAttribute('NotOnOrAfter', expires)
}
if (options.audiences) {
const audienceRestrictionsElement = doc.createElementNS(ASSERTION_NS, 'saml:AudienceRestriction')
const audiences = options.audiences instanceof Array ? options.audiences : [options.audiences]
audiences.forEach(function (audience) {
const element = doc.createElementNS(ASSERTION_NS, 'saml:Audience')
element.textContent = audience
audienceRestrictionsElement.appendChild(element)
})
conditions.appendChild(audienceRestrictionsElement)
}
if (options.recipient) { confirmationData.setAttribute('Recipient', options.recipient) }
if (options.inResponseTo) { confirmationData.setAttribute('InResponseTo', options.inResponseTo) }
if (options.attributes) {
const statement = doc.createElementNS(ASSERTION_NS, 'saml:AttributeStatement')
statement.setAttribute('xmlns:xs', 'http://www.w3.org/2001/XMLSchema')
statement.setAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance')
Object.keys(options.attributes).forEach(function (prop) {
if (typeof options.attributes[prop] === 'undefined') return
const attributeElement = doc.createElementNS(ASSERTION_NS, 'saml:Attribute')
attributeElement.setAttribute('Name', prop)
const values = options.attributes[prop] instanceof Array ? options.attributes[prop] : [options.attributes[prop]]
values.forEach(function (value) {
const valueElement = doc.createElementNS(ASSERTION_NS, 'saml:AttributeValue')
valueElement.setAttribute('xsi:type', 'xs:anyType')
valueElement.textContent = value
attributeElement.appendChild(valueElement)
})
if (values && values.length > 0) {
// saml:Attribute must have at least one saml:AttributeValue
statement.appendChild(attributeElement)
}
})
doc.documentElement.appendChild(statement)
}
doc.getElementsByTagName('saml:AuthnStatement')[0].setAttribute('AuthnInstant', now)
if (options.sessionExpiration) {
doc.getElementsByTagName('saml:AuthnStatement')[0].setAttribute('SessionNotOnOrAfter', options.sessionExpiration)
}
if (options.sessionIndex) {
doc.getElementsByTagName('saml:AuthnStatement')[0].setAttribute('SessionIndex', options.sessionIndex)
}
const nameID = doc.documentElement.getElementsByTagNameNS(ASSERTION_NS, 'NameID')[0]
if (options.nameIdentifier) {
nameID.textContent = options.nameIdentifier
}
if (options.nameIdentifierFormat) {
nameID.setAttribute('Format', options.nameIdentifierFormat)
}
if (options.authnContextClassRef) {
const authnCtxClassRef = doc.getElementsByTagName('saml:AuthnContextClassRef')[0]
authnCtxClassRef.textContent = options.authnContextClassRef
}
const sig = this.signDocument(doc.toString(), "//*[local-name(.)='Assertion']", options)
return sig.getSignedXml()
},
signDocument (token, reference, options) {
options.signatureAlgorithm = options.signatureAlgorithm || 'rsa-sha256'
options.digestAlgorithm = options.digestAlgorithm || 'sha256'
token = removeWhitespace(token)
const cert = pemToCert(options.cert)
const sig = new SignedXml({
signatureAlgorithm: ALGORITHMS.signature[options.signatureAlgorithm],
digestAlgorithm: ALGORITHMS.digest[options.digestAlgorithm],
canonicalizationAlgorithm: 'http://www.w3.org/2001/10/xml-exc-c14n#',
idAttribute: 'ID',
privateKey: options.key
})
sig.addReference({
xpath: reference,
transforms: ['http://www.w3.org/2000/09/xmldsig#enveloped-signature', 'http://www.w3.org/2001/10/xml-exc-c14n#'],
digestAlgorithm: ALGORITHMS.digest[options.digestAlgorithm]
})
sig.keyInfoProvider = {
getKeyInfo: function (key, prefix) {
return '<' + prefix + ':X509Data><' + prefix + ':X509Certificate>' + cert + '</' + prefix + ':X509Certificate></' + prefix + ':X509Data>'
}
}
sig.computeSignature(token, { prefix: 'ds', location: { action: 'after', reference: "//*[local-name(.)='Issuer']" } })
return sig
},
createResponse (options) {
let response = '<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0"'
response += ' ID="_' + crypto.randomBytes(21).toString('hex') + '"'
response += ' IssueInstant="' + options.instant + '"'
if (options.inResponseTo) {
response += ' InResponseTo="' + options.inResponseTo + '"'
}
if (options.destination) {
response += ' Destination="' + options.destination + '"'
}
response += '><saml:Issuer>' + options.issuer + '</saml:Issuer>'
response += '<samlp:Status><samlp:StatusCode Value="' + options.samlStatusCode + '"/>'
if (options.samlStatusMessage) {
response += '<samlp:StatusMessage>' + options.samlStatusMessage + '</samlp:StatusMessage>'
}
response += '</samlp:Status>'
response += options.assertion
response += '</samlp:Response>'
return response
}
}
export default idp

File diff suppressed because it is too large Load Diff

11
idp/instrument.js Normal file
View File

@ -0,0 +1,11 @@
import * as Sentry from '@sentry/node'
import { nodeProfilingIntegration } from '@sentry/profiling-node'
Sentry.init({
dsn: 'https://0bc5040f94640cb1084cb27132265702@o4508066290532352.ingest.de.sentry.io/4508066312683600',
integrations: [
nodeProfilingIntegration()
],
tracesSampleRate: 1.0,
profilesSampleRate: 1.0
})

View File

@ -1,31 +1,28 @@
{ {
"name": "ssotoolsidp", "name": "ssotoolsidp",
"version": "1.0.0", "version": "1.0.0",
"scripts": {}, "type": "module",
"scripts": {
"start": "nodemon index.js",
"lint": "standard --fix"
},
"dependencies": { "dependencies": {
"async": "^3.2.4", "@sentry/node": "^8.33.1",
"@sentry/profiling-node": "^8.33.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"body-parser": "^1.19.0", "body-parser": "^1.20.3",
"cookie-parser": "^1.4.5", "cookie-parser": "^1.4.6",
"crypto": "^1.0.1", "crypto": "^1.0.1",
"debug": "^4.1.1", "express": "^4.21.0",
"express": "^4.17.1", "jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^8.5.1", "mongodb": "^6.9.0",
"mongodb": "^5.3.0", "uuid": "^10.0.0",
"pem": "^1.13.2", "xml-crypto": "^6.0.0",
"saml2-js": "^2.0.3", "xmldom": "^0.6.0",
"underscore": "^1.9.1",
"url": "^0.11.0",
"util": "^0.11.1",
"uuid": "^3.3.2",
"xml-crypto": "^1.1.1",
"xml-encryption": "^0.11.2",
"xml2js": "^0.4.19",
"xmlbuilder": "^10.1.1",
"xmldom": "^0.1.27",
"zlib": "^1.0.5" "zlib": "^1.0.5"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^2.0.7" "nodemon": "^3.1.7",
"standard": "^17.1.2"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -2,22 +2,26 @@
"name": "web", "name": "web",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"start": "vite --port 3800", "start": "vite --port 3800",
"build": "vite build" "build": "vite build",
"lint": "standard --fix"
}, },
"dependencies": { "dependencies": {
"@mdi/font": "^7.0.96", "@mdi/font": "^7.0.96",
"@sentry/vue": "^8.33.1",
"moment": "^2.29.4", "moment": "^2.29.4",
"vue": "^3.2.45", "vue": "^3.5.11",
"vue-router": "^4.1.6", "vue-router": "^4.4.5",
"vuetify": "^3.0.1", "vuetify": "^3.7.2",
"vuex": "^4.1.0" "vuex": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^3.2.0", "@vitejs/plugin-vue": "^5.1.4",
"vite": "^3.2.4", "standard": "^17.1.2",
"vue-template-compiler": "^2.7.14" "vite": "^5.4.8",
"vue-template-compiler": "^2.7.16"
}, },
"browserslist": [ "browserslist": [
"> 1%", "> 1%",

View File

@ -142,6 +142,10 @@
<v-card-text> <v-card-text>
<p>No problem. Enter the email address of your account below, and if it exists we'll send a password-reset link to you.</p> <p>No problem. Enter the email address of your account below, and if it exists we'll send a password-reset link to you.</p>
<v-text-field class="mt-5" type="email" label="Email address" v-model="loginData.email" /> <v-text-field class="mt-5" type="email" label="Email address" v-model="loginData.email" />
<v-alert v-if="resettingPasswordError" type="error">
<h4>There was a problem resetting your password</h4>
<p>{{resettingPasswordError}}</p>
</v-alert>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
@ -175,6 +179,7 @@ export default {
showPassword: false, showPassword: false,
forgottenPasswordOpen: false, forgottenPasswordOpen: false,
resettingPassword: false, resettingPassword: false,
resettingPasswordError: null
} }
}, },
computed: { computed: {
@ -280,8 +285,9 @@ export default {
api.req('POST', '/accounts/password/reset', { email: this.loginData.email }, () => { api.req('POST', '/accounts/password/reset', { email: this.loginData.email }, () => {
this.resettingPassword = false; this.resettingPassword = false;
this.forgottenPasswordOpen = false; this.forgottenPasswordOpen = false;
}, () => { }, err => {
this.resettingPassword = false; this.resettingPassword = false;
this.resettingPasswordError = err.message;
}); });
}, },
} }

View File

@ -1,45 +1,45 @@
const hosts = { const hosts = {
'development': 'http://localhost:3801', development: 'http://localhost:3801',
'production': 'https://api.sso.tools', production: 'https://api.sso.tools'
}; }
export const api = { export const api = {
token: null, token: null,
req(method, path, data, success, fail) { req (method, path, data, success, fail) {
const xhr = new XMLHttpRequest(); const xhr = new window.XMLHttpRequest()
xhr.open(method, `${hosts[process.env.NODE_ENV]}${path}`); xhr.open(method, `${hosts[process.env.NODE_ENV]}${path}`)
xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Content-Type', 'application/json')
if (api.token) { if (api.token) {
xhr.setRequestHeader('Authorization', `Bearer ${api.token}`); xhr.setRequestHeader('Authorization', `Bearer ${api.token}`)
} }
xhr.onreadystatechange = () => { xhr.onreadystatechange = () => {
if (xhr.readyState === 4) { if (xhr.readyState === 4) {
if (xhr.status === 200) { if (xhr.status === 200) {
let response; let response
try { response = JSON.parse(xhr.responseText); } catch (err) { console.log(err); } try { response = JSON.parse(xhr.responseText) } catch (err) { console.log(err) }
if (success) success(response); if (success) success(response)
} else { } else {
if (xhr.status === 401) { if (xhr.status === 401) {
return fail && fail({ status: 401, message: 'Authorisation is needed' }); return fail && fail({ status: 401, message: 'Authorisation is needed' })
} }
let message; let message
try { message = JSON.parse(xhr.responseText).message; } catch (err) { if (fail) fail({ status: xhr.status, message: 'There was a problem with this request' }); } try { message = JSON.parse(xhr.responseText).message } catch (err) { if (fail) fail({ status: xhr.status, message: 'There was a problem with this request' }) }
if (fail) fail({ status: xhr.status, message }); if (fail) fail({ status: xhr.status, message })
} }
} }
}; }
xhr.send(data && JSON.stringify(data)); xhr.send(data && JSON.stringify(data))
}, },
unauthenticatedRequest(method, path, data, success, fail, options) { unauthenticatedRequest (method, path, data, success, fail, options) {
api.req(method, path, data, success, fail, false, options); api.req(method, path, data, success, fail, false, options)
}, },
authenticatedRequest(method, path, data, success, fail, options ) { authenticatedRequest (method, path, data, success, fail, options) {
api.req(method, path, data, success, fail, true, options); api.req(method, path, data, success, fail, true, options)
}, }
}; }
export default api; export default api

View File

@ -8,6 +8,8 @@
<p>A globally unique machine-friendly (alphanumeric) code to identify this IDP on SSO Tools. We derive the issuer and other SSO information from this code, so you may need to update other configurations elsewhere if you do change it.</p> <p>A globally unique machine-friendly (alphanumeric) code to identify this IDP on SSO Tools. We derive the issuer and other SSO information from this code, so you may need to update other configurations elsewhere if you do change it.</p>
<v-text-field v-model="idp.code" label="Code" /> <v-text-field v-model="idp.code" label="Code" />
<v-alert v-if="errorMessage" type="error">{{errorMessage}}</v-alert>
<v-btn color='primary' :loading="saving" v-on:click="save">Save changes</v-btn> <v-btn color='primary' :loading="saving" v-on:click="save">Save changes</v-btn>
<v-snackbar v-model="snackbar" :timeout="2000" >Settings saved</v-snackbar> <v-snackbar v-model="snackbar" :timeout="2000" >Settings saved</v-snackbar>
@ -24,6 +26,7 @@ export default {
return { return {
snackbar: false, snackbar: false,
saving: false, saving: false,
errorMessage: null
} }
}, },
methods: { methods: {
@ -31,12 +34,14 @@ export default {
const { name, code } = this.idp; const { name, code } = this.idp;
const data = { name, code }; const data = { name, code };
this.saving = true; this.saving = true;
this.errorMessage = null;
api.req('PUT', `/idps/${this.idp._id}`, { name, code }, resp => { api.req('PUT', `/idps/${this.idp._id}`, { name, code }, resp => {
this.$emit('onUpdateIdp', resp); this.$emit('onUpdateIdp', resp);
this.snackbar = true; this.snackbar = true;
this.saving = false; this.saving = false;
}, err => { }, err => {
this.saving = false; this.saving = false;
this.errorMessage = err.message;
}); });
}, },
}, },

View File

@ -72,6 +72,8 @@
<h3 class="mb-2">OAuth2 settings (optional)</h3> <h3 class="mb-2">OAuth2 settings (optional)</h3>
<v-text-field class="mb-2" label="Redirect URI" v-model="newSP.oauth2RedirectUri" hint="The URI users will be redirected to after successful authorization." placeholder="https://myapp.com/oauth/callback"/> <v-text-field class="mb-2" label="Redirect URI" v-model="newSP.oauth2RedirectUri" hint="The URI users will be redirected to after successful authorization." placeholder="https://myapp.com/oauth/callback"/>
<v-alert v-if="createError" type="error">{{createError}}</v-alert>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
@ -98,6 +100,7 @@ export default {
newSP: { name: '', entityId: '', serviceUrl: '', callbackUrl: '', logoutUrl: '', logoutCallbackUrl: '', oauth2RedirectUri: ''}, newSP: { name: '', entityId: '', serviceUrl: '', callbackUrl: '', logoutUrl: '', logoutCallbackUrl: '', oauth2RedirectUri: ''},
dialog: false, dialog: false,
editing: false, editing: false,
createError: null,
} }
}, },
created () { created () {
@ -116,6 +119,7 @@ export default {
create (event) { create (event) {
const { _id, name, entityId, serviceUrl, callbackUrl, logoutUrl, logoutCallbackUrl, oauth2RedirectUri } = this.newSP; const { _id, name, entityId, serviceUrl, callbackUrl, logoutUrl, logoutCallbackUrl, oauth2RedirectUri } = this.newSP;
const data = {name, entityId, serviceUrl, callbackUrl, logoutUrl, logoutCallbackUrl, oauth2RedirectUri}; const data = {name, entityId, serviceUrl, callbackUrl, logoutUrl, logoutCallbackUrl, oauth2RedirectUri};
this.createError = null;
if (_id && this.editing) { if (_id && this.editing) {
api.req('PUT', `/idps/${this.idp._id}/sps/${_id}`, data, resp => { api.req('PUT', `/idps/${this.idp._id}/sps/${_id}`, data, resp => {
this.sps.map(s => { this.sps.map(s => {
@ -124,12 +128,16 @@ export default {
}); });
this.dialog = false; this.dialog = false;
this.editing = false; this.editing = false;
}, err => console.log(err)); }, err => {
this.createError = err.message;
});
} else { } else {
api.req('POST', `/idps/${this.idp._id}/sps`, data, resp => { api.req('POST', `/idps/${this.idp._id}/sps`, data, resp => {
this.sps.push(resp); this.sps.push(resp);
this.dialog = false; this.dialog = false;
}, err => console.log(err)); }, err => {
this.createError = err.message;
});
} }
}, },
editSp(sp) { editSp(sp) {

View File

@ -68,6 +68,7 @@
</div> </div>
</div> </div>
</div> </div>
<v-alert v-if="createError" type="error" dismissible>{{createError}}</v-alert>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
@ -132,6 +133,8 @@
<v-text-field class="mb-2" label="Attribute key" v-model="newAttribute.samlMapping" hint="Values for this attribute will be sent with this key during the SSO process." /> <v-text-field class="mb-2" label="Attribute key" v-model="newAttribute.samlMapping" hint="Values for this attribute will be sent with this key during the SSO process." />
<v-text-field label="Default value" v-model="newAttribute.defaultValue" hint="If not overridden in the user itself, this value will be sent as a default."/> <v-text-field label="Default value" v-model="newAttribute.defaultValue" hint="If not overridden in the user itself, this value will be sent as a default."/>
</div> </div>
<v-alert v-if="createAttributeError" type="error" dismissible>{{createAttributeError}}</v-alert>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
@ -166,6 +169,8 @@ export default {
editing: false, editing: false,
attributeDialog: false, attributeDialog: false,
editingAttribute: false, editingAttribute: false,
createError: null,
createAttributeError: null,
} }
}, },
created () { created () {
@ -194,6 +199,7 @@ export default {
create (event) { create (event) {
const { _id, firstName, lastName, email, password, attributes } = this.newUser; const { _id, firstName, lastName, email, password, attributes } = this.newUser;
const data = {firstName, lastName, email, password, attributes}; const data = {firstName, lastName, email, password, attributes};
this.createError = null;
if (_id && this.editing) { if (_id && this.editing) {
api.req('PUT', `/idps/${this.idp._id}/users/${_id}`, data, resp => { api.req('PUT', `/idps/${this.idp._id}/users/${_id}`, data, resp => {
this.users.map(u => { this.users.map(u => {
@ -202,17 +208,22 @@ export default {
}); });
this.dialog = false; this.dialog = false;
this.editing = false; this.editing = false;
}, err => console.log(err)); }, err => {
this.createError = err.message;
});
} else { } else {
api.req('POST', `/idps/${this.idp._id}/users`, data, resp => { api.req('POST', `/idps/${this.idp._id}/users`, data, resp => {
this.users.push(resp); this.users.push(resp);
this.dialog = false; this.dialog = false;
}, err => console.log(err)); }, err => {
this.createError = err.message;
});
} }
}, },
createAttribute() { createAttribute() {
const { _id, name, defaultValue, samlMapping } = this.newAttribute; const { _id, name, defaultValue, samlMapping } = this.newAttribute;
const data = {name, defaultValue, samlMapping}; const data = {name, defaultValue, samlMapping};
this.createAttributeError = null;
if (_id && this.editingAttribute) { if (_id && this.editingAttribute) {
api.req('PUT', `/idps/${this.idp._id}/attributes/${_id}`, data, resp => { api.req('PUT', `/idps/${this.idp._id}/attributes/${_id}`, data, resp => {
this.attributes.map(u => { this.attributes.map(u => {
@ -221,12 +232,16 @@ export default {
}); });
this.attributeDialog = false; this.attributeDialog = false;
this.editingAttribute = false; this.editingAttribute = false;
}, err => console.log(err)); }, err => {
this.createAttributeError = err.message;
});
} else { } else {
api.req('POST', `/idps/${this.idp._id}/attributes`, data, resp => { api.req('POST', `/idps/${this.idp._id}/attributes`, data, resp => {
this.attributes.push(resp); this.attributes.push(resp);
this.attributeDialog = false; this.attributeDialog = false;
}, err => console.log(err)); }, err => {
this.createAttributeError = err.message;
});
} }
}, },
editUser(user) { editUser(user) {

View File

@ -7,7 +7,7 @@
<div v-if="!success"> <div v-if="!success">
<v-text-field :type="showPassword ? 'text' : 'password'" label="Password" v-model="password" :append-icon="showPassword ? 'visibility' : 'visibility_off'" @click:append="showPassword = !showPassword"/> <v-text-field :type="showPassword ? 'text' : 'password'" label="Password" v-model="password" :append-icon="showPassword ? 'visibility' : 'visibility_off'" @click:append="showPassword = !showPassword"/>
<v-alert :value="error" type="error"> <v-alert v-if="error" type="error" class="mt-5 mb-5">
<h4>Unable to set the password</h4> <h4>Unable to set the password</h4>
<p>{{error}}</p> <p>{{error}}</p>
</v-alert> </v-alert>
@ -15,10 +15,10 @@
<v-btn :loading="settingPassword" color="primary" @click="setPassword">Set new password</v-btn> <v-btn :loading="settingPassword" color="primary" @click="setPassword">Set new password</v-btn>
</div> </div>
<v-alert :value="success" type="success"> <v-alert v-if="success" type="success" class="mt-5">
<h4>Password updated successfully</h4> <h4>Password updated successfully</h4>
<p>When you're ready, you can login with your new password.</p> <p>When you're ready, you can login with your new password.</p>
<v-btn color="primary" @click="e => $store.commit('openLogin', true)">Login</v-btn> <v-btn color="primary" class="mt-2" @click="e => $store.commit('openLogin', true)">Login</v-btn>
</v-alert> </v-alert>
</v-container> </v-container>
</template> </template>

View File

@ -7,69 +7,70 @@ import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives' import * as directives from 'vuetify/directives'
import { aliases, mdi } from 'vuetify/iconsets/mdi' import { aliases, mdi } from 'vuetify/iconsets/mdi'
import '@mdi/font/css/materialdesignicons.css' import '@mdi/font/css/materialdesignicons.css'
import * as Sentry from '@sentry/vue'
import App from './App.vue' import App from './App.vue'
import Home from './components/Home.vue'; import Home from './components/Home.vue'
import ResetPassword from './components/ResetPassword.vue'; import ResetPassword from './components/ResetPassword.vue'
import Account from './components/Account.vue'; import Account from './components/Account.vue'
import Dashboard from './components/Dashboard.vue' import Dashboard from './components/Dashboard.vue'
import Root from './components/Root.vue' import Root from './components/Root.vue'
import NewIDP from './components/NewIDP.vue' import NewIDP from './components/NewIDP.vue'
import IDP from './components/IDP.vue'; import IDP from './components/IDP.vue'
import IDPHome from './components/IdpHome.vue'; import IDPHome from './components/IdpHome.vue'
import IDPUsers from './components/IdpUsers.vue'; import IDPUsers from './components/IdpUsers.vue'
import IDPSettings from './components/IdpSettings.vue'; import IDPSettings from './components/IdpSettings.vue'
import IDPSPs from './components/IdpSps.vue'; import IDPSPs from './components/IdpSps.vue'
import IDPSAML from './components/IdpSaml.vue'; import IDPSAML from './components/IdpSaml.vue'
import IDPSAMLLogs from './components/IdpSamlLogs.vue'; import IDPSAMLLogs from './components/IdpSamlLogs.vue'
import IDPOAuth from './components/IdpOauth.vue'; import IDPOAuth from './components/IdpOauth.vue'
import IDPOAuthGuide from './components/IdpOauthGuide.vue'; import IDPOAuthGuide from './components/IdpOauthGuide.vue'
import IDPSaml2Guide from './components/IdpSaml2Guide.vue'; import IDPSaml2Guide from './components/IdpSaml2Guide.vue'
import IDPOAuthLogs from './components/IdpOauthLogs.vue'; import IDPOAuthLogs from './components/IdpOauthLogs.vue'
import GuideLayout from './components/Guide.vue'; import GuideLayout from './components/Guide.vue'
import PrivacyPolicy from './components/legal/PrivacyPolicy.vue'; import PrivacyPolicy from './components/legal/PrivacyPolicy.vue'
import TermsOfUse from './components/legal/TermsOfUse.vue'; import TermsOfUse from './components/legal/TermsOfUse.vue'
const store = createStore({ const store = createStore({
state: { state: {
loggedIn: false, loggedIn: false,
user: null, user: null,
registerOpen: false, registerOpen: false,
loginOpen: false, loginOpen: false
}, },
mutations: { mutations: {
login (state, loggedIn) { login (state, loggedIn) {
state.loggedIn = loggedIn; state.loggedIn = loggedIn
}, },
setUser (state, user) { setUser (state, user) {
state.user = user; state.user = user
if (user && window.drift && window.drift.identify) { if (user && window.drift && window.drift.identify) {
window.drift.identify(user._id, { window.drift.identify(user._id, {
email: user.email, email: user.email,
firstName: user.firstName, firstName: user.firstName
}); })
} }
if (!user && window.drift && window.drift.reset) { if (!user && window.drift && window.drift.reset) {
window.drift.reset(); window.drift.reset()
} }
}, },
updateProfile (state, profile) { updateProfile (state, profile) {
state.user = Object.assign({}, state.user, profile); state.user = Object.assign({}, state.user, profile)
}, },
openRegister (state, open) { openRegister (state, open) {
state.registerOpen = open; state.registerOpen = open
}, },
openLogin (state, open) { openLogin (state, open) {
state.loginOpen = open; state.loginOpen = open
}, }
} }
}) })
const router = createRouter({ const router = createRouter({
scrollBehavior() { scrollBehavior () {
return { left: 0, top: 0 }; return { left: 0, top: 0 }
}, },
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
@ -79,24 +80,32 @@ const router = createRouter({
{ path: '/account', component: Account }, { path: '/account', component: Account },
{ path: '/password/reset', component: ResetPassword }, { path: '/password/reset', component: ResetPassword },
{ path: '/dashboard', component: Dashboard }, { path: '/dashboard', component: Dashboard },
{ path: '/guides', component: GuideLayout, children: [ {
{ path: 'oauth2', component: IDPOAuthGuide }, path: '/guides',
{ path: 'saml2', component: IDPSaml2Guide }, component: GuideLayout,
] }, children: [
{ path: 'oauth2', component: IDPOAuthGuide },
{ path: 'saml2', component: IDPSaml2Guide }
]
},
{ path: '/idps/new', component: NewIDP }, { path: '/idps/new', component: NewIDP },
{ path: '/idps/:id', component: IDP, children: [ {
{ path: '', component: IDPHome }, path: '/idps/:id',
{ path: 'users', component: IDPUsers }, component: IDP,
{ path: 'settings', component: IDPSettings }, children: [
{ path: 'sps', component: IDPSPs }, { path: '', component: IDPHome },
{ path: 'saml', component: IDPSAML }, { path: 'users', component: IDPUsers },
{ path: 'saml/guide', component: IDPSaml2Guide }, { path: 'settings', component: IDPSettings },
{ path: 'saml/logs', component: IDPSAMLLogs }, { path: 'sps', component: IDPSPs },
{ path: 'oauth', component: IDPOAuth }, { path: 'saml', component: IDPSAML },
{ path: 'oauth/guide', component: IDPOAuthGuide }, { path: 'saml/guide', component: IDPSaml2Guide },
{ path: 'oauth/logs', component: IDPOAuthLogs }, { path: 'saml/logs', component: IDPSAMLLogs },
] }, { path: 'oauth', component: IDPOAuth },
{ path: '/root', component: Root }, { path: 'oauth/guide', component: IDPOAuthGuide },
{ path: 'oauth/logs', component: IDPOAuthLogs }
]
},
{ path: '/root', component: Root }
] ]
}) })
@ -107,12 +116,25 @@ const vuetify = createVuetify({
defaultSet: 'mdi', defaultSet: 'mdi',
aliases, aliases,
sets: { sets: {
mdi, mdi
} }
}, }
}) })
const app = createApp(App) const app = createApp(App)
Sentry.init({
app,
dsn: 'https://68f35a231add93109261ef91390c4c5f@o4508066290532352.ingest.de.sentry.io/4508066292760656',
integrations: [
Sentry.browserTracingIntegration({ router }),
Sentry.replayIntegration()
],
tracesSampleRate: 1.0, // Capture 100% of the transactions
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0
})
app.use(store) app.use(store)
app.use(vuetify) app.use(vuetify)
app.use(router) app.use(router)

View File

@ -1,6 +1,6 @@
export default { export default {
hasPermission(user, permission, scope) { hasPermission (user, permission, scope) {
if (!user?.permissions || !permission) return false; if (!user?.permissions || !permission) return false
return user.permissions[scope || 'global']?.indexOf(permission) > -1; return user.permissions[scope || 'global']?.indexOf(permission) > -1
} }
} }

File diff suppressed because it is too large Load Diff