diff --git a/app.py b/app.py index 31393c8..ce8e019 100644 --- a/app.py +++ b/app.py @@ -26,10 +26,14 @@ from helpers import ( KratosUser ) from config import * +import logging app = Flask(__name__) cors = CORS(app) app.config["SECRET_KEY"] = SECRET_KEY + +app.logger.setLevel(logging.INFO) + app.register_blueprint(api_v1) app.register_blueprint(web) @@ -42,7 +46,6 @@ app.register_error_handler(HydraError, hydra_error) jwt = JWTManager(app) - # When token is not valid or missing handler @jwt.invalid_token_loader @jwt.unauthorized_loader diff --git a/areas/__init__.py b/areas/__init__.py index dfc3176..0628ba2 100644 --- a/areas/__init__.py +++ b/areas/__init__.py @@ -2,7 +2,7 @@ from flask import Blueprint api_v1 = Blueprint("api_v1", __name__, url_prefix="/api/v1") web = Blueprint("web", __name__, url_prefix="/web") - +# cli = Blueprint('cli', __name__) @api_v1.route("/") @api_v1.route("/health") diff --git a/areas/login/login.py b/areas/login/login.py index 4866cf9..37210d1 100644 --- a/areas/login/login.py +++ b/areas/login/login.py @@ -30,6 +30,19 @@ from ory_kratos_client.api import v0alpha2_api as kratos_api from areas import web from config import * +from flask import current_app + +from helpers import ( + BadRequest, + KratosError, + HydraError, + bad_request_error, + validation_error, + kratos_error, + global_error, + hydra_error, + KratosUser +) # APIs # Create HYDRA & KRATOS API interfaces @@ -143,7 +156,7 @@ def auth(): challenge = request.args.post("login_challenge") if not challenge: - app.logger.error("No challenge given. Error in request") + current_app.logger.error("No challenge given. Error in request") abort(400, description="Challenge required when requesting authorization") @@ -160,25 +173,25 @@ def auth(): url = PUBLIC_URL + "/auth?login_challenge=" + challenge url = urllib.parse.quote_plus(url) - app.logger.info("Redirecting to login. Setting flow_state cookies") - app.logger.info("auth_url: " + url) + current_app.logger.info("Redirecting to login. Setting flow_state cookies") + current_app.logger.info("auth_url: " + url) - response = redirect(app.config["PUBLIC_URL"] + "/login") + response = redirect(PUBLIC_URL + "/login") response.set_cookie('flow_state', 'auth') response.set_cookie('auth_url', url) return response - app.logger.info("User is logged in. We can authorize the user") + current_app.logger.info("User is logged in. We can authorize the user") try: login_request = HYDRA.login_request(challenge) except hydra_client.exceptions.NotFound: - app.logger.error(f"Not Found. Login request not found. challenge={challenge}") + current_app.logger.error(f"Not Found. Login request not found. challenge={challenge}") abort(404, description="Login request not found. Please try again.") except hydra_client.exceptions.HTTPError: - app.logger.error(f"Conflict. Login request has been used already. challenge={challenge}") + current_app.logger.error(f"Conflict. Login request has been used already. challenge={challenge}") abort(503, description="Login request already used. Please try again.") # Authorize the user @@ -208,10 +221,10 @@ def consent(): try: consent_request = HYDRA.consent_request(challenge) except hydra_client.exceptions.NotFound: - app.logger.error(f"Not Found. Consent request {challenge} not found") + current_app.logger.error(f"Not Found. Consent request {challenge} not found") abort(404, description="Consent request does not exist. Please try again") except hydra_client.exceptions.HTTPError: - app.logger.error(f"Conflict. Consent request {challenge} already used") + current_app.logger.error(f"Conflict. Consent request {challenge} already used") abort(503, description="Consent request already used. Please try again") # Get information about this consent request: @@ -223,11 +236,12 @@ def consent(): # Get the related user object user = KratosUser(KRATOS_ADMIN, kratos_id) if not user: - app.logger.error(f"User not found in database: {kratos_id}") + current_app.logger.error(f"User not found in database: {kratos_id}") abort(401, description="User not found. Please try again.") # Get role on this app - app_obj = db.session.query(App).filter(App.slug == app_id).first() + #app_obj = db.session.query(App).filter(App.slug == app_id).first() + app_obj = False # Default access level roles = [] @@ -239,7 +253,7 @@ def consent(): ) for role_obj in role_objects: roles.append(role_obj.role) - app.logger.info(f"Using '{roles}' when applying consent for {kratos_id}") + current_app.logger.info(f"Using '{roles}' when applying consent for {kratos_id}") # Get claims for this user, provided the current app claims = user.get_claims(app_id, roles) @@ -247,8 +261,8 @@ def consent(): # pylint: disable=fixme # TODO: Need to implement checking claims here, once the backend for that is # developed - app.logger.info(f"Providing consent to {app_id} for {kratos_id}") - app.logger.info(f"{kratos_id} was granted access to {app_id}") + current_app.logger.info(f"Providing consent to {app_id} for {kratos_id}") + current_app.logger.info(f"{kratos_id} was granted access to {app_id}") # False positive: pylint: disable=no-member return redirect(consent_request.accept( @@ -285,7 +299,7 @@ def get_auth(): cookie = request.cookies.get('ory_kratos_session') cookie = "ory_kratos_session=" + cookie except TypeError: - app.logger.info("User not logged in or cookie corrupted") + current_app.logger.info("User not logged in or cookie corrupted") return False # Given a cookie, check if it is valid and get the profile @@ -297,7 +311,7 @@ def get_auth(): return api_response.identity except ory_kratos_client.ApiException as error: - app.logger.error(f"Exception when calling V0alpha2Api->to_session(): {error}\n") + current_app.logger.error(f"Exception when calling V0alpha2Api->to_session(): {error}\n") return False diff --git a/run_app.sh b/run_app.sh index 8fda587..e174879 100755 --- a/run_app.sh +++ b/run_app.sh @@ -29,10 +29,10 @@ export HYDRA_AUTHORIZATION_BASE_URL="https://sso.init.stackspin.net/oauth2/auth" export TOKEN_URL="https://sso.init.stackspin.net/oauth2/token" # Login facilitator paths -export KRATOS_PUBLIC_URL=http://localhost/kapi +export KRATOS_PUBLIC_URL=http://localhost/kratos export KRATOS_ADMIN_URL=http://localhost:8000 export HYDRA_ADMIN_URL=http://localhost:4445 -export PUBLIC_URL=http://localhost/login +export PUBLIC_URL=http://localhost/web/ export DATABASE_URL="mysql+pymysql://stackspin:stackspin@localhost/stackspin?charset=utf8mb4" diff --git a/set-ssh-tunnel.sh b/set-ssh-tunnel.sh index a68902b..fcb2ef4 100755 --- a/set-ssh-tunnel.sh +++ b/set-ssh-tunnel.sh @@ -22,10 +22,10 @@ fi admin=`ssh $host -lroot kubectl get service -n $namespace |grep single-sign-on-kratos-admin | awk '{print $3'}` public=`ssh $host -lroot kubectl get service -n $namespace |grep single-sign-on-kratos-public | awk '{print $3}'` hydra=`ssh $host -lroot kubectl get service -n $namespace |grep single-sign-on-hydra-admin | awk '{print $3}'` -psql=`ssh $host -lroot kubectl get service -n $namespace |grep single-sign-on-database-postgres|grep -v headless | awk '{print $3}'` +mysql=`ssh $host -lroot kubectl get service -n $namespace |grep single-sign-on-database-maria|grep -v headless | awk '{print $3}'` -if [ "x$admin" == 'x' ] || [ "x$public" == 'x' ] || [ "x$hydra" == 'x' ] || [ "x$psql" == 'x' ] +if [ "x$admin" == 'x' ] || [ "x$public" == 'x' ] || [ "x$hydra" == 'x' ] || [ "x$mysql" == 'x' ] then echo "It seems we where not able find at least one of the remote services" echo " please make sure that kubectl use the right namespace by default." @@ -39,7 +39,7 @@ echo " kratos admin port will be at localhost: 8000 kratos public port will be at localhost: 8080 hydra admin port will be at localhost: 4445 -psql port will be at localhost: 5432 +mysql port will be at localhost: 3306 " -ssh -L 8000:$admin:80 -L 8080:$public:80 -L 4445:$hydra:4445 -L 5432:$psql:5432 root@$host \ No newline at end of file +ssh -L 8000:$admin:80 -L 8080:$public:80 -L 4445:$hydra:4445 -L 3306:$mysql:3306 root@$host \ No newline at end of file diff --git a/static/.gitkeep b/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/static/base.js b/static/base.js new file mode 100644 index 0000000..2b2e07f --- /dev/null +++ b/static/base.js @@ -0,0 +1,410 @@ + + +/* base.js + This is the base JS file to render the user interfaces of kratos and provide + the end user with flows for login, recovery etc. + + check_flow_*(): + These functions check the status of the flow and based on the status do some + action to get a better experience for the end user. Usually this is a + redirect based on the state + + flow_*(): + execute / render all UI elements in a flow. Kratos expects you to work on + to query kratos which provides you with the UI elements needed to be + rendered. This querying and rendering is done exectly by those function. + Based on what kratos provides or the state of the flow, elements are maybe + hidden or shown + +*/ + + +// Check if an auth flow is configured and redirect to auth page in that +// case. +function check_flow_auth() { + var state = Cookies.get('flow_state'); + var url = Cookies.get('auth_url'); + + if (state == 'auth') { + Cookies.set('flow_state',''); + window.location.href = url; + } +} + +// Check if there if the flow is expired, if so, reset the cookie +function check_flow_expired() { + var state = Cookies.get('flow_state'); + + if (state == 'flow_expired') { + Cookies.set('flow_state',''); + $("#contentFlowExpired").show(); + } +} + + + +// The script executed on login flows +function flow_login() { + + var flow = $.urlParam('flow'); + var uri = api_url + 'self-service/login/flows?id=' + flow; + + // Query the Kratos backend to know what fields to render for the + // current flow + $.ajax({ + type: "GET", + url: uri, + success: function(data) { + + // Render login form (group: password) + var html = render_form(data, 'password'); + $("#contentLogin").html(html); + + }, + complete: function(obj) { + + // If we get a 410, the flow is expired, need to refresh the flow + if (obj.status == 410) { + Cookies.set('flow_state','flow_expired'); + // If we call the page without arguments, we get a new flow + window.location.href = 'login'; + } + } + }); +} + +// This is called after a POST on settings. It tells if the save was +// successful and display / handles based on that outcome +function flow_settings_validate() { + + var flow = $.urlParam('flow'); + var uri = api_url + 'self-service/settings/flows?id=' + flow; + + $.ajax( { + type: "GET", + url: uri, + success: function(data) { + + // We had success. We save that fact in our flow_state + // cookie and regenerate a new flow + if (data.state == 'success') { + Cookies.set('flow_state', 'settings_saved'); + + // Redirect to generate new flow ID + window.location.href = 'settings'; + } + else { + + // There was an error, Kratos does not specify what is + // wrong. So we just show the general error message and + // let the user figure it out. We can re-use the flow-id + $("#contentProfileSaveFailed").show(); + } + } + }); +} + + +// Render the settings flow, this is where users can change their personal +// settings, like name and password. The form contents are defined by Kratos +function flow_settings() { + + // Get the details from the current flow from kratos + var flow = $.urlParam('flow'); + var uri = api_url + 'self-service/settings/flows?id=' + flow; + $.ajax({ + type: "GET", + url: uri, + success: function(data) { + + var state = Cookies.get('flow_state') + + // If we have confirmation the settings are saved, show the + // notification + if (state == 'settings_saved') { + $("#contentProfileSaved").show(); + Cookies.set('flow_state', 'settings'); + } + + // Hide prfile section if we are in recovery state + // so the user is not confused by other fields. The user + // probably want to setup a password only first. + if (state == 'recovery') { + $("#contentProfile").hide(); + } + + + // Render the password & profile form based on the fields we got + // from the API + var html = render_form(data, 'password'); + $("#contentPassword").html(html); + + html = render_form(data, 'profile'); + $("#contentProfile").html(html); + + // If the submit button is hit, execute the POST with Ajax. + $("#formpassword").submit(function(e) { + + // avoid to execute the actual submit of the form. + e.preventDefault(); + + var form = $(this); + var url = form.attr('action'); + + $.ajax({ + type: "POST", + url: url, + data: form.serialize(), + complete: function(obj) { + // Validate the settings + flow_settings_validate(); + }, + }); + }); + + + + }, + complete: function(obj) { + + // If we get a 410, the flow is expired, need to refresh the flow + if (obj.status == 410) { + Cookies.set('flow_state','flow_expired'); + window.location.href = 'settings'; + } + + } + }); + +} + +function flow_recover() { + var flow = $.urlParam('flow'); + var uri = api_url + 'self-service/recovery/flows?id=' + flow; + + $.ajax( { + type: "GET", + url: uri, + success: function(data) { + + // Render the recover form, method 'link' + var html = render_form(data, 'link'); + $("#contentRecover").html(html); + + // Do form post as an AJAX call + $("#formlink").submit(function(e) { + + // avoid to execute the actual submit of the form. + e.preventDefault(); + + var form = $(this); + var url = form.attr('action'); + + // keep stat we are in recovery + Cookies.set('flow_state', 'recovery'); + $.ajax({ + type: "POST", + url: url, + data: form.serialize(), // serializes the form's elements. + success: function(data) + { + + // Show the request is sent out + $("#contentRecover").hide(); + $("#contentRecoverRequested").show(); + } + }); + }); + + + }, + complete: function(obj) { + + // If we get a 410, the flow is expired, need to refresh the flow + if (obj.status == 410) { + Cookies.set('flow_state','flow_expired'); + window.location.href = 'recovery'; + + } + } + }); +} + +// Based on Kratos UI data and a group name, get the full form for that group. +// kratos groups elements which belongs together in a group and should be posted +// at once. The elements in the default group should be part of all other +// groups. +// +// data: data object as returned form the API +// group: group to render. +function render_form(data, group) { + + // Create form + var action = data.ui.action; + var method = data.ui.method; + var form = "
"; + return form; + +} + +// Return form element based on name, including help text (sub), placeholder etc. +// Kratos give us form names and types and specifies what to render. However +// it does not provide labels or translations. This function returns a HTML +// form element based on the fields provided by Kratos with proper names and +// labels +// type: input type, usual "input", "hidden" or "submit". But bootstrap types +// like "email" are also supported +// name: name of the field. Used when posting data +// value: If there is already a value known, show it +function getFormElement(type, name, value) { + + if (value == undefined) { + value = ''; + } + if (name == 'email' || name == 'traits.email') { + return getFormInput( + 'email', + name, + value, + 'E-mail address', + 'Please enter your e-mail address here', + 'Please provide your e-mail address. We will send a recovery ' + + 'link to that e-mail address.', + ); + } + + if (name == 'traits.username') { + return getFormInput( + 'name', + name, + value, + 'Username', + 'Please provide an username', + null + ); + } + + if (name == 'traits.name') { + return getFormInput( + 'name', + name, + value, + 'Full name', + 'Please provide your full name', + null + ); + } + + + if (name == 'password_identifier') { + return getFormInput( + 'email', + name, + value, + 'E-mail address', + 'Please provide your e-mail address to login', + null + ); + } + + if (name == 'password') { + return getFormInput( + 'password', + name, + value, + 'Password', + 'Please provide your password', + null + ); + } + + + if (type == 'hidden' || name == 'traits.uuid') { + + return ` + `; + } + + if (type == 'submit') { + + return ``s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n// Abbreviations\n//\n// 1. Remove the bottom border in Firefox 39-.\n// 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Duplicate behavior to the data-* attribute for our tooltip plugin\n\nabbr[title],\nabbr[data-original-title] { // 4\n text-decoration: underline; // 2\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n border-bottom: 0; // 1\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // Undo browser default\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\ndfn {\n font-style: italic; // Add the correct font style in Android 4.3-\n}\n\n// stylelint-disable font-weight-notation\nb,\nstrong {\n font-weight: bolder; // Add the correct font weight in Chrome, Edge, and Safari\n}\n// stylelint-enable font-weight-notation\n\nsmall {\n font-size: 80%; // Add the correct font size in all browsers\n}\n\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n//\n\nsub,\nsup {\n position: relative;\n font-size: 75%;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n//\n// Links\n//\n\na {\n color: $link-color;\n text-decoration: $link-decoration;\n background-color: transparent; // Remove the gray background on active links in IE 10.\n -webkit-text-decoration-skip: objects; // Remove gaps in links underline in iOS 8+ and Safari 8+.\n\n @include hover {\n color: $link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href)\n// which have not been made explicitly keyboard-focusable (without tabindex).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([tabindex]) {\n color: inherit;\n text-decoration: none;\n\n @include hover-focus {\n color: inherit;\n text-decoration: none;\n }\n\n &:focus {\n outline: 0;\n }\n}\n\n\n//\n// Code\n//\n\n// stylelint-disable font-family-no-duplicate-names\npre,\ncode,\nkbd,\nsamp {\n font-family: monospace, monospace; // Correct the inheritance and scaling of font size in all browsers.\n font-size: 1em; // Correct the odd `em` font sizing in all browsers.\n}\n// stylelint-enable font-family-no-duplicate-names\n\npre {\n // Remove browser default top margin\n margin-top: 0;\n // Reset browser default of `1em` to use `rem`s\n margin-bottom: 1rem;\n // Don't allow content to break outside\n overflow: auto;\n // We have @viewport set which causes scrollbars to overlap content in IE11 and Edge, so\n // we force a non-overlapping, non-auto-hiding scrollbar to counteract.\n -ms-overflow-style: scrollbar;\n}\n\n\n//\n// Figures\n//\n\nfigure {\n // Apply a consistent margin strategy (matches our type styles).\n margin: 0 0 1rem;\n}\n\n\n//\n// Images and content\n//\n\nimg {\n vertical-align: middle;\n border-style: none; // Remove the border on images inside links in IE 10-.\n}\n\nsvg:not(:root) {\n overflow: hidden; // Hide the overflow in IE\n}\n\n\n//\n// Tables\n//\n\ntable {\n border-collapse: collapse; // Prevent double borders\n}\n\ncaption {\n padding-top: $table-cell-padding;\n padding-bottom: $table-cell-padding;\n color: $text-muted;\n text-align: left;\n caption-side: bottom;\n}\n\nth {\n // Matches default `
`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n// Abbreviations\n//\n// 1. Remove the bottom border in Firefox 39-.\n// 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Duplicate behavior to the data-* attribute for our tooltip plugin\n\nabbr[title],\nabbr[data-original-title] { // 4\n text-decoration: underline; // 2\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n border-bottom: 0; // 1\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // Undo browser default\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\ndfn {\n font-style: italic; // Add the correct font style in Android 4.3-\n}\n\n// stylelint-disable font-weight-notation\nb,\nstrong {\n font-weight: bolder; // Add the correct font weight in Chrome, Edge, and Safari\n}\n// stylelint-enable font-weight-notation\n\nsmall {\n font-size: 80%; // Add the correct font size in all browsers\n}\n\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n//\n\nsub,\nsup {\n position: relative;\n font-size: 75%;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n//\n// Links\n//\n\na {\n color: $link-color;\n text-decoration: $link-decoration;\n background-color: transparent; // Remove the gray background on active links in IE 10.\n -webkit-text-decoration-skip: objects; // Remove gaps in links underline in iOS 8+ and Safari 8+.\n\n @include hover {\n color: $link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href)\n// which have not been made explicitly keyboard-focusable (without tabindex).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([tabindex]) {\n color: inherit;\n text-decoration: none;\n\n @include hover-focus {\n color: inherit;\n text-decoration: none;\n }\n\n &:focus {\n outline: 0;\n }\n}\n\n\n//\n// Code\n//\n\n// stylelint-disable font-family-no-duplicate-names\npre,\ncode,\nkbd,\nsamp {\n font-family: monospace, monospace; // Correct the inheritance and scaling of font size in all browsers.\n font-size: 1em; // Correct the odd `em` font sizing in all browsers.\n}\n// stylelint-enable font-family-no-duplicate-names\n\npre {\n // Remove browser default top margin\n margin-top: 0;\n // Reset browser default of `1em` to use `rem`s\n margin-bottom: 1rem;\n // Don't allow content to break outside\n overflow: auto;\n // We have @viewport set which causes scrollbars to overlap content in IE11 and Edge, so\n // we force a non-overlapping, non-auto-hiding scrollbar to counteract.\n -ms-overflow-style: scrollbar;\n}\n\n\n//\n// Figures\n//\n\nfigure {\n // Apply a consistent margin strategy (matches our type styles).\n margin: 0 0 1rem;\n}\n\n\n//\n// Images and content\n//\n\nimg {\n vertical-align: middle;\n border-style: none; // Remove the border on images inside links in IE 10-.\n}\n\nsvg:not(:root) {\n overflow: hidden; // Hide the overflow in IE\n}\n\n\n//\n// Tables\n//\n\ntable {\n border-collapse: collapse; // Prevent double borders\n}\n\ncaption {\n padding-top: $table-cell-padding;\n padding-bottom: $table-cell-padding;\n color: $text-muted;\n text-align: left;\n caption-side: bottom;\n}\n\nth {\n // Matches default `
`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n// Abbreviations\n//\n// 1. Remove the bottom border in Firefox 39-.\n// 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Duplicate behavior to the data-* attribute for our tooltip plugin\n\nabbr[title],\nabbr[data-original-title] { // 4\n text-decoration: underline; // 2\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n border-bottom: 0; // 1\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // Undo browser default\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\ndfn {\n font-style: italic; // Add the correct font style in Android 4.3-\n}\n\n// stylelint-disable font-weight-notation\nb,\nstrong {\n font-weight: bolder; // Add the correct font weight in Chrome, Edge, and Safari\n}\n// stylelint-enable font-weight-notation\n\nsmall {\n font-size: 80%; // Add the correct font size in all browsers\n}\n\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n//\n\nsub,\nsup {\n position: relative;\n font-size: 75%;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n//\n// Links\n//\n\na {\n color: $link-color;\n text-decoration: $link-decoration;\n background-color: transparent; // Remove the gray background on active links in IE 10.\n -webkit-text-decoration-skip: objects; // Remove gaps in links underline in iOS 8+ and Safari 8+.\n\n @include hover {\n color: $link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href)\n// which have not been made explicitly keyboard-focusable (without tabindex).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([tabindex]) {\n color: inherit;\n text-decoration: none;\n\n @include hover-focus {\n color: inherit;\n text-decoration: none;\n }\n\n &:focus {\n outline: 0;\n }\n}\n\n\n//\n// Code\n//\n\n// stylelint-disable font-family-no-duplicate-names\npre,\ncode,\nkbd,\nsamp {\n font-family: monospace, monospace; // Correct the inheritance and scaling of font size in all browsers.\n font-size: 1em; // Correct the odd `em` font sizing in all browsers.\n}\n// stylelint-enable font-family-no-duplicate-names\n\npre {\n // Remove browser default top margin\n margin-top: 0;\n // Reset browser default of `1em` to use `rem`s\n margin-bottom: 1rem;\n // Don't allow content to break outside\n overflow: auto;\n // We have @viewport set which causes scrollbars to overlap content in IE11 and Edge, so\n // we force a non-overlapping, non-auto-hiding scrollbar to counteract.\n -ms-overflow-style: scrollbar;\n}\n\n\n//\n// Figures\n//\n\nfigure {\n // Apply a consistent margin strategy (matches our type styles).\n margin: 0 0 1rem;\n}\n\n\n//\n// Images and content\n//\n\nimg {\n vertical-align: middle;\n border-style: none; // Remove the border on images inside links in IE 10-.\n}\n\nsvg:not(:root) {\n overflow: hidden; // Hide the overflow in IE\n}\n\n\n//\n// Tables\n//\n\ntable {\n border-collapse: collapse; // Prevent double borders\n}\n\ncaption {\n padding-top: $table-cell-padding;\n padding-bottom: $table-cell-padding;\n color: $text-muted;\n text-align: left;\n caption-side: bottom;\n}\n\nth {\n // Matches default `