Compare commits
17 Commits
cf02c3a769
...
b06757fb03
Author | SHA1 | Date | |
---|---|---|---|
b06757fb03 | |||
cc3d279228 | |||
6e21ebf600 | |||
aedd56c223 | |||
36f6d8e8d4 | |||
0c46114d9e | |||
4119c30ba8 | |||
16990fb3a6 | |||
0a6a75b08e | |||
1be4f33d8c | |||
4f6b652660 | |||
64b124b37e | |||
cbf07017e2 | |||
565daa6647 | |||
f8ebd209cf | |||
0f47a123ae | |||
607b469625 |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*.sw*
|
||||
.DS_Store
|
@ -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
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM python:3.9-slim-buster
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
# set work directory
|
||||
WORKDIR /app
|
||||
|
@ -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
223
api/api/accounts.py
Normal 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
502
api/api/idps.py
Normal 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
33
api/api/root.py
Normal 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
60
api/api/users.py
Normal 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)
|
385
api/app.py
385
api/app.py
@ -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()))
|
||||
|
@ -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}
|
@ -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}
|
@ -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}
|
@ -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)
|
@ -1,3 +0,0 @@
|
||||
import werkzeug
|
||||
|
||||
errors = werkzeug.exceptions
|
@ -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
|
@ -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)
|
||||
|
||||
|
@ -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
4
api/lint.sh
Executable file
@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
|
||||
ruff format .
|
||||
ruff check --fix .
|
1477
api/poetry.lock
generated
1477
api/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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
3
api/util/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
import werkzeug
|
||||
|
||||
errors = werkzeug.exceptions
|
11
api/util/database.py
Normal file
11
api/util/database.py
Normal 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
28
api/util/mail.py
Normal 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
80
api/util/util.py
Normal 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)
|
@ -1,4 +1,4 @@
|
||||
FROM node:19
|
||||
FROM node:20
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /usr/src/app
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
444
idp/idp.js
444
idp/idp.js
@ -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
|
||||
|
791
idp/index.js
791
idp/index.js
File diff suppressed because it is too large
Load Diff
11
idp/instrument.js
Normal file
11
idp/instrument.js
Normal 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
|
||||
})
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
3515
idp/yarn.lock
3515
idp/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -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%",
|
||||
|
@ -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;
|
||||
});
|
||||
},
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
});
|
||||
},
|
||||
},
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
120
web/src/main.js
120
web/src/main.js
@ -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')
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,4 +2,4 @@ import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default {
|
||||
plugins: [vue()]
|
||||
}
|
||||
}
|
||||
|
2650
web/yarn.lock
2650
web/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user