Compare commits

..

No commits in common. "b06757fb03f5f785190d8c7bee5d03b3cf4ee23d" and "cf02c3a769c10fda19daf37ba43b90ad50bb6056" have entirely different histories.

45 changed files with 3324 additions and 7872 deletions

2
.gitignore vendored
View File

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

View File

@ -1,4 +1,4 @@
steps:
pipeline:
buildweb:
group: build
image: node
@ -44,5 +44,4 @@ steps:
- 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'
when:
branch: main
branches: main

View File

@ -1,4 +1,4 @@
FROM python:3.12-slim-bookworm
FROM python:3.9-slim-buster
# set work directory
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:
```shell
virtualenv -p python3.12 .venv # You only need this the first time
virtualenv -p python3 .venv # You only need this the first time
source .venv/bin/activate
```

View File

@ -1,223 +0,0 @@
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}

View File

@ -1,502 +0,0 @@
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}

View File

@ -1,33 +0,0 @@
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}

View File

@ -1,60 +0,0 @@
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,349 +1,148 @@
import sentry_sdk
from flask import Flask, jsonify, request
from flask_cors import CORS
from flask_limiter import Limiter
import werkzeug
from webargs import fields, validate
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,
)
from chalicelib.util import util
from chalicelib.api import accounts, users, idps, root
app = Flask(__name__)
CORS(app)
limiter = Limiter(util.limit_by_user, app=app, default_limits=["20 per minute"])
limiter = Limiter(app, default_limits=['20 per minute'], key_func=util.limit_by_user)
@app.errorhandler(werkzeug.exceptions.TooManyRequests)
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)
def handle_bad_request(e):
return jsonify({"message": e.description}), 400
return jsonify({'message': e.description}), 400
@app.errorhandler(werkzeug.exceptions.Unauthorized)
def handle_not_authorized(e):
return jsonify({"message": e.description}), 401
return jsonify({'message': e.description}), 401
@app.errorhandler(werkzeug.exceptions.Forbidden)
def handle_forbidden(e):
return jsonify({"message": e.description}), 403
return jsonify({'message': e.description}), 403
@app.errorhandler(werkzeug.exceptions.NotFound)
def handle_not_found(e):
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
return jsonify({'message': e.description}), 404
# 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", methods=["POST"])
@limiter.limit("5 per minute", key_func=util.limit_by_client, methods=["POST"])
@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/enrol', methods=['POST'])
def enrol():
return util.jsonify(accounts.enrol(request.json))
@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/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"])
@app.route('/accounts/sessions', methods=['DELETE'])
def logout():
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", methods=["DELETE"])
@use_args(
{
"password": fields.Str(required=True),
}
)
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', methods=['PUT'])
@limiter.limit('5 per minute', key_func=util.limit_by_user, methods=['PUT'])
def password():
body = request.json
return util.jsonify(accounts.update_password(util.get_user(required=False), body))
@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
@app.route("/users/me", methods=["GET"])
@app.route('/users/me', methods=['GET'])
def users_me():
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))
@app.route('/users/<id>', methods=['PUT'])
def user_route(id):
return util.jsonify(users.update(util.get_user(), id, request.json))
# 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", 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"])
@app.route('/idps/<id>', methods=['GET', 'PUT', 'DELETE'])
def idp_route(id):
if request.method == "GET":
if request.method == 'GET':
return util.jsonify(idps.get_one(util.get_user(required=False), id))
if request.method == "DELETE":
if request.method == 'PUT':
return util.jsonify(idps.update(util.get_user(required=False), id, request.json))
if request.method == 'DELETE':
return util.jsonify(idps.delete(util.get_user(required=False), id))
@app.route("/idps/<id>", methods=["PUT"])
@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):
@app.route('/idps/<id>/sps', methods=['GET', 'POST'])
def idp_sps_route(id):
if request.method == 'GET':
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", 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"])
@app.route('/idps/<id>/sps/<sp_id>', methods=['PUT', 'DELETE'])
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))
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"])
def idp_users_route_get(id):
@app.route('/idps/<id>/users', methods=['GET', 'POST'])
def idp_users_route(id):
if request.method == 'GET':
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", methods=["POST"])
@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):
@app.route('/idps/<id>/users/<user_id>', methods=['PUT', 'DELETE'])
def idp_user_route(id, user_id):
if request.method == 'DELETE':
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"])
def idp_attributes_route_get(id):
@app.route('/idps/<id>/attributes', methods=['GET', 'POST'])
def idp_attributes_route(id):
if request.method == 'GET':
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>/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"])
@app.route('/idps/<id>/saml2/logs', methods=['GET'])
def idp_saml_logs_route(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):
return util.jsonify(idps.get_oauth_logs(util.get_user(required=False), id))
# Root
@app.route("/root/users", methods=["GET"])
@app.route('/root/users', methods=['GET'])
def root_users():
return util.jsonify(root.get_users(util.get_user()))

