Compare commits

...

17 Commits

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

2
.gitignore vendored Normal file
View File

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

View File

@ -1,4 +1,4 @@
pipeline:
steps:
buildweb:
group: build
image: node
@ -43,5 +43,6 @@ pipeline:
- s3cmd --configure --access_key=$LINODE_ACCESS_KEY --secret_key=$LINODE_SECRET_ACCESS_KEY --host=https://eu-central-1.linodeobjects.com --host-bucket="%(bucket)s.eu-central-1.linodeobjects.com" --dump-config > /root/.s3cfg
- 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'
branches: main
when:
branch: main

View File

@ -1,4 +1,4 @@
FROM python:3.9-slim-buster
FROM python:3.12-slim-bookworm
# set work directory
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 .venv # You only need this the first time
virtualenv -p python3.12 .venv # You only need this the first time
source .venv/bin/activate
```
@ -33,4 +33,4 @@ flask run
**Note:** The service expects to be able to connect to a local MongoDB running on port 27017.
Once running, the service is available on port 6002.
Once running, the service is available on port 6002.

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

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

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

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

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

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

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

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

View File

@ -1,148 +1,349 @@
import sentry_sdk
from flask import Flask, jsonify, request
from flask_cors import CORS
from flask_limiter import Limiter
import werkzeug
from chalicelib.util import util
from chalicelib.api import accounts, users, idps, root
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,
)
app = Flask(__name__)
CORS(app)
limiter = Limiter(app, default_limits=['20 per minute'], key_func=util.limit_by_user)
limiter = Limiter(util.limit_by_user, app=app, default_limits=["20 per minute"])
@app.errorhandler(werkzeug.exceptions.TooManyRequests)
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
return jsonify({"message": e.description}), 404
@app.errorhandler(werkzeug.exceptions.UnprocessableEntity)
def handle_unprocessable_entity(e):
validation_errors = e.data.get("messages")
message = ""
def build_message(message, d):
if not d:
return message
for key in d:
if isinstance(d[key], dict):
message += f"""{str(key)}: """
return build_message(message, d[key])
elif isinstance(d[key], list):
message += f"""{str(key)}: {', '.join(d[key])}\n"""
return message
if validation_errors:
message = build_message("", validation_errors.get("json"))
return jsonify(
{
"message": message,
"validations": validation_errors,
}
), 422
# ACCOUNTS
@app.route('/accounts', methods=['POST'])
@limiter.limit('5 per minute', key_func=util.limit_by_client, methods=['POST'])
def register():
return util.jsonify(accounts.create(request.json))
@app.route('/accounts/enrol', methods=['POST'])
def enrol():
return util.jsonify(accounts.enrol(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/sessions', methods=['POST'])
@limiter.limit('5 per minute', key_func=util.limit_by_client, methods=['POST'])
def login():
return util.jsonify(accounts.login(request.json))
@app.route('/accounts/sessions', methods=['DELETE'])
@app.route("/accounts/enrol", methods=["POST"])
@limiter.limit("5 per minute", key_func=util.limit_by_client, methods=["POST"])
@use_args({"token": fields.Str(), "password": fields.Str(required=True)})
def enrol(args):
return util.jsonify(accounts.enrol(args))
@app.route("/accounts/sessions", methods=["POST"])
@limiter.limit("5 per minute", key_func=util.limit_by_client, methods=["POST"])
@use_args(
{
"email": fields.Email(required=True),
"password": fields.Str(required=True),
"idpsToClaim": fields.List(fields.Str(), missing=[], allow_none=True),
}
)
def login(args):
return util.jsonify(accounts.login(args))
@app.route("/accounts/sessions", methods=["DELETE"])
def logout():
return util.jsonify(accounts.logout(util.get_user()))
return util.jsonify(accounts.logout(util.get_user()))
@app.route('/accounts', methods=['DELETE'])
def delete_account():
body = request.json
return util.jsonify(accounts.delete(util.get_user(), body.get('password')))
@app.route('/accounts/password', methods=['PUT'])
@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", 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/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'])
def users_me():
return util.jsonify(users.me(util.get_user()))
@app.route('/users/<id>', methods=['PUT'])
def user_route(id):
return util.jsonify(users.update(util.get_user(), id, request.json))
@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))
# IDPs
@app.route('/idps', methods=['GET', 'POST'])
def idps_route():
if request.method == 'POST':
return util.jsonify(idps.create(util.get_user(required=False), request.json))
if request.method == 'GET':
params = request.args or {}
return util.jsonify(idps.get(util.get_user(required=False), params.get('include')))
@app.route('/idps/<id>', methods=['GET', 'PUT', 'DELETE'])
@app.route("/idps", methods=["GET"])
@use_args({"include": fields.Str(missing="")}, location="query")
def idps_route_get(args):
return util.jsonify(idps.get(util.get_user(required=False), args.get("include")))
@app.route("/idps", methods=["POST"])
@use_args(
{
"code": fields.Str(required=True, validate=validate.Length(min=2)),
"name": fields.Str(required=True, validate=validate.Length(min=2)),
}
)
def idps_route_post(args):
return util.jsonify(idps.create(util.get_user(required=False), args))
@app.route("/idps/<id>", methods=["GET", "DELETE"])
def idp_route(id):
if request.method == 'GET':
return util.jsonify(idps.get_one(util.get_user(required=False), id))
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))
if request.method == "GET":
return util.jsonify(idps.get_one(util.get_user(required=False), id))
if request.method == "DELETE":
return util.jsonify(idps.delete(util.get_user(required=False), id))
@app.route('/idps/<id>/sps', methods=['GET', 'POST'])
def idp_sps_route(id):
if request.method == 'GET':
@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):
return util.jsonify(idps.get_sps(util.get_user(required=False), id))
if request.method == 'POST':
return util.jsonify(idps.create_sp(util.get_user(required=False), id, request.json))
@app.route('/idps/<id>/sps/<sp_id>', methods=['PUT', 'DELETE'])
@app.route("/idps/<id>/sps", methods=["POST"])
@use_args(
{
"name": fields.Str(required=True, validate=validate.Length(min=2)),
"entityId": fields.Str(),
"serviceUrl": fields.Str(),
"callbackUrl": fields.Str(),
"logoutUrl": fields.Str(),
"logoutCallbackUrl": fields.Str(),
"oauth2RedirectUri": fields.Str(),
}
)
def idp_sps_route_post(args, id):
return util.jsonify(idps.create_sp(util.get_user(required=False), id, args))
@app.route("/idps/<id>/sps/<sp_id>", methods=["PUT"])
@use_args(
{
"name": fields.Str(required=True, validate=validate.Length(min=2)),
"entityId": fields.Str(),
"serviceUrl": fields.Str(),
"callbackUrl": fields.Str(),
"logoutUrl": fields.Str(),
"logoutCallbackUrl": fields.Str(),
"oauth2RedirectUri": fields.Str(),
}
)
def idp_sp_route_put(args, id, sp_id):
return util.jsonify(idps.update_sp(util.get_user(required=False), id, sp_id, args))
@app.route("/idps/<id>/sps/<sp_id>", methods=["DELETE"])
def idp_sp_route(id, sp_id):
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', 'POST'])
def idp_users_route(id):
if request.method == 'GET':
@app.route("/idps/<id>/users", methods=["GET"])
def idp_users_route_get(id):
return util.jsonify(idps.get_users(util.get_user(required=False), id))
if request.method == 'POST':
return util.jsonify(idps.create_user(util.get_user(required=False), id, request.json))
@app.route('/idps/<id>/users/<user_id>', methods=['PUT', 'DELETE'])
def idp_user_route(id, user_id):
if request.method == 'DELETE':
@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):
return util.jsonify(idps.delete_user(util.get_user(required=False), id, user_id))
if request.method == 'PUT':
return util.jsonify(idps.update_user(util.get_user(required=False), id, user_id, request.json))
@app.route('/idps/<id>/attributes', methods=['GET', 'POST'])
def idp_attributes_route(id):
if request.method == 'GET':
@app.route("/idps/<id>/attributes", methods=["GET"])
def idp_attributes_route_get(id):
return util.jsonify(idps.get_attributes(util.get_user(required=False), id))
if request.method == 'POST':
return util.jsonify(idps.create_attribute(util.get_user(required=False), id, request.json))
@app.route('/idps/<id>/attributes/<attr_id>', methods=['PUT', 'DELETE'])
def idp_attribute_route(id, attr_id):
if request.method == 'DELETE':
return util.jsonify(idps.delete_attribute(util.get_user(required=False), id, attr_id))
if request.method == 'PUT':
return util.jsonify(idps.update_attribute(util.get_user(required=False), id, attr_id, request.json))
@app.route('/idps/<id>/saml2/logs', methods=['GET'])
@app.route("/idps/<id>/attributes", methods=["POST"])
@use_args(
{
"name": fields.Str(required=True, validate=validate.Length(min=2)),
"defaultValue": fields.Str(),
"samlMapping": fields.Str(),
}
)
def idp_attributes_route_post(args, id):
return util.jsonify(idps.create_attribute(util.get_user(required=False), id, args))
@app.route("/idps/<id>/attributes/<attr_id>", methods=["PUT"])
@use_args(
{
"name": fields.Str(required=True, validate=validate.Length(min=2)),
"defaultValue": fields.Str(),
"samlMapping": fields.Str(),
}
)
def idp_attribute_route_put(args, id, attr_id):
return util.jsonify(
idps.update_attribute(util.get_user(required=False), id, attr_id, args)
)
@app.route("/idps/<id>/attributes/<attr_id>", methods=["DELETE"])
def idp_attribute_route_delete(id, attr_id):
return util.jsonify(
idps.delete_attribute(util.get_user(required=False), id, attr_id)
)
@app.route("/idps/<id>/saml2/logs", methods=["GET"])
def idp_saml_logs_route(id):
return util.jsonify(idps.get_logs(util.get_user(required=False), id))
return util.jsonify(idps.get_logs(util.get_user(required=False), id))
@app.route('/idps/<id>/oauth2/logs', methods=['GET'])
@app.route("/idps/<id>/oauth2/logs", methods=["GET"])
def idp_oauth_logs_route(id):
return util.jsonify(idps.get_oauth_logs(util.get_user(required=False), id))
return util.jsonify(idps.get_oauth_logs(util.get_user(required=False), id))
# Root
@app.route('/root/users', methods=['GET'])
@app.route("/root/users", methods=["GET"])
def root_users():
return util.jsonify(root.get_users(util.get_user()))
return util.jsonify(root.get_users(util.get_user()))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4
api/lint.sh Executable file
View File

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

1477
api/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,23 +3,29 @@ name = "ssotools-api"
version = "0.1.0"
description = ""
authors = ["Will Webberley <will@sso.tools>"]
package-mode = false
[tool.poetry.dependencies]
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"
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"
[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"

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

@ -1,19 +1,19 @@
const { MongoClient } = require('mongodb');
import { MongoClient } from '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)
}
};
}
module.exports = database;
export default database

View File

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

File diff suppressed because it is too large Load Diff

11
idp/instrument.js Normal file
View File

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

View File

@ -1,31 +1,28 @@
{
"name": "ssotoolsidp",
"version": "1.0.0",
"scripts": {},
"type": "module",
"scripts": {
"start": "nodemon index.js",
"lint": "standard --fix"
},
"dependencies": {
"async": "^3.2.4",
"@sentry/node": "^8.33.1",
"@sentry/profiling-node": "^8.33.1",
"bcryptjs": "^2.4.3",
"body-parser": "^1.19.0",
"cookie-parser": "^1.4.5",
"body-parser": "^1.20.3",
"cookie-parser": "^1.4.6",
"crypto": "^1.0.1",
"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",
"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",
"zlib": "^1.0.5"
},
"devDependencies": {
"nodemon": "^2.0.7"
"nodemon": "^3.1.7",
"standard": "^17.1.2"
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -142,6 +142,10 @@
<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>
@ -175,6 +179,7 @@ export default {
showPassword: false,
forgottenPasswordOpen: false,
resettingPassword: false,
resettingPasswordError: null
}
},
computed: {
@ -280,8 +285,9 @@ 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 XMLHttpRequest();
xhr.open(method, `${hosts[process.env.NODE_ENV]}${path}`);
xhr.setRequestHeader('Content-Type', 'application/json');
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')
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);
unauthenticatedRequest (method, path, data, success, fail, 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);
},
};
authenticatedRequest (method, path, data, success, fail, options) {
api.req(method, path, data, success, fail, true, options)
}
}
export default api;
export default api

View File

@ -8,6 +8,8 @@
<p>A globally unique machine-friendly (alphanumeric) code to identify this IDP on SSO Tools. We derive the issuer and other SSO information from this code, so you may need to update other configurations elsewhere if you do change it.</p>
<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>
@ -24,6 +26,7 @@ export default {
return {
snackbar: false,
saving: false,
errorMessage: null
}
},
methods: {
@ -31,12 +34,14 @@ 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,6 +72,8 @@
<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>
@ -98,6 +100,7 @@ export default {
newSP: { name: '', entityId: '', serviceUrl: '', callbackUrl: '', logoutUrl: '', logoutCallbackUrl: '', oauth2RedirectUri: ''},
dialog: false,
editing: false,
createError: null,
}
},
created () {
@ -116,6 +119,7 @@ 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 => {
@ -124,12 +128,16 @@ export default {
});
this.dialog = false;
this.editing = false;
}, err => console.log(err));
}, err => {
this.createError = err.message;
});
} else {
api.req('POST', `/idps/${this.idp._id}/sps`, data, resp => {
this.sps.push(resp);
this.dialog = false;
}, err => console.log(err));
}, err => {
this.createError = err.message;
});
}
},
editSp(sp) {

View File

@ -68,6 +68,7 @@
</div>
</div>
</div>
<v-alert v-if="createError" type="error" dismissible>{{createError}}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
@ -132,6 +133,8 @@
<v-text-field class="mb-2" label="Attribute key" v-model="newAttribute.samlMapping" hint="Values for this attribute will be sent with this key during the SSO process." />
<v-text-field 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>
@ -166,6 +169,8 @@ export default {
editing: false,
attributeDialog: false,
editingAttribute: false,
createError: null,
createAttributeError: null,
}
},
created () {
@ -194,6 +199,7 @@ 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 => {
@ -202,17 +208,22 @@ export default {
});
this.dialog = false;
this.editing = false;
}, err => console.log(err));
}, err => {
this.createError = err.message;
});
} else {
api.req('POST', `/idps/${this.idp._id}/users`, data, resp => {
this.users.push(resp);
this.dialog = false;
}, err => console.log(err));
}, err => {
this.createError = err.message;
});
}
},
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 => {
@ -221,12 +232,16 @@ export default {
});
this.attributeDialog = false;
this.editingAttribute = false;
}, err => console.log(err));
}, err => {
this.createAttributeError = err.message;
});
} else {
api.req('POST', `/idps/${this.idp._id}/attributes`, data, resp => {
this.attributes.push(resp);
this.attributeDialog = false;
}, err => console.log(err));
}, err => {
this.createAttributeError = err.message;
});
}
},
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 :value="error" type="error">
<v-alert v-if="error" type="error" class="mt-5 mb-5">
<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 :value="success" type="success">
<v-alert v-if="success" type="success" class="mt-5">
<h4>Password updated successfully</h4>
<p>When you're ready, you can login with your new password.</p>
<v-btn color="primary" @click="e => $store.commit('openLogin', true)">Login</v-btn>
<v-btn color="primary" class="mt-2" @click="e => $store.commit('openLogin', true)">Login</v-btn>
</v-alert>
</v-container>
</template>

View File

@ -7,69 +7,70 @@ 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 };
scrollBehavior () {
return { left: 0, top: 0 }
},
history: createWebHistory(),
routes: [
@ -79,24 +80,32 @@ const router = createRouter({
{ path: '/account', component: Account },
{ path: '/password/reset', component: ResetPassword },
{ path: '/dashboard', component: Dashboard },
{ path: '/guides', component: GuideLayout, children: [
{ path: 'oauth2', component: IDPOAuthGuide },
{ path: 'saml2', component: IDPSaml2Guide },
] },
{
path: '/guides',
component: GuideLayout,
children: [
{ path: 'oauth2', component: IDPOAuthGuide },
{ path: 'saml2', component: IDPSaml2Guide }
]
},
{ path: '/idps/new', component: NewIDP },
{ path: '/idps/:id', component: IDP, children: [
{ path: '', component: IDPHome },
{ path: 'users', component: IDPUsers },
{ path: 'settings', component: IDPSettings },
{ path: 'sps', component: IDPSPs },
{ path: 'saml', component: IDPSAML },
{ path: 'saml/guide', component: IDPSaml2Guide },
{ path: 'saml/logs', component: IDPSAMLLogs },
{ path: 'oauth', component: IDPOAuth },
{ path: 'oauth/guide', component: IDPOAuthGuide },
{ path: 'oauth/logs', component: IDPOAuthLogs },
] },
{ path: '/root', component: Root },
{
path: '/idps/:id',
component: IDP,
children: [
{ path: '', component: IDPHome },
{ path: 'users', component: IDPUsers },
{ path: 'settings', component: IDPSettings },
{ path: 'sps', component: IDPSPs },
{ path: 'saml', component: IDPSAML },
{ path: 'saml/guide', component: IDPSaml2Guide },
{ path: 'saml/logs', component: IDPSAMLLogs },
{ path: 'oauth', component: IDPOAuth },
{ path: 'oauth/guide', component: IDPOAuthGuide },
{ path: 'oauth/logs', component: IDPOAuthLogs }
]
},
{ path: '/root', component: Root }
]
})
@ -107,13 +116,26 @@ 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)
app.mount('#app')
app.mount('#app')

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;
hasPermission (user, permission, scope) {
if (!user?.permissions || !permission) return false
return user.permissions[scope || 'global']?.indexOf(permission) > -1
}
}
}

View File

@ -2,4 +2,4 @@ import vue from '@vitejs/plugin-vue'
export default {
plugins: [vue()]
}
}

File diff suppressed because it is too large Load Diff