diff --git a/docs/conf_sso_test_msscolab.rst b/docs/conf_sso_test_msscolab.rst
index 1927640e5..0a3d51810 100644
--- a/docs/conf_sso_test_msscolab.rst
+++ b/docs/conf_sso_test_msscolab.rst
@@ -8,7 +8,10 @@ Testing IDP (`mslib/msidp`) is specifically designed for testing the Single Sign
Here is documentation that explains the configuration of the MSS Colab Server with the testing IdP.
.. warning::
- When running publicly rather than in development, you should not use the built-in development server ( msidp / idp.py ).
+ msidp is for development and tests only. Never run it where others can reach it, and never let an MSColab
+ server that others use trust it: its users and passwords are the ones in ``mslib/msidp/idp_user.py``, which
+ everyone knows, and it lacks protections of a real identity provider. For example it accepts unsigned requests,
+ has no CSRF protection on its login form and redirects to any ``redirect_uri``. It prints this warning on start.
The development server is provided by MSS for convenience, but is not designed to be particularly efficient, stable, or secure.
@@ -127,5 +130,7 @@ When migrations finished, you can start mscolab server using the following comm
$ msui
-* Login with identity provider through Qt Client application.
+* Login with identity provider through Qt Client application. After the login at msidp the browser shows a
+ one-time code, enter it in msui. If an MSColab account with the email of the msidp user exists already, msui asks
+ for its password once to link the msidp login to it.
* To log in to the mscolab server through the identity provider, you can use the credentials specified in the ``USERS`` and ``PASSWD`` section of the ``MSS/mslib/msidp/idp_user.py`` file. Look for the relevant section in the file to find the necessary login credentials.
diff --git a/docs/samples/config/mscolab/mss_saml2_backend.yaml.sample b/docs/samples/config/mscolab/mss_saml2_backend.yaml.sample
index 09711f2ac..7b7c1e159 100644
--- a/docs/samples/config/mscolab/mss_saml2_backend.yaml.sample
+++ b/docs/samples/config/mscolab/mss_saml2_backend.yaml.sample
@@ -52,7 +52,8 @@ config:
want_response_signed: true
want_assertion_signed: true
allow_unknown_attributes: true
- allow_unsolicited: true
+ # only responses to a login request of this server
+ allow_unsolicited: false
endpoints:
assertion_consumer_service:
- [http://localhost:8083/localhost_test_idp/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST']
@@ -106,7 +107,7 @@ config:
# want_response_signed: true
# want_assertion_signed: true
# allow_unknown_attributes: true
- # allow_unsolicited: true
+ # allow_unsolicited: false
# endpoints:
# assertion_consumer_service:
# - [http://localhost:8083/idp2/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST']
diff --git a/docs/samples/config/mscolab/setup_saml2_backend.py.sample b/docs/samples/config/mscolab/setup_saml2_backend.py.sample
index edc4a8963..3c47f81d7 100644
--- a/docs/samples/config/mscolab/setup_saml2_backend.py.sample
+++ b/docs/samples/config/mscolab/setup_saml2_backend.py.sample
@@ -17,6 +17,9 @@ class setup_saml2_backend:
'idp_identity_name': 'localhost_test_idp', # make sure to use underscore for the blanks
'idp_data': {
'idp_name': 'Testing Identity Provider', # this name is used on the Login page to connect to the Provider.
+ # the attribute that identifies a user: it must not change and must never be given to someone else.
+ # Without it the IdP must send a persistent NameID.
+ 'user_id_attribute': 'eduPersonPrincipalName',
}
},
diff --git a/docs/sso_via_saml_mscolab.rst b/docs/sso_via_saml_mscolab.rst
index c531ff305..3811038f5 100644
--- a/docs/sso_via_saml_mscolab.rst
+++ b/docs/sso_via_saml_mscolab.rst
@@ -102,6 +102,7 @@ In this implementation, as we are enabling only one IdP, there is no need to con
'idp_identity_name': 'localhost_test_idp', # make sure to use underscore for the blanks
'idp_data': {
'idp_name': 'Testing Identity Provider', # this name is used on the Login page to connect to the Provider
+ 'user_id_attribute': 'eduPersonPrincipalName', # the attribute that identifies a user
}
},
]
@@ -116,6 +117,12 @@ In this implementation, as we are enabling only one IdP, there is no need to con
Idp_name refers to the name of the Identity Provider that will be displayed in the MSColab server web interface for end users to select when configuring SSO.
+ User_id_attribute is the SAML attribute that identifies a user of this IdP, e.g. ``eduPersonPrincipalName``
+ or ``urn:oasis:names:tc:SAML:attribute:subject-id``. Its value must not change and must never be given to
+ someone else. Without ``user_id_attribute`` the IdP must send a persistent NameID
+ (``urn:oasis:names:tc:SAML:2.0:nameid-format:persistent``). MSColab refuses a login without such an
+ identifier. The email address is no identifier: it can change, and an IdP can assert any address.
+
Establish pysaml2, Saml2Client for the MSColab server
#####################################################
@@ -189,7 +196,7 @@ Please refer the yaml file template (`mss_saml2_backend.yaml.sample`) in the dir
want_response_signed: true
want_assertion_signed: true
allow_unknown_attributes: true
- allow_unsolicited: true
+ allow_unsolicited: false
endpoints:
assertion_consumer_service:
- [http://localhost:8083/idp2/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST']
@@ -221,7 +228,48 @@ While the core purpose of IdPs is to authenticate users and provide information
Guide to IDP Configuration
--------------------------
-In the SSO process through the MSColab server, the username is obtained as ``givenName``, and the email address is obtained as ``email``. Therefore, when configuring the IdP, it is necessary to configure it accordingly to ensure the correct return of the givenName attribute and the email address along with the SAML response.
+In the SSO process through the MSColab server, the username is obtained as ``givenName`` (or ``displayName``), and the email address is obtained as ``email`` (or ``mail``). Therefore, when configuring the IdP, it is necessary to configure it accordingly to ensure the correct return of the givenName attribute and the email address along with the SAML response. The IdP must also send the attribute of ``user_id_attribute`` or a persistent NameID, see above.
+
+How a SAML login finds its account
+----------------------------------
+
+MSColab links an account to SAML identities: the entity id of the IdP together with the value of
+``user_id_attribute`` (or the persistent NameID). A login by a linked identity logs in to its account, also when the
+IdP sends another email address than before.
+
+* The first login by an identity that isn't linked yet gets a new account with the email address and the given name
+ that the IdP sends. The given name becomes the username; if it is taken, contains ``@`` or is longer than 255
+ characters, the part of the email address before the ``@`` does. The browser shows why a login is refused: no valid
+ email address, or both usernames can't be used, or several accounts have the email address in different upper and
+ lower case.
+* If an account with that email address exists already, in any upper and lower case, the login is refused until its
+ owner links the identity: msui asks for the password of that account once and links the identity right after the
+ password login.
+* Accounts of MSColab before 12.0.0 that logged in by SAML have no linked identity yet. Such an account is linked on
+ its next login by email, if it logged in by the same configured IdP before and the response comes from the IdP that
+ MSColab sent the login request to. A response of another IdP of the same metadata, e.g. of a federation, needs the
+ link by the owner, like any other existing account.
+* With ``MAIL_ENABLED`` an account whose email address isn't confirmed can't log in by SAML either; msui tells so, and
+ the code works again once the address is confirmed.
+* A SAML login never changes the password of an account. Accounts created by a SAML login have a random password; their
+ owners can set one by a password reset. A password reset unlinks the SAML identities of the account: whoever resets
+ the password owns the email address. The next SAML login links the identity again, with the new password.
+* Without ``user_id_attribute`` an IdP must send a persistent NameID. The server warns at its start if neither is set
+ up for a configured IdP: every SAML login by it would be refused.
+
+The login in msui
+-----------------
+
+After the login at the IdP the browser shows a one-time code. The user enters it in msui, which exchanges it for a
+login token. The code works once and for 20 minutes; the IdP has to answer the login request within 10 minutes. The
+server accepts only responses to login requests it sent, each once, so ``allow_unsolicited`` must be ``false``; a
+login started at the IdP (IdP-initiated) doesn't work. The requests are stored in the database, so every process of
+the server accepts the response. Anyone can start a login, so the server keeps at most 1000 unfinished ones; further
+logins are refused with "503 Service Unavailable" until some of them expire. With ``ENABLE_BASIC_HTTP_AUTHENTICATION``
+the browser needs the basic authentication of the server to start a login, and msui to exchange the code, as for a
+password login. An assertion that isn't signed counts only if the signed response around it comes from the same IdP.
+msui 12.0.0 also logs in by SAML to servers before 12.0.0; msui before 12.0.0 can't log in by SAML to a 12.0.0
+server.
3. Configuration example through Keycloak 13.0.1
@@ -469,6 +517,10 @@ Configuration in MSColab settings for Keycloak
.. note::
Make sure to insert idp_identity_name as above ('key_cloak_v_13'), which used in this example.
+ This example has no ``user_id_attribute``: set the Name ID Format of the client in Keycloak to
+ ``persistent`` and ``name_id_format`` in `mss_saml2_backend.yaml` to
+ ``urn:oasis:names:tc:SAML:2.0:nameid-format:persistent``, then the NameID identifies the user.
+
Configuration mss_saml2_backend.yaml file
Create your mss_saml2_backend.yaml file in your ``SSO_DIR``.
@@ -528,7 +580,7 @@ Configuration mss_saml2_backend.yaml file
want_response_signed: true
want_assertion_signed: true
allow_unknown_attributes: true
- allow_unsolicited: true
+ allow_unsolicited: false
endpoints:
assertion_consumer_service:
- [http://localhost:8083/keycloak_idp/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST']
diff --git a/mslib/mscolab/CLAUDE.md b/mslib/mscolab/CLAUDE.md
index b0002f8e3..21c79438f 100644
--- a/mslib/mscolab/CLAUDE.md
+++ b/mslib/mscolab/CLAUDE.md
@@ -70,6 +70,13 @@ and `servers-without-qt` contracts in setup.cfg.
`logout_everywhere`) sets a new one and invalidates all earlier tokens. Email-link tokens use
`generate_confirmation_token(email, purpose)` with their own salt per purpose, and
links in emails are built with `public_url_for` from `PUBLIC_URL`, never `_external=True`.
+- A SAML login is accepted by the linked `IdpIdentity` (IdP entity id + configured `user_id_attribute` or
+ persistent NameID), never by email alone, and never changes a password. Only responses to a request in
+ `saml_logins` are accepted, once, at most `MAX_SAML_LOGINS` open at a time; msui exchanges the one-time code at
+ `idp_login_auth` (see `auth.idp_login`). The issuer is the one of the signed assertion (of an unsigned one only if
+ the signed response has the same issuer). A pre-12 account is linked by email only for the IdP the request went
+ to. The code is used up in the same transaction that creates or links the account, so a failure leaves it valid.
+ The ACS routes are added to the app in `init_saml`: a blueprint can't change after its first registration.
- No module level state that belongs to one app (app, db engine, SocketIO,
HTTPBasicAuth, managers). It lives on the app: `app.config` or
`app.extensions['sockio' | 'cm' | 'fm' | 'basic_auth']`, read via `current_app`.
diff --git a/mslib/mscolab/api/endpoints.py b/mslib/mscolab/api/endpoints.py
index 9cfb240a9..8aa7bef70 100644
--- a/mslib/mscolab/api/endpoints.py
+++ b/mslib/mscolab/api/endpoints.py
@@ -57,5 +57,6 @@
STATUS = "status"
REGISTER = "register"
IDP_LOGIN_AUTH = "idp_login_auth"
+IDP_LINK = "idp_link"
MESSAGES = "messages"
MESSAGE_ATTACHMENT = "message_attachment"
diff --git a/mslib/mscolab/api/schemas.py b/mslib/mscolab/api/schemas.py
index 871ea0ffd..910c2e77b 100644
--- a/mslib/mscolab/api/schemas.py
+++ b/mslib/mscolab/api/schemas.py
@@ -469,9 +469,7 @@ def from_text(cls, text):
@dataclass
class LoginRequest:
- """POST endpoints.TOKEN -- log in (email+password), or complete an IDP
- login by posting the IDP-issued token as the "password".
- """
+ """POST endpoints.TOKEN -- log in (email+password)."""
email: str
password: str
@@ -1096,14 +1094,15 @@ def from_text(cls, text):
@dataclass
class IdpLoginAuthRequest:
- """POST endpoints.IDP_LOGIN_AUTH -- submit the SAML-issued token to
- complete an IDP login.
+ """POST endpoints.IDP_LOGIN_AUTH -- exchange the one-time code that the
+ browser shows after a SAML login for a login token.
Unlike every other route migrated so far, the client sends this as a
real JSON body (`requests.post(url, json=...)`), not form-encoded data,
and the server reads it via `request.get_json()`, not `request.form` --
so this is `to_json_data()`/`from_json_data()`, not
- `to_form_data()`/`from_form()`.
+ `to_form_data()`/`from_form()`. The field is called token for
+ compatibility, it holds the code.
"""
token: str
@@ -1118,21 +1117,22 @@ def from_json_data(cls, data):
@dataclass
class IdpUserInfo:
- """User info embedded in IdpLoginAuthResponse -- a different shape than
- UserInfo (username/id/emailid here, vs. username/id/fullname there; no
- route shares this exact shape with any other migrated so far).
+ """User info embedded in IdpLoginAuthResponse -- UserInfo plus emailid,
+ so that the response is also a LoginResponse for msui's after_login.
"""
username: str
id: int # noqa: A003
emailid: str
+ fullname: str = ""
def to_dict(self):
- return {"username": self.username, "id": self.id, "emailid": self.emailid}
+ return {"username": self.username, "id": self.id, "emailid": self.emailid, "fullname": self.fullname}
@classmethod
def from_dict(cls, data):
- return cls(username=data["username"], id=data["id"], emailid=data["emailid"])
+ return cls(username=data["username"], id=data["id"], emailid=data["emailid"],
+ fullname=data.get("fullname") or "")
@dataclass
@@ -1141,30 +1141,86 @@ class IdpLoginAuthResponse:
The success path returns via a bare json.dumps (text/html Content-Type,
same pattern as create_operation/get_operation_by_id) with
- {"success": True, "token": ..., "user": {...}}. Every failure path
- (invalid/expired token, no matching user, or a malformed request
- raising TypeError) returns via flask.jsonify (application/json) with
- just {"success": False} and HTTP 401 -- to_dict() lets the handler keep
- using jsonify() for that path without silently changing its
- Content-Type, mirroring DeleteOperationResponse's to_dict()/to_text()
- split.
+ {"success": True, "token": ..., "user": {...}}; token is the login token,
+ the same as the one of POST endpoints.TOKEN. Every failure path returns
+ via flask.jsonify (application/json) with {"success": False}: HTTP 401
+ for an invalid, expired or used code, HTTP 409 with link_required and
+ the emailid of the account if the SAML identity isn't linked to an
+ account yet and its email belongs to one -- the user logs in to that
+ account and links the identity by POST endpoints.IDP_LINK with the same
+ code. HTTP 403 with a message for the user if the login is refused,
+ e.g. for an account whose email isn't confirmed. to_dict() lets the
+ handler keep using jsonify() for these paths.
+
+ Servers before 12.0.0 answer success with a one-time password in token;
+ a 12.0.0 server marks its login token with "login_token": true.
+ legacy_password, a success without that mark, tells msui to log in with
+ the password at endpoints.TOKEN, as the older servers expect.
"""
success: bool
token: Optional[str] = None
user: Optional[IdpUserInfo] = None
+ link_required: bool = False
+ emailid: Optional[str] = None
+ message: Optional[str] = None
+ legacy_password: bool = False
def to_text(self):
- return json.dumps({"success": self.success, "token": self.token, "user": self.user.to_dict()})
+ return json.dumps({"success": self.success, "token": self.token, "login_token": True,
+ "user": self.user.to_dict()})
def to_dict(self):
- return {"success": self.success}
+ data = {"success": self.success}
+ if self.link_required:
+ data.update(link_required=True, emailid=self.emailid)
+ if self.message is not None:
+ data.update(message=self.message)
+ return data
@classmethod
def from_text(cls, text):
data = json.loads(text)
- user = IdpUserInfo.from_dict(data["user"]) if "user" in data else None
- return cls(success=data["success"], token=data.get("token"), user=user)
+ user = IdpUserInfo.from_dict(data["user"]) if data.get("user") else None
+ return cls(success=data["success"], token=data.get("token"), user=user,
+ link_required=bool(data.get("link_required", False)), emailid=data.get("emailid"),
+ message=data.get("message"),
+ legacy_password=bool(data.get("success")) and data.get("login_token") is not True)
+
+
+@dataclass
+class IdpLinkRequest:
+ """POST endpoints.IDP_LINK -- link the SAML identity of a one-time code,
+ which endpoints.IDP_LOGIN_AUTH refused with link_required, to the logged
+ in user (token is the login token, as for every other route).
+ """
+
+ token: str
+ code: str
+
+ def to_form_data(self):
+ return {"token": self.token, "code": self.code}
+
+ @classmethod
+ def from_form(cls, form):
+ return cls(token=form.get("token"), code=form.get("code"))
+
+
+@dataclass
+class IdpLinkResponse:
+ """Response body for POST endpoints.IDP_LINK (or the body "False" for an
+ invalid login token, as for every route of verify_user)."""
+
+ success: bool
+
+ def to_dict(self):
+ return {"success": self.success}
+
+ @classmethod
+ def from_text(cls, text):
+ if text == AUTH_FAILED_TEXT:
+ return cls(success=False)
+ return cls(success=bool(json.loads(text).get("success")))
@dataclass
diff --git a/mslib/mscolab/app/__init__.py b/mslib/mscolab/app/__init__.py
index 77c5cb9d5..caa198612 100644
--- a/mslib/mscolab/app/__init__.py
+++ b/mslib/mscolab/app/__init__.py
@@ -271,9 +271,10 @@ def repair_schema():
"""
from alembic.migration import MigrationContext
from alembic.operations import Operations
- from mslib.mscolab.migrations.schema_repair import (AUTOINCREMENT_TABLES, add_fullname, add_token_nonce,
- fix_postgresql_sequences, has_autoincrement_ids,
- has_column, has_dangling_references,
+ from mslib.mscolab.migrations.schema_repair import (AUTOINCREMENT_TABLES, add_fullname, add_saml_tables,
+ add_token_nonce, fix_postgresql_sequences,
+ has_autoincrement_ids, has_column,
+ has_dangling_references, has_table,
sqlite_foreign_keys_off, use_autoincrement_ids)
from mslib.mscolab.models import db
@@ -294,6 +295,10 @@ def repair_schema():
logging.warning("The database misses users.token_nonce, it ran an earlier development state of the "
"12.0.0 migration. Adding it now; every user has to log in again.")
add_token_nonce(Operations(MigrationContext.configure(conn)), conn)
+ if not (has_table(conn, "idp_identities") and has_table(conn, "saml_logins")):
+ logging.warning("The database misses the tables of SAML logins, it ran an earlier development state of "
+ "the 12.0.0 migration. Adding them now.")
+ add_saml_tables(Operations(MigrationContext.configure(conn)), conn)
if has_dangling_references(conn):
# Only a database that ran an earlier development state of the 12.0.0 migration, which repairs them:
# a server start doesn't delete rows. Until they are repaired, deleting their user or operation fails.
diff --git a/mslib/mscolab/auth.py b/mslib/mscolab/auth.py
index 2c7312ad0..8f0431b18 100644
--- a/mslib/mscolab/auth.py
+++ b/mslib/mscolab/auth.py
@@ -26,20 +26,24 @@
"""
+import collections.abc
import datetime
import functools
import hashlib
import hmac
import logging
+import secrets
import email_validator
import sqlalchemy
from flask import current_app, request, abort, g, url_for
from itsdangerous import URLSafeTimedSerializer, BadSignature
+from saml2.saml import NAMEID_FORMAT_PERSISTENT
from mslib.mscolab.api.schemas import TOKEN_HEADER
+from mslib.mscolab.app import db
from mslib.mscolab.conf import setup_saml2_backend
-from mslib.mscolab.models import User, fits_column
+from mslib.mscolab.models import IdpIdentity, SamlLogin, User, fits_column
def optional_auth(f):
@@ -153,8 +157,7 @@ def wrapper(*args, **kwargs):
# email confirmation, can't be used for another, e.g. a password reset.
EMAIL_CONFIRMATION = "email-confirmation"
PASSWORD_RESET = "password-reset"
-IDP_LOGIN = "idp-login"
-_TOKEN_PURPOSES = (EMAIL_CONFIRMATION, PASSWORD_RESET, IDP_LOGIN)
+_TOKEN_PURPOSES = (EMAIL_CONFIRMATION, PASSWORD_RESET)
def _serializer(purpose):
@@ -178,7 +181,7 @@ def confirm_token(token, purpose, expiration=3600):
def generate_confirmation_token(email, purpose):
"""
- A signed token with email for purpose, one of EMAIL_CONFIRMATION, PASSWORD_RESET and IDP_LOGIN
+ A signed token with email for purpose, one of EMAIL_CONFIRMATION and PASSWORD_RESET
"""
return _serializer(purpose).dumps(email)
@@ -225,40 +228,357 @@ def public_url_for(endpoint, **values):
return current_app.config['PUBLIC_URL'].rstrip("/") + url_for(endpoint, **values)
+def find_idp_config(idp_identity_name):
+ """The configured IdP idp_identity_name of setup_saml2_backend.CONFIGURED_IDPS, None if there is none"""
+ for idp_config in setup_saml2_backend.CONFIGURED_IDPS:
+ if idp_config['idp_identity_name'] == idp_identity_name:
+ return idp_config
+ return None
+
+
def get_idp_entity_id(selected_idp):
"""
Finds the entity_id from the configured IDPs
:return: the entity_id of the idp or None
"""
- for config in setup_saml2_backend.CONFIGURED_IDPS:
- if selected_idp == config['idp_identity_name']:
- idps = config['idp_data']['saml2client'].metadata.identity_providers()
- only_idp = idps[0]
- entity_id = only_idp
- return entity_id
- return None
+ idp_config = find_idp_config(selected_idp)
+ if idp_config is None:
+ return None
+ return idp_config['idp_data']['saml2client'].metadata.identity_providers()[0]
+
+
+# The IdP must answer a SAML login within SAML_REQUEST_LIFETIME seconds, msui must exchange its code within
+# IDP_LOGIN_CODE_LIFETIME seconds after that
+SAML_REQUEST_LIFETIME = 600
+IDP_LOGIN_CODE_LIFETIME = 1200
+# Anyone can start a SAML login, the server keeps at most this many unfinished ones
+MAX_SAML_LOGINS = 1000
+
+
+def _now():
+ return datetime.datetime.now(tz=datetime.timezone.utc)
+
+
+def _saml_requests(idp_identity_name, request_id):
+ # the login request_id sent to the IdP idp_identity_name, if the IdP can still answer it
+ return SamlLogin.query.filter(
+ SamlLogin.request_id == str(request_id),
+ SamlLogin.idp_identity_name == str(idp_identity_name),
+ SamlLogin.code_hash.is_(None),
+ SamlLogin.created_on >= _now() - datetime.timedelta(seconds=SAML_REQUEST_LIFETIME))
-def create_or_update_idp_user(email, username, token, authentication_backend):
+def remember_saml_request(request_id, idp_identity_name):
"""
- Creates or updates an idp user in the system based on the provided email,
- username, token, and authentication backend.
- :param email: idp users email
- :param username: idp users username
- :param token: authentication token
- :param authentication_backend: authenticated identity providers name
- :return: bool : query success or not
+ Stores the id of an AuthnRequest sent to the IdP idp_identity_name: only a response to it is accepted, and once
+
+ Returns False and stores nothing if MAX_SAML_LOGINS logins are unfinished, until some of them expire.
"""
- fm = current_app.extensions['fm']
- user = User.query.filter_by(emailid=email).first()
- if not user:
- # using an IDP for a new account/profile, e-mail is already verified by the IDP
- confirm_time = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(seconds=1)
- user = User(email, username, password=token, confirmed=True, confirmed_on=confirm_time,
- authentication_backend=authentication_backend)
- result = fm.modify_user(user, action="create")
+ # expired requests and codes
+ SamlLogin.query.filter(sqlalchemy.or_(
+ sqlalchemy.and_(SamlLogin.code_hash.is_(None),
+ SamlLogin.created_on < _now() - datetime.timedelta(seconds=SAML_REQUEST_LIFETIME)),
+ SamlLogin.created_on < _now() - datetime.timedelta(seconds=IDP_LOGIN_CODE_LIFETIME),
+ )).delete(synchronize_session=False)
+ if SamlLogin.query.count() >= MAX_SAML_LOGINS:
+ db.session.commit()
+ return False
+ db.session.add(SamlLogin(request_id, idp_identity_name))
+ db.session.commit()
+ return True
+
+
+class OutstandingSamlRequests(collections.abc.Mapping):
+ """
+ The AuthnRequests sent to an IdP that it can still answer, as pysaml2 expects them in parse_authn_request_response
+
+ Looked up in the database, a response can arrive at another process of the server than the one that sent the
+ request. pysaml2 refuses a response to a request that isn't one of them, also if the SP configuration has
+ allow_unsolicited; finish_saml_login refuses one without a request.
+ """
+
+ def __init__(self, idp_identity_name):
+ self.idp_identity_name = idp_identity_name
+ # pysaml2 looks up the same request several times
+ self._found = {}
+
+ def __getitem__(self, request_id):
+ if request_id is None:
+ raise KeyError(request_id)
+ if request_id not in self._found:
+ self._found[request_id] = _saml_requests(self.idp_identity_name, request_id).first() is not None
+ if not self._found[request_id]:
+ raise KeyError(request_id)
+ # where the login started, pysaml2 keeps it as came_from; unused
+ return "/"
+
+ def __iter__(self):
+ # pysaml2 only logs the keys
+ return iter(())
+
+ def __len__(self):
+ return 0
+
+ def __bool__(self):
+ # pysaml2 ignores an empty mapping
+ return True
+
+
+def saml_attributes(authn_response):
+ """
+ The attributes of a SAML response as {name: [values]}, by their names in the response and by the friendly names
+ of pysaml2's attribute maps
+ """
+ attributes = {}
+ assertion = getattr(authn_response, "assertion", None)
+ for statement in (assertion.attribute_statement if assertion is not None else []):
+ for attribute in statement.attribute:
+ values = [value.text for value in attribute.attribute_value if value.text]
+ for name in (attribute.name, attribute.friendly_name):
+ if name:
+ attributes.setdefault(name, values)
+ for name, values in (authn_response.ava or {}).items():
+ attributes[name] = [value for value in values if isinstance(value, str)]
+ return attributes
+
+
+def saml_identity(authn_response, idp_data, attributes):
+ """
+ (issuer, subject) of a SAML response, the identity a user is linked to; None if the response has no persistent
+ identifier
+
+ The issuer is the entity id of the IdP that signed the assertion: the metadata of one configured IdP can hold
+ several, e.g. of a federation. The issuer of the response around it may be unsigned. An unsigned assertion
+ (want_assertion_signed: false) counts only if the signed response has the same issuer: pysaml2 checks the
+ signature of an assertion or response that has one, so only a signature ties the issuer to its key. The subject
+ is the value of the attribute idp_data['user_id_attribute'] in attributes, see saml_attributes, or the NameID if
+ that isn't set and the NameID is persistent. A transient NameID changes with every login, an email address can
+ be given to someone else. Logs why a response has no identity.
+ """
+ assertion = getattr(authn_response, "assertion", None)
+ issuer = assertion.issuer.text if assertion is not None and assertion.issuer is not None else None
+ if issuer and getattr(assertion, "signature", None) is None:
+ response = getattr(authn_response, "response", None)
+ if getattr(response, "signature", None) is None or authn_response.issuer() != issuer:
+ logging.warning("Refused a SAML response with an unsigned assertion of %r: the response around it isn't "
+ "signed by the same IdP", issuer)
+ return None
+ attribute = idp_data.get('user_id_attribute')
+ if attribute:
+ values = attributes.get(attribute, [])
+ # of several values none is the identifier
+ subject = values[0] if len(values) == 1 else None
else:
- user.authentication_backend = authentication_backend
- user.hash_password(token)
- result = fm.modify_user(user, action="update_idp_user")
- return result
+ name_id = authn_response.name_id
+ subject = name_id.text if name_id is not None and name_id.format == NAMEID_FORMAT_PERSISTENT else None
+ if not issuer:
+ logging.warning("Refused a SAML response without issuer")
+ return None
+ if not subject:
+ logging.warning("Refused a SAML response of %r without a persistent identifier: set 'user_id_attribute' of "
+ "the IdP to a single valued attribute that identifies a user", issuer)
+ return None
+ if len(issuer) > 255 or len(subject) > 255:
+ logging.warning("Refused a SAML response of %r: its issuer or identifier is longer than 255 characters", issuer)
+ return None
+ return issuer, subject
+
+
+class SamlLoginRefused(Exception):
+ """A SAML login that can't log in, the message tells the user why"""
+
+
+def user_of_email(emailid):
+ """
+ The user of emailid, whatever its case, None if there is none; an exact match comes first
+
+ Raises SamlLoginRefused if several accounts have emailid in different case: which one is meant is unknown.
+ """
+ user = User.query.filter_by(emailid=str(emailid)).first()
+ if user is None:
+ users = User.query.filter(sqlalchemy.func.lower(User.emailid) == str(emailid).lower()).limit(2).all()
+ if len(users) > 1:
+ raise SamlLoginRefused("Several accounts of this server have your email address in different upper and "
+ "lower case. Ask its administrator for help.")
+ user = users[0] if users else None
+ return user
+
+
+def _new_saml_username(emailid, username):
+ """
+ The username of a new account for a SAML login: the one that the IdP sent, else, also if that is taken or
+ can't be used, the start of the email; raises SamlLoginRefused if register_user would refuse both
+ """
+ candidates = [name.strip() for name in (username, emailid.split("@")[0]) if name and name.strip()]
+ for candidate in candidates:
+ if len(candidate) <= 255 and "@" not in candidate \
+ and User.query.filter_by(username=candidate).first() is None:
+ return candidate
+ raise SamlLoginRefused(f"The usernames {' and '.join(repr(name) for name in candidates)} that your identity "
+ "provider sent are taken or can't be used for an account of this server. Ask its "
+ "administrator for help.")
+
+
+def check_saml_login(issuer, subject, emailid, username):
+ """
+ Checks the email and username that the IdP sent with the identity (issuer, subject); returns them as
+ finish_saml_login stores them, raises SamlLoginRefused if the login can't log in
+
+ A linked identity needs neither. Else the email must be valid: it may belong to an existing account, which must
+ be linked, see idp_login. Else a new account is created, its username is checked like by register_user.
+ """
+ if IdpIdentity.query.filter_by(issuer=str(issuer), subject=str(subject)).first() is not None:
+ return None, None
+ if not isinstance(emailid, str) or len(emailid) > 255:
+ raise SamlLoginRefused("Your identity provider sent no valid email address.")
+ try:
+ email_validator.validate_email(emailid, check_deliverability=False)
+ except email_validator.EmailNotValidError:
+ raise SamlLoginRefused("Your identity provider sent no valid email address.")
+ if user_of_email(emailid) is not None:
+ return emailid, None
+ return emailid, _new_saml_username(emailid, username)
+
+
+def _code_hash(code):
+ # the code is random and long, a plain hash is enough to not store it
+ return hashlib.sha256(code.encode("utf-8")).hexdigest()
+
+
+def finish_saml_login(request_id, idp_identity_name, issuer, subject, emailid, username):
+ """
+ Stores the identity that the IdP asserts in its response to the login request_id, with emailid and username
+ checked by check_saml_login; returns a one-time code that msui exchanges for a login token by idp_login, None if
+ the IdP can't answer request_id (any more)
+
+ Only one response per request: a second one, e.g. the first sent again, is refused.
+ """
+ if request_id is None:
+ return None
+ code = secrets.token_urlsafe(32)
+ updated = _saml_requests(idp_identity_name, request_id).update({
+ SamlLogin.code_hash: _code_hash(code),
+ SamlLogin.issuer: str(issuer),
+ SamlLogin.subject: str(subject),
+ SamlLogin.emailid: None if emailid is None else str(emailid),
+ SamlLogin.username: None if username is None else str(username),
+ SamlLogin.created_on: _now(),
+ }, synchronize_session=False)
+ db.session.commit()
+ return code if updated == 1 else None
+
+
+_SamlLoginResult = collections.namedtuple("_SamlLoginResult", "id idp_identity_name issuer subject emailid username")
+
+
+def _saml_login_of_code(code):
+ # the finished SAML login of code, a copy: the row is gone once the code is used
+ if not isinstance(code, str) or not code:
+ return None
+ saml_login = SamlLogin.query.filter(
+ SamlLogin.code_hash == _code_hash(code),
+ SamlLogin.created_on >= _now() - datetime.timedelta(seconds=IDP_LOGIN_CODE_LIFETIME)).first()
+ if saml_login is None:
+ return None
+ return _SamlLoginResult(saml_login.id, saml_login.idp_identity_name, saml_login.issuer, saml_login.subject,
+ saml_login.emailid, saml_login.username)
+
+
+def _use_saml_login(saml_login, user, link=False, first_identity=False):
+ """
+ Uses up the code of saml_login: stores user, if it is new, and links the identity of saml_login to it, if link
+
+ All in one transaction, so nothing changes if it fails: another request used the code first, linked the identity,
+ or created the account meanwhile; or first_identity and the user has an identity meanwhile. Returns whether it
+ succeeded.
+ """
+ # the checks below read the state of now, not of the reads before
+ db.session.commit()
+ try:
+ if SamlLogin.query.filter_by(id=saml_login.id).delete(synchronize_session=False) != 1:
+ db.session.rollback()
+ return False
+ if user.id is None:
+ db.session.add(user)
+ db.session.flush()
+ elif first_identity:
+ # waits for another request that links an identity to user at the same time
+ User.query.filter_by(id=user.id).with_for_update().one()
+ if IdpIdentity.query.filter_by(u_id=user.id).count() > 0:
+ db.session.rollback()
+ return False
+ if link:
+ db.session.add(IdpIdentity(user.id, saml_login.issuer, saml_login.subject))
+ db.session.commit()
+ except sqlalchemy.exc.IntegrityError:
+ db.session.rollback()
+ return False
+ return True
+
+
+def _refuse_unconfirmed(user):
+ # like /token, before the code is used: the user can confirm the email and try the same code again
+ if current_app.config['MAIL_ENABLED'] and not user.confirmed:
+ raise SamlLoginRefused("Your account isn't confirmed yet. Confirm your email address, then enter the code "
+ "again.")
+
+
+def idp_login(code):
+ """
+ The user that the one-time code of a SAML login logs in
+
+ Returns (user, None) and makes the code invalid. Returns (None, emailid) if the identity isn't linked to a user
+ and its email belongs to an existing account: the user must log in to that account to link it, see
+ link_idp_identity, the code stays valid for that. Returns (None, None) if the code is invalid or expired, or the
+ login can't log in, see check_saml_login. Raises SamlLoginRefused, with a message for the user, if the account
+ isn't confirmed (with MAIL_ENABLED) or its email is ambiguous; the code stays valid then.
+
+ A new identity gets a new account. A SAML login never changes the password of an account.
+ """
+ saml_login = _saml_login_of_code(code)
+ if saml_login is None:
+ return None, None
+ identity = IdpIdentity.query.filter_by(issuer=saml_login.issuer, subject=saml_login.subject).first()
+ if identity is not None:
+ user = identity.user
+ _refuse_unconfirmed(user)
+ return (user, None) if _use_saml_login(saml_login, user) else (None, None)
+ if not saml_login.emailid:
+ logging.warning("The SAML login of %s has no email, can't create an account", saml_login.idp_identity_name)
+ return None, None
+ user = user_of_email(saml_login.emailid)
+ if user is not None:
+ # Accounts of older versions logged in by the email that the IdP sent. One that logged in by this IdP before
+ # and has no identity yet is linked on its first login, if the response comes from the IdP the login request
+ # went to: the metadata of a federation lets any of its IdPs answer and assert the email. Any other account
+ # must be linked by its owner.
+ if user.authentication_backend != saml_login.idp_identity_name or user.idp_identities \
+ or saml_login.issuer != get_idp_entity_id(saml_login.idp_identity_name):
+ return None, user.emailid
+ _refuse_unconfirmed(user)
+ linked = _use_saml_login(saml_login, user, link=True, first_identity=True)
+ return (user, None) if linked else (None, None)
+ try:
+ username = _new_saml_username(saml_login.emailid, saml_login.username)
+ except SamlLoginRefused as ex:
+ # an account with the username was created since the IdP answered
+ logging.warning("Refused the SAML login of %s: %s", saml_login.idp_identity_name, ex)
+ return None, None
+ # nobody knows the password, the owner can set one by a password reset
+ user = User(saml_login.emailid, username, password=secrets.token_urlsafe(32), confirmed=True,
+ confirmed_on=_now() + datetime.timedelta(seconds=1),
+ authentication_backend=saml_login.idp_identity_name)
+ return (user, None) if _use_saml_login(saml_login, user, link=True) else (None, None)
+
+
+def link_idp_identity(user, code):
+ """
+ Links the identity of a SAML login to user, if idp_login refused its code because its email is the one of user;
+ returns whether it was linked
+
+ user is logged in, so whoever links knows the password of the account and can log in at the IdP.
+ """
+ saml_login = _saml_login_of_code(code)
+ if saml_login is None or not saml_login.emailid or saml_login.emailid.lower() != user.emailid.lower():
+ return False
+ return _use_saml_login(saml_login, user, link=True)
diff --git a/mslib/mscolab/blueprints/auth/__init__.py b/mslib/mscolab/blueprints/auth/__init__.py
index e0b390cb2..280a1abd0 100644
--- a/mslib/mscolab/blueprints/auth/__init__.py
+++ b/mslib/mscolab/blueprints/auth/__init__.py
@@ -26,25 +26,29 @@
"""
import datetime
+import functools
import json
import logging
-import secrets
-from flask import Blueprint, request, url_for, render_template, jsonify, flash, redirect, current_app
+from flask import Blueprint, request, url_for, render_template, jsonify, flash, redirect, current_app, abort, g
from flask.wrappers import Response
from saml2 import BINDING_HTTP_REDIRECT, BINDING_HTTP_POST
from saml2.metadata import create_metadata_string
+from saml2.saml import NAMEID_FORMAT_PERSISTENT
-from mslib.mscolab.auth import check_login, register_user, generate_confirmation_token, create_or_update_idp_user, \
- get_idp_entity_id, confirm_token, generate_password_reset_token, confirm_password_reset_token, public_url_for, \
- EMAIL_CONFIRMATION, IDP_LOGIN
-from mslib.mscolab.auth import optional_auth, request_token
+from mslib.mscolab.auth import check_login, register_user, generate_confirmation_token, get_idp_entity_id, \
+ confirm_token, generate_password_reset_token, confirm_password_reset_token, public_url_for, EMAIL_CONFIRMATION
+from mslib.mscolab.auth import optional_auth, request_token, verify_user
+from mslib.mscolab.auth import OutstandingSamlRequests, SamlLoginRefused, check_saml_login, find_idp_config, \
+ finish_saml_login, idp_login, link_idp_identity, remember_saml_request, saml_attributes, saml_identity
from mslib.mscolab.conf import setup_saml2_backend
from mslib.mscolab.forms import ResendConfirmationForm, ResetPasswordForm, ResetRequestForm
from mslib.mscolab.models import User
from mslib.mscolab.api import endpoints
from mslib.mscolab.api.attachments import normalized_extensions
from mslib.mscolab.api.schemas import (
+ IdpLinkRequest,
+ IdpLinkResponse,
IdpLoginAuthRequest,
IdpLoginAuthResponse,
IdpUserInfo,
@@ -139,151 +143,174 @@ def user_register_handler():
return jsonify(response.to_dict()), status_code
+def saml_required(func):
+ """The SAML routes exist only with USE_SAML2"""
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ if not current_app.config.get("USE_SAML2"):
+ abort(404)
+ return func(*args, **kwargs)
+ return wrapper
+
+
def init_saml(state):
- """Runs once when blueprint is registered."""
- app = state.app
+ """
+ Adds the assertion consumer services of the configured IdPs to the app, if it uses SAML
+ Their paths come from the configuration of each IdP, so they are added to the app and not to the blueprint, which
+ can't change once it was registered with an app.
+ """
+ app = state.app
if not app.config.get("USE_SAML2"):
return
-
- setup_saml2_backend()
- register_saml_routes()
-
-
-def register_saml_routes():
- """All SAML routes are registered here safely."""
-
- def create_acs_post_handler(idp_config):
- """
- Create acs_post_handler function for the given idp_config.
- """
-
- def acs_post_handler():
- """
- Function to handle SAML authentication response.
- """
- try:
- outstanding_queries = {}
- binding = BINDING_HTTP_POST
- authn_response = idp_config['idp_data']['saml2client'].parse_authn_request_response(
- request.form["SAMLResponse"], binding, outstanding=outstanding_queries
- )
-
- try:
- email = authn_response.ava["email"][0]
- username = authn_response.ava["givenName"][0]
- token = generate_confirmation_token(email, IDP_LOGIN)
- except (NameError, AttributeError, KeyError):
- try:
- # Initialize an empty dictionary to store attribute values
- attributes = {}
-
- # Loop through attribute statements
- for attribute_statement in authn_response.assertion.attribute_statement:
- for attribute in attribute_statement.attribute:
- attribute_name = attribute.name
- attribute_value = \
- attribute.attribute_value[0].text if attribute.attribute_value else None
- attributes[attribute_name] = attribute_value
-
- # Extract the email and givenname attributes
- email = attributes["email"]
- username = attributes["givenName"]
- token = generate_confirmation_token(email, IDP_LOGIN)
- except (NameError, AttributeError, KeyError):
- return render_template('auth/errors/403.html'), 403
-
- if email is not None and username is not None:
- idp_identity_name = idp_config['idp_identity_name']
- idp_user_db_state = create_or_update_idp_user(email,
- username, token, idp_identity_name)
- if idp_user_db_state:
- return render_template('auth/idp/idp_login_success.html', token=token), 200
- return render_template('auth/errors/500.html'), 500
- return render_template('auth/errors/500.html'), 500
- except (NameError, AttributeError, KeyError):
- return render_template('auth/errors/403.html'), 403
-
- return acs_post_handler
-
- # Implementation for handling configured SAML assertion consumer endpoints
for idp_config in setup_saml2_backend.CONFIGURED_IDPS:
try:
- for assertion_consumer_endpoint in idp_config['idp_data']['assertion_consumer_endpoints']:
- # Dynamically add the route for the current endpoint
- AUTH_BP.add_url_rule(f'/{assertion_consumer_endpoint}/', assertion_consumer_endpoint,
- create_acs_post_handler(idp_config), methods=['POST'])
- except (NameError, AttributeError, KeyError) as ex:
- logging.warning("USE_SAML2 is %s, Failure is: %s", current_app.config['USE_SAML2'], ex)
-
- @AUTH_BP.route('/available_idps/', methods=['GET'])
- def available_idps():
- """
- This function checks if IDP (Identity Provider) is enabled in the mscolab_settings module.
- If IDP is enabled, it retrieves the configured IDPs from setup_saml2_backend.CONFIGURED_IDPS
- and renders the 'idp/available_idps.html' template with the list of configured IDPs.
- """
- configured_idps = setup_saml2_backend.CONFIGURED_IDPS
- return render_template('auth/idp/available_idps.html', configured_idps=configured_idps), 200
-
- @AUTH_BP.route("/idp_login/", methods=['POST'])
- def idp_login():
- """Handle the login process for the user by selected IDP"""
- selected_idp = request.form.get('selectedIdentityProvider')
- sp_config = None
- for config_ in setup_saml2_backend.CONFIGURED_IDPS:
- if selected_idp == config_['idp_identity_name']:
- sp_config = config_['idp_data']['saml2client']
- break
-
+ endpoints_ = idp_config['idp_data']['assertion_consumer_endpoints']
+ except KeyError as ex:
+ logging.warning("The IdP %s has no assertion consumer service: %s", idp_config.get('idp_identity_name'), ex)
+ continue
+ if not idp_config['idp_data'].get('user_id_attribute'):
+ name_id_formats = idp_config['idp_data']['saml2client'].config.getattr("name_id_format", "sp") or []
+ if NAMEID_FORMAT_PERSISTENT not in ([name_id_formats] if isinstance(name_id_formats, str)
+ else name_id_formats):
+ logging.warning("The IdP %s has no 'user_id_attribute' and the SP asks for no persistent NameID: "
+ "every SAML login by it is refused. Set 'user_id_attribute' in "
+ "setup_saml2_backend.py, see docs/sso_via_saml_mscolab.rst",
+ idp_config['idp_identity_name'])
+ for index, path in enumerate(endpoints_):
+ # as configured, also with a trailing slash: the metadata tells the IdP this URL
+ app.add_url_rule(f"/{path.lstrip('/')}", f"saml_acs_{idp_config['idp_identity_name']}_{index}",
+ _acs_post_handler(idp_config), methods=['POST'], strict_slashes=False)
+
+
+def _first(values):
+ return values[0] if values else None
+
+
+def _acs_post_handler(idp_config):
+ """The handler of the SAML responses of the IdP idp_config"""
+ idp_identity_name = idp_config['idp_identity_name']
+
+ def acs_post_handler():
try:
- _, response_binding = sp_config.config.getattr("endpoints", "sp")[
- "assertion_consumer_service"
- ][0]
- entity_id = get_idp_entity_id(selected_idp)
- _, binding, http_args = sp_config.prepare_for_negotiated_authenticate(
- entityid=entity_id,
- response_binding=response_binding,
- )
- if binding == BINDING_HTTP_REDIRECT:
- headers = dict(http_args["headers"])
- return redirect(str(headers["Location"]), code=303)
- return Response(http_args["data"], headers=http_args["headers"])
- except (NameError, AttributeError):
+ authn_response = idp_config['idp_data']['saml2client'].parse_authn_request_response(
+ request.form["SAMLResponse"], BINDING_HTTP_POST, outstanding=OutstandingSamlRequests(idp_identity_name))
+ except Exception as ex:
+ # pysaml2 raises many kinds of exceptions for an invalid, unsigned, expired or unsolicited response
+ logging.warning("Refused a SAML response of %s: %s: %s", idp_identity_name, type(ex).__name__, ex)
+ return render_template('auth/errors/403.html'), 403
+ if authn_response is None:
+ return render_template('auth/errors/403.html'), 403
+ attributes = saml_attributes(authn_response)
+ identity = saml_identity(authn_response, idp_config['idp_data'], attributes)
+ if identity is None:
+ # saml_identity logged why
return render_template('auth/errors/403.html'), 403
-
- @AUTH_BP.route(f"/{endpoints.IDP_LOGIN_AUTH}/", methods=['POST'])
- def idp_login_auth():
- """Handle the SAML authentication validation of client application."""
try:
- req = IdpLoginAuthRequest.from_json_data(request.get_json())
- email = confirm_token(req.token, IDP_LOGIN, expiration=1200)
- if email:
- user = check_login(email, req.token)
- if user:
- fm = current_app.extensions['fm']
- random_token = secrets.token_hex(16)
- user.hash_password(random_token)
- fm.modify_user(user, action="update_idp_user")
- user_info = IdpUserInfo(username=user.username, id=user.id, emailid=user.emailid)
- response = IdpLoginAuthResponse(success=True, token=random_token, user=user_info)
- return response.to_text()
- return jsonify(IdpLoginAuthResponse(success=False).to_dict()), 401
- return jsonify(IdpLoginAuthResponse(success=False).to_dict()), 401
- except TypeError:
- return jsonify(IdpLoginAuthResponse(success=False).to_dict()), 401
-
- @AUTH_BP.route("/metadata/ You don't have permission to access this page {{ message }} The server can't take more logins right now. Please try again in a few minutes. Congratulations! You have successfully logged in to the mscolab server using Identity Provider. Please proceed to log in using the user interface by below token. Token : {{token}}
+ You have logged in to the MSColab server by your identity provider. Enter this code in the user interface within 20 minutes to finish the login. It works once. Code: If an MSColab account with your email exists already, the user interface asks for its password once, to link
+ this login to the account.403 - Forbidden
503 - Service Unavailable
+
+ Choose Identity Provider
-
-
- {{ code }}
-
+