View File

View File

@ -0,0 +1,162 @@
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}

348
api/chalicelib/api/idps.py Normal file
View File

@ -0,0 +1,348 @@
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

@ -0,0 +1,13 @@
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

@ -0,0 +1,46 @@
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

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

View File

@ -0,0 +1,10 @@
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

@ -0,0 +1,27 @@
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

@ -0,0 +1,62 @@
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)

View File

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

1467
api/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -1,11 +0,0 @@
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

View File

@ -1,28 +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,80 +0,0 @@
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:20
FROM node:19
# Create app directory
WORKDIR /usr/src/app

View File

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

View File

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

View File

@ -1,10 +1,9 @@
import crypto from 'crypto'
import zlib from 'zlib'
import { Buffer } from 'buffer'
import { DOMParser } from 'xmldom'
import { SignedXml } from 'xml-crypto'
const SAMLP = `<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0" ID="" IssueInstant="">
var crypto = require('crypto');
var zlib = require('zlib');
var Buffer = require('buffer').Buffer;
var Parser = require('xmldom').DOMParser;
var SignedXml = require('xml-crypto').SignedXml;
var samlp = `<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0" ID="" IssueInstant="">
<saml:Issuer></saml:Issuer>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" />
@ -19,231 +18,224 @@ const 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:AuthnContext>
</saml:AuthnStatement>
</saml:Assertion>`
const ASSERTION_NS = 'urn:oasis:names:tc:SAML:2.0:assertion'
const SAMLP_NS = 'urn:oasis:names:tc:SAML:2.0:protocol'
const ALGORITHMS = {
</saml:Assertion>`;
function pemToCert(pem) {
var cert = /-----BEGIN CERTIFICATE-----([^-]*)-----END CERTIFICATE-----/g.exec(pem.toString());
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: {
'rsa-sha256': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
'rsa-sha1': 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'
},
digest: {
sha256: 'http://www.w3.org/2001/04/xmlenc#sha256',
sha1: 'http://www.w3.org/2000/09/xmldsig#sha1'
}
'sha256': 'http://www.w3.org/2001/04/xmlenc#sha256',
'sha1': 'http://www.w3.org/2000/09/xmldsig#sha1'
}
};
function pemToCert (pem) {
const cert = /-----BEGIN CERTIFICATE-----([^-]*)-----END CERTIFICATE-----/g.exec(pem.toString())
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()
}
const idp = {
parseRequest (options, request, callback) {
options.issuer = options.issuer || 'https://idp.sso.tools'
request = decodeURIComponent(request)
let buffer = Buffer.from(request, 'base64')
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)
const info = {}
var 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]
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
info.login.issuer = issuer.textContent;
}
const nameIDPolicy = rootElement.getElementsByTagNameNS(SAMLP_NS, 'NameIDPolicy')[0]
var nameIDPolicy = rootElement.getElementsByTagNameNS(SAMLP_NS, 'NameIDPolicy')[0];
if (nameIDPolicy) {
info.login.nameIdentifierFormat = nameIDPolicy.getAttribute('Format')
info.login.nameIdentifierFormat = nameIDPolicy.getAttribute('Format');
}
const requestedAuthnContext = rootElement.getElementsByTagNameNS(SAMLP_NS, 'RequestedAuthnContext')[0]
var requestedAuthnContext = rootElement.getElementsByTagNameNS(SAMLP_NS, 'RequestedAuthnContext')[0];
if (requestedAuthnContext) {
const authnContextClassRef = requestedAuthnContext.getElementsByTagNameNS(ASSERTION_NS, 'AuthnContextClassRef')[0]
var authnContextClassRef = requestedAuthnContext.getElementsByTagNameNS(ASSERTION_NS, 'AuthnContextClassRef')[0];
if (authnContextClassRef) {
info.login.authnContextClassRef = authnContextClassRef.textContent
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
} 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>'
'</samlp:LogoutResponse>';
}
} catch (e) {
console.log(e)
} catch(e) { console.log(e)
}
return info
},
};
createAssertion (options) {
if (!options.key) { throw new Error('Expecting a private key in pem format') }
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') }
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'
options.signatureAlgorithm = options.signatureAlgorithm || 'rsa-sha256';
options.digestAlgorithm = options.digestAlgorithm || 'sha256';
const doc = new DOMParser().parseFromString(SAMLP.toString())
var doc = new Parser().parseFromString(samlp.toString());
doc.documentElement.setAttribute('ID', '_' + (options.uid || crypto.randomBytes(21).toString('hex')))
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
var 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]
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) {
const expires = new Date(Date.now() + options.lifetimeInSeconds * 1000).toISOString()
conditions.setAttribute('NotBefore', now)
conditions.setAttribute('NotOnOrAfter', expires)
confirmationData.setAttribute('NotOnOrAfter', expires)
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) {
const audienceRestrictionsElement = doc.createElementNS(ASSERTION_NS, 'saml:AudienceRestriction')
const audiences = options.audiences instanceof Array ? options.audiences : [options.audiences]
var audienceRestrictionsElement = doc.createElementNS(ASSERTION_NS, 'saml:AudienceRestriction');
var 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)
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.recipient)
confirmationData.setAttribute('Recipient', options.recipient);
if (options.inResponseTo) { confirmationData.setAttribute('InResponseTo', options.inResponseTo) }
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')
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
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]]
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) {
const valueElement = doc.createElementNS(ASSERTION_NS, 'saml:AttributeValue')
valueElement.setAttribute('xsi:type', 'xs:anyType')
valueElement.textContent = value
attributeElement.appendChild(valueElement)
})
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)
statement.appendChild(attributeElement);
}
})
doc.documentElement.appendChild(statement)
});
doc.documentElement.appendChild(statement);
}
doc.getElementsByTagName('saml:AuthnStatement')[0].setAttribute('AuthnInstant', now)
doc.getElementsByTagName('saml:AuthnStatement')[0].setAttribute('AuthnInstant', now);
if (options.sessionExpiration) {
doc.getElementsByTagName('saml:AuthnStatement')[0].setAttribute('SessionNotOnOrAfter', options.sessionExpiration)
doc.getElementsByTagName('saml:AuthnStatement')[0].setAttribute('SessionNotOnOrAfter', options.sessionExpiration);
}
if (options.sessionIndex) {
doc.getElementsByTagName('saml:AuthnStatement')[0].setAttribute('SessionIndex', options.sessionIndex)
doc.getElementsByTagName('saml:AuthnStatement')[0].setAttribute('SessionIndex', options.sessionIndex);
}
const nameID = doc.documentElement.getElementsByTagNameNS(ASSERTION_NS, 'NameID')[0]
var nameID = doc.documentElement.getElementsByTagNameNS(ASSERTION_NS, 'NameID')[0];
if (options.nameIdentifier) {
nameID.textContent = options.nameIdentifier
nameID.textContent = options.nameIdentifier;
}
if (options.nameIdentifierFormat) {
nameID.setAttribute('Format', options.nameIdentifierFormat)
nameID.setAttribute('Format', options.nameIdentifierFormat);
}
if( options.authnContextClassRef ) {
const authnCtxClassRef = doc.getElementsByTagName('saml:AuthnContextClassRef')[0]
authnCtxClassRef.textContent = options.authnContextClassRef
var authnCtxClassRef = doc.getElementsByTagName('saml:AuthnContextClassRef')[0];
authnCtxClassRef.textContent = options.authnContextClassRef;
}
const sig = this.signDocument(doc.toString(), "//*[local-name(.)='Assertion']", options)
return sig.getSignedXml()
},
var sig = exports.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]
})
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>'
return '<'+prefix+':X509Data><'+prefix+':X509Certificate>' + cert + '</'+prefix+':X509Certificate></'+prefix+':X509Data>';
}
};
sig.computeSignature(token, {prefix: 'ds', location: {action: 'after', reference : "//*[local-name(.)='Issuer']"}});
return sig;
}
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 + '"'
exports.createResponse = function(options) {
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"';
response += ' ID="_' + crypto.randomBytes(21).toString('hex') + '"';
response += ' IssueInstant="' + options.instant + '"';
if (options.inResponseTo) {
response += ' InResponseTo="' + options.inResponseTo + '"'
response += ' InResponseTo="' + options.inResponseTo + '"';
}
if (options.destination) {
response += ' Destination="' + options.destination + '"'
response += ' Destination="' + options.destination + '"';
}
response += '><saml:Issuer>' + options.issuer + '</saml:Issuer>'
response += '<samlp:Status><samlp:StatusCode Value="' + options.samlStatusCode + '"/>'
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:StatusMessage>' + options.samlStatusMessage + '</samlp:StatusMessage>';
}
response += '</samlp:Status>'
response += options.assertion
response += '</samlp:Response>'
response += '</samlp:Status>';
response += options.assertion;
response += '</samlp:Response>';
return response
}
}
export default idp
return response;
};

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +0,0 @@
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,28 +1,31 @@
{
"name": "ssotoolsidp",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "nodemon index.js",
"lint": "standard --fix"
},
"scripts": {},
"dependencies": {
"@sentry/node": "^8.33.1",
"@sentry/profiling-node": "^8.33.1",
"async": "^3.2.4",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.3",
"cookie-parser": "^1.4.6",
"body-parser": "^1.19.0",
"cookie-parser": "^1.4.5",
"crypto": "^1.0.1",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"mongodb": "^6.9.0",
"uuid": "^10.0.0",
"xml-crypto": "^6.0.0",
"xmldom": "^0.6.0",
"debug": "^4.1.1",
"express": "^4.17.1",
"jsonwebtoken": "^8.5.1",
"mongodb": "^5.3.0",
"pem": "^1.13.2",
"saml2-js": "^2.0.3",
"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"
},
"devDependencies": {
"nodemon": "^3.1.7",
"standard": "^17.1.2"
"nodemon": "^2.0.7"
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -142,10 +142,6 @@
<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>
<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-actions>
<v-spacer></v-spacer>
@ -179,7 +175,6 @@ export default {
showPassword: false,
forgottenPasswordOpen: false,
resettingPassword: false,
resettingPasswordError: null
}
},
computed: {
@ -285,9 +280,8 @@ export default {
api.req('POST', '/accounts/password/reset', { email: this.loginData.email }, () => {
this.resettingPassword = false;
this.forgottenPasswordOpen = false;
}, err => {
}, () => {
this.resettingPassword = false;
this.resettingPasswordError = err.message;
});
},
}

View File

@ -1,45 +1,45 @@
const hosts = {
development: 'http://localhost:3801',
production: 'https://api.sso.tools'
}
'development': 'http://localhost:3801',
'production': 'https://api.sso.tools',
};
export const api = {
token: null,
req(method, path, data, success, fail) {
const xhr = new window.XMLHttpRequest()
xhr.open(method, `${hosts[process.env.NODE_ENV]}${path}`)
xhr.setRequestHeader('Content-Type', 'application/json')
const xhr = new XMLHttpRequest();
xhr.open(method, `${hosts[process.env.NODE_ENV]}${path}`);
xhr.setRequestHeader('Content-Type', 'application/json');
if (api.token) {
xhr.setRequestHeader('Authorization', `Bearer ${api.token}`)
xhr.setRequestHeader('Authorization', `Bearer ${api.token}`);
}
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
let response
try { response = JSON.parse(xhr.responseText) } catch (err) { console.log(err) }
if (success) success(response)
let response;
try { response = JSON.parse(xhr.responseText); } catch (err) { console.log(err); }
if (success) success(response);
} else {
if (xhr.status === 401) {
return fail && fail({ status: 401, message: 'Authorisation is needed' })
return fail && fail({ status: 401, message: 'Authorisation is needed' });
}
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' }) }
if (fail) fail({ status: xhr.status, 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' }); }
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) {
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 ) {
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,8 +8,6 @@
<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-alert v-if="errorMessage" type="error">{{errorMessage}}</v-alert>
<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>
@ -26,7 +24,6 @@ export default {
return {
snackbar: false,
saving: false,
errorMessage: null
}
},
methods: {
@ -34,14 +31,12 @@ export default {
const { name, code } = this.idp;
const data = { name, code };
this.saving = true;
this.errorMessage = null;
api.req('PUT', `/idps/${this.idp._id}`, { name, code }, resp => {
this.$emit('onUpdateIdp', resp);
this.snackbar = true;
this.saving = false;
}, err => {
this.saving = false;
this.errorMessage = err.message;
});
},
},

View File

@ -72,8 +72,6 @@
<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-alert v-if="createError" type="error">{{createError}}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
@ -100,7 +98,6 @@ export default {
newSP: { name: '', entityId: '', serviceUrl: '', callbackUrl: '', logoutUrl: '', logoutCallbackUrl: '', oauth2RedirectUri: ''},
dialog: false,
editing: false,
createError: null,
}
},
created () {
@ -119,7 +116,6 @@ export default {
create (event) {
const { _id, name, entityId, serviceUrl, callbackUrl, logoutUrl, logoutCallbackUrl, oauth2RedirectUri } = this.newSP;
const data = {name, entityId, serviceUrl, callbackUrl, logoutUrl, logoutCallbackUrl, oauth2RedirectUri};
this.createError = null;
if (_id && this.editing) {
api.req('PUT', `/idps/${this.idp._id}/sps/${_id}`, data, resp => {
this.sps.map(s => {
@ -128,16 +124,12 @@ export default {
});
this.dialog = false;
this.editing = false;
}, err => {
this.createError = err.message;
});
}, err => console.log(err));
} else {
api.req('POST', `/idps/${this.idp._id}/sps`, data, resp => {
this.sps.push(resp);
this.dialog = false;
}, err => {
this.createError = err.message;
});
}, err => console.log(err));
}
},
editSp(sp) {

View File

@ -68,7 +68,6 @@
</div>
</div>
</div>
<v-alert v-if="createError" type="error" dismissible>{{createError}}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
@ -133,8 +132,6 @@
<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."/>
</div>
<v-alert v-if="createAttributeError" type="error" dismissible>{{createAttributeError}}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
@ -169,8 +166,6 @@ export default {
editing: false,
attributeDialog: false,
editingAttribute: false,
createError: null,
createAttributeError: null,
}
},
created () {
@ -199,7 +194,6 @@ export default {
create (event) {
const { _id, firstName, lastName, email, password, attributes } = this.newUser;
const data = {firstName, lastName, email, password, attributes};
this.createError = null;
if (_id && this.editing) {
api.req('PUT', `/idps/${this.idp._id}/users/${_id}`, data, resp => {
this.users.map(u => {
@ -208,22 +202,17 @@ export default {
});
this.dialog = false;
this.editing = false;
}, err => {
this.createError = err.message;
});
}, err => console.log(err));
} else {
api.req('POST', `/idps/${this.idp._id}/users`, data, resp => {
this.users.push(resp);
this.dialog = false;
}, err => {
this.createError = err.message;
});
}, err => console.log(err));
}
},
createAttribute() {
const { _id, name, defaultValue, samlMapping } = this.newAttribute;
const data = {name, defaultValue, samlMapping};
this.createAttributeError = null;
if (_id && this.editingAttribute) {
api.req('PUT', `/idps/${this.idp._id}/attributes/${_id}`, data, resp => {
this.attributes.map(u => {
@ -232,16 +221,12 @@ export default {
});
this.attributeDialog = false;
this.editingAttribute = false;
}, err => {
this.createAttributeError = err.message;
});
}, err => console.log(err));
} else {
api.req('POST', `/idps/${this.idp._id}/attributes`, data, resp => {
this.attributes.push(resp);
this.attributeDialog = false;
}, err => {
this.createAttributeError = err.message;
});
}, err => console.log(err));
}
},
editUser(user) {

View File

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

View File

@ -7,70 +7,69 @@ import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
import '@mdi/font/css/materialdesignicons.css'
import * as Sentry from '@sentry/vue'
import App from './App.vue'
import Home from './components/Home.vue'
import ResetPassword from './components/ResetPassword.vue'
import Account from './components/Account.vue'
import Home from './components/Home.vue';
import ResetPassword from './components/ResetPassword.vue';
import Account from './components/Account.vue';
import Dashboard from './components/Dashboard.vue'
import Root from './components/Root.vue'
import NewIDP from './components/NewIDP.vue'
import IDP from './components/IDP.vue'
import IDPHome from './components/IdpHome.vue'
import IDPUsers from './components/IdpUsers.vue'
import IDPSettings from './components/IdpSettings.vue'
import IDPSPs from './components/IdpSps.vue'
import IDPSAML from './components/IdpSaml.vue'
import IDPSAMLLogs from './components/IdpSamlLogs.vue'
import IDPOAuth from './components/IdpOauth.vue'
import IDPOAuthGuide from './components/IdpOauthGuide.vue'
import IDPSaml2Guide from './components/IdpSaml2Guide.vue'
import IDPOAuthLogs from './components/IdpOauthLogs.vue'
import GuideLayout from './components/Guide.vue'
import IDP from './components/IDP.vue';
import IDPHome from './components/IdpHome.vue';
import IDPUsers from './components/IdpUsers.vue';
import IDPSettings from './components/IdpSettings.vue';
import IDPSPs from './components/IdpSps.vue';
import IDPSAML from './components/IdpSaml.vue';
import IDPSAMLLogs from './components/IdpSamlLogs.vue';
import IDPOAuth from './components/IdpOauth.vue';
import IDPOAuthGuide from './components/IdpOauthGuide.vue';
import IDPSaml2Guide from './components/IdpSaml2Guide.vue';
import IDPOAuthLogs from './components/IdpOauthLogs.vue';
import GuideLayout from './components/Guide.vue';
import PrivacyPolicy from './components/legal/PrivacyPolicy.vue'
import TermsOfUse from './components/legal/TermsOfUse.vue'
import PrivacyPolicy from './components/legal/PrivacyPolicy.vue';
import TermsOfUse from './components/legal/TermsOfUse.vue';
const store = createStore({
state: {
loggedIn: false,
user: null,
registerOpen: false,
loginOpen: false
loginOpen: false,
},
mutations: {
login (state, loggedIn) {
state.loggedIn = loggedIn
state.loggedIn = loggedIn;
},
setUser (state, user) {
state.user = user
state.user = user;
if (user && window.drift && window.drift.identify) {
window.drift.identify(user._id, {
email: user.email,
firstName: user.firstName
})
firstName: user.firstName,
});
}
if (!user && window.drift && window.drift.reset) {
window.drift.reset()
window.drift.reset();
}
},
updateProfile (state, profile) {
state.user = Object.assign({}, state.user, profile)
state.user = Object.assign({}, state.user, profile);
},
openRegister (state, open) {
state.registerOpen = open
state.registerOpen = open;
},
openLogin (state, open) {
state.loginOpen = open
}
state.loginOpen = open;
},
}
})
const router = createRouter({
scrollBehavior() {
return { left: 0, top: 0 }
return { left: 0, top: 0 };
},
history: createWebHistory(),
routes: [
@ -80,19 +79,12 @@ const router = createRouter({
{ path: '/account', component: Account },
{ path: '/password/reset', component: ResetPassword },
{ path: '/dashboard', component: Dashboard },
{
path: '/guides',
component: GuideLayout,
children: [
{ path: '/guides', component: GuideLayout, children: [
{ path: 'oauth2', component: IDPOAuthGuide },
{ path: 'saml2', component: IDPSaml2Guide }
]
},
{ path: 'saml2', component: IDPSaml2Guide },
] },
{ path: '/idps/new', component: NewIDP },
{
path: '/idps/:id',
component: IDP,
children: [
{ path: '/idps/:id', component: IDP, children: [
{ path: '', component: IDPHome },
{ path: 'users', component: IDPUsers },
{ path: 'settings', component: IDPSettings },
@ -102,10 +94,9 @@ const router = createRouter({
{ path: 'saml/logs', component: IDPSAMLLogs },
{ path: 'oauth', component: IDPOAuth },
{ path: 'oauth/guide', component: IDPOAuthGuide },
{ path: 'oauth/logs', component: IDPOAuthLogs }
]
},
{ path: '/root', component: Root }
{ path: 'oauth/logs', component: IDPOAuthLogs },
] },
{ path: '/root', component: Root },
]
})
@ -116,25 +107,12 @@ const vuetify = createVuetify({
defaultSet: 'mdi',
aliases,
sets: {
mdi
}
mdi,
}
},
})
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(vuetify)
app.use(router)

View File

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

File diff suppressed because it is too large Load Diff