diff --git a/cmdb-api/api/app.py b/cmdb-api/api/app.py index dbb1d88..757a5da 100644 --- a/cmdb-api/api/app.py +++ b/cmdb-api/api/app.py @@ -19,7 +19,9 @@ from flask.json.provider import DefaultJSONProvider import api.views.entry from api.extensions import (bcrypt, cache, celery, cors, db, es, login_manager, migrate, rd) from api.extensions import inner_secrets -from api.flask_cas import CAS +from api.lib.perm.authentication.cas import CAS +from api.lib.perm.authentication.oauth2 import OAuth2 +from api.lib.perm.authentication.oidc import OIDC from api.lib.secrets.secrets import InnerKVManger from api.models.acl import User @@ -96,6 +98,8 @@ def create_app(config_object="settings"): register_shell_context(app) register_commands(app) CAS(app) + OIDC(app) + OAuth2(app) app.wsgi_app = ReverseProxy(app.wsgi_app) configure_upload_dir(app) diff --git a/cmdb-api/api/lib/perm/authentication/__init__.py b/cmdb-api/api/lib/perm/authentication/__init__.py new file mode 100644 index 0000000..380474e --- /dev/null +++ b/cmdb-api/api/lib/perm/authentication/__init__.py @@ -0,0 +1 @@ +# -*- coding:utf-8 -*- diff --git a/cmdb-api/api/flask_cas/__init__.py b/cmdb-api/api/lib/perm/authentication/cas/__init__.py similarity index 95% rename from cmdb-api/api/flask_cas/__init__.py rename to cmdb-api/api/lib/perm/authentication/cas/__init__.py index dc31cf5..5f0fd52 100644 --- a/cmdb-api/api/flask_cas/__init__.py +++ b/cmdb-api/api/lib/perm/authentication/cas/__init__.py @@ -15,7 +15,7 @@ try: except ImportError: from flask import _request_ctx_stack as stack -from api.flask_cas import routing +from . import routing class CAS(object): diff --git a/cmdb-api/api/flask_cas/cas_urls.py b/cmdb-api/api/lib/perm/authentication/cas/cas_urls.py similarity index 100% rename from cmdb-api/api/flask_cas/cas_urls.py rename to cmdb-api/api/lib/perm/authentication/cas/cas_urls.py diff --git a/cmdb-api/api/flask_cas/routing.py b/cmdb-api/api/lib/perm/authentication/cas/routing.py similarity index 94% rename from cmdb-api/api/flask_cas/routing.py rename to cmdb-api/api/lib/perm/authentication/cas/routing.py index 8dcba3c..27fc635 100644 --- a/cmdb-api/api/flask_cas/routing.py +++ b/cmdb-api/api/lib/perm/authentication/cas/routing.py @@ -3,8 +3,13 @@ import uuid import bs4 from flask import Blueprint -from flask import current_app, session, request, url_for, redirect -from flask_login import login_user, logout_user +from flask import current_app +from flask import redirect +from flask import request +from flask import session +from flask import url_for +from flask_login import login_user +from flask_login import logout_user from six.moves.urllib_request import urlopen from api.lib.perm.acl.cache import UserCache @@ -15,7 +20,8 @@ from .cas_urls import create_cas_validate_url blueprint = Blueprint('cas', __name__) -@blueprint.route('/api/sso/login') +@blueprint.route('/api/cas/login') +# @blueprint.route('/api/sso/login') def login(): """ This route has two purposes. First, it is used by the user @@ -63,7 +69,8 @@ def login(): return redirect(redirect_url) -@blueprint.route('/api/sso/logout') +@blueprint.route('/api/cas/logout') +# @blueprint.route('/api/sso/logout') def logout(): """ When the user accesses this route they are logged out. diff --git a/cmdb-api/api/lib/perm/authentication/oauth2/__init__.py b/cmdb-api/api/lib/perm/authentication/oauth2/__init__.py new file mode 100644 index 0000000..cb45334 --- /dev/null +++ b/cmdb-api/api/lib/perm/authentication/oauth2/__init__.py @@ -0,0 +1,26 @@ +# -*- coding:utf-8 -*- + +from flask import current_app + +from . import routing + + +class OAuth2(object): + def __init__(self, app=None, url_prefix=None): + self._app = app + if app is not None: + self.init_app(app, url_prefix) + + @staticmethod + def init_app(app, url_prefix=None): + # Configuration defaults + app.config.setdefault('OAUTH2_GRANT_TYPE', 'authorization_code') + app.config.setdefault('OAUTH2_RESPONSE_TYPE', 'code') + app.config.setdefault('OAUTH2_AFTER_LOGIN', '/') + + # Register Blueprint + app.register_blueprint(routing.blueprint, url_prefix=url_prefix) + + @property + def app(self): + return self._app or current_app diff --git a/cmdb-api/api/lib/perm/authentication/oauth2/routing.py b/cmdb-api/api/lib/perm/authentication/oauth2/routing.py new file mode 100644 index 0000000..ca5c0f7 --- /dev/null +++ b/cmdb-api/api/lib/perm/authentication/oauth2/routing.py @@ -0,0 +1,118 @@ +# -*- coding:utf-8 -*- + +import secrets +import uuid + +import requests +from flask import Blueprint +from flask import abort +from flask import current_app +from flask import redirect +from flask import request +from flask import session +from flask import url_for +from flask_login import login_user, logout_user +from six.moves.urllib.parse import urlencode + +from api.lib.perm.acl.cache import UserCache + +blueprint = Blueprint('oauth2', __name__) + + +@blueprint.route('/api/oauth2/login') +@blueprint.route('/api/sso/login') +def login(): + if request.values.get("next"): + session["next"] = request.values.get("next") + + session['oauth2_state'] = secrets.token_urlsafe(16) + + qs = urlencode({ + 'client_id': current_app.config['OAUTH2_CLIENT_ID'], + 'redirect_uri': url_for('oauth2.callback', _external=True), + 'response_type': current_app.config['OAUTH2_RESPONSE_TYPE'], + 'scope': ' '.join(current_app.config['OAUTH2_SCOPES'] or []), + 'state': session['oauth2_state'], + }) + + return redirect("{}?{}".format(current_app.config['OAUTH2_AUTHORIZE_URL'].split('?')[0], qs)) + + +@blueprint.route('/api/oauth2/callback') +def callback(): + redirect_url = session.get("next") or current_app.config.get("OAUTH2_AFTER_LOGIN") + + if request.values['state'] != session.get('oauth2_state'): + return abort(401, "state is invalid") + + if 'code' not in request.values: + return abort(401, 'code is invalid') + + response = requests.post(current_app.config['OAUTH2_TOKEN_URL'], data={ + 'client_id': current_app.config['OAUTH2_CLIENT_ID'], + 'client_secret': current_app.config['OAUTH2_CLIENT_SECRET'], + 'code': request.values['code'], + 'grant_type': current_app.config['OAUTH2_GRANT_TYPE'], + 'redirect_uri': url_for('oauth2.callback', _external=True), + }, headers={'Accept': 'application/json'}) + if response.status_code != 200: + current_app.logger.error(response.text) + return abort(401) + oauth2_token = response.json().get('access_token') + if not oauth2_token: + return abort(401) + + response = requests.get(current_app.config['OAUTH2_USER_INFO']['url'], headers={ + 'Authorization': 'Bearer {}'.format(oauth2_token), + 'Accept': 'application/json', + }) + if response.status_code != 200: + return abort(401) + + email = current_app.config['OAUTH2_USER_INFO']['email'](response.json()) + username = current_app.config['OAUTH2_USER_INFO']['username'](response.json()) + user = UserCache.get(username) + if user is None: + current_app.logger.info("create user: {}".format(username)) + from api.lib.perm.acl.user import UserCRUD + + user_dict = dict(username=username, email=email) + user_dict['password'] = uuid.uuid4().hex + + user = UserCRUD.add(**user_dict) + + # log the user in + login_user(user) + + from api.lib.perm.acl.acl import ACLManager + user_info = ACLManager.get_user_info(username) + + session["acl"] = dict(uid=user_info.get("uid"), + avatar=user.avatar if user else user_info.get("avatar"), + userId=user_info.get("uid"), + rid=user_info.get("rid"), + userName=user_info.get("username"), + nickName=user_info.get("nickname") or user_info.get("username"), + parentRoles=user_info.get("parents"), + childRoles=user_info.get("children"), + roleName=user_info.get("role")) + session["uid"] = user_info.get("uid") + + return redirect(redirect_url) + + +@blueprint.route('/api/oauth2/logout') +@blueprint.route('/api/sso/logout') +def logout(): + "acl" in session and session.pop("acl") + "uid" in session and session.pop("uid") + 'oauth2_state' in session and session.pop('oauth2_state') + "next" in session and session.pop("next") + + redirect_url = url_for('oauth2.login', _external=True, next=request.referrer) + + logout_user() + + current_app.logger.debug('Redirecting to: {0}'.format(redirect_url)) + + return redirect(redirect_url) diff --git a/cmdb-api/api/lib/perm/authentication/oidc/__init__.py b/cmdb-api/api/lib/perm/authentication/oidc/__init__.py new file mode 100644 index 0000000..67a5925 --- /dev/null +++ b/cmdb-api/api/lib/perm/authentication/oidc/__init__.py @@ -0,0 +1,25 @@ +# -*- coding:utf-8 -*- + +from flask import current_app + +from . import routing + + +class OIDC(object): + def __init__(self, app=None, url_prefix=None): + self._app = app + if app is not None: + self.init_app(app, url_prefix) + + @staticmethod + def init_app(app, url_prefix=None): + # Configuration defaults + app.config.setdefault('OIDC_GRANT_TYPE', 'authorization_code') + app.config.setdefault('OIDC_RESPONSE_TYPE', 'code') + app.config.setdefault('OIDC_AFTER_LOGIN', '/') + # Register Blueprint + app.register_blueprint(routing.blueprint, url_prefix=url_prefix) + + @property + def app(self): + return self._app or current_app diff --git a/cmdb-api/api/lib/perm/authentication/oidc/routing.py b/cmdb-api/api/lib/perm/authentication/oidc/routing.py new file mode 100644 index 0000000..fce284a --- /dev/null +++ b/cmdb-api/api/lib/perm/authentication/oidc/routing.py @@ -0,0 +1,118 @@ +# -*- coding:utf-8 -*- + +import secrets +import uuid + +import requests +from flask import Blueprint +from flask import abort +from flask import current_app +from flask import redirect +from flask import request +from flask import session +from flask import url_for +from flask_login import login_user, logout_user +from six.moves.urllib.parse import urlencode + +from api.lib.perm.acl.cache import UserCache + +blueprint = Blueprint('oidc', __name__) + + +@blueprint.route('/api/oidc/login') +@blueprint.route('/api/sso/login') +def login(): + if request.values.get("next"): + session["next"] = request.values.get("next") + + session['oidc_state'] = secrets.token_urlsafe(16) + + qs = urlencode({ + 'client_id': current_app.config['OIDC_CLIENT_ID'], + 'redirect_uri': url_for('oidc.callback', _external=True), + 'response_type': current_app.config['OIDC_RESPONSE_TYPE'], + 'scope': ' '.join(current_app.config['OIDC_SCOPES'] or []), + 'state': session['oidc_state'], + }) + + return redirect("{}?{}".format(current_app.config['OIDC_AUTHORIZE_URL'].split('?')[0], qs)) + + +@blueprint.route('/api/oidc/callback') +def callback(): + redirect_url = session.get("next") or current_app.config.get("OIDC_AFTER_LOGIN") + + if request.values['state'] != session.get('oidc_state'): + return abort(401, "state is invalid") + + if 'code' not in request.values: + return abort(401, 'code is invalid') + + response = requests.post(current_app.config['OIDC_TOKEN_URL'], data={ + 'client_id': current_app.config['OIDC_CLIENT_ID'], + 'client_secret': current_app.config['OIDC_CLIENT_SECRET'], + 'code': request.values['code'], + 'grant_type': current_app.config['OIDC_GRANT_TYPE'], + 'redirect_uri': url_for('oidc.callback', _external=True), + }, headers={'Accept': 'application/json'}) + if response.status_code != 200: + current_app.logger.error(response.text) + return abort(401) + oidc_token = response.json().get('access_token') + if not oidc_token: + return abort(401) + + response = requests.get(current_app.config['OIDC_USER_INFO']['url'], headers={ + 'Authorization': 'Bearer {}'.format(oidc_token), + 'Accept': 'application/json', + }) + if response.status_code != 200: + return abort(401) + + email = current_app.config['OIDC_USER_INFO']['email'](response.json()) + username = current_app.config['OIDC_USER_INFO']['username'](response.json()) + user = UserCache.get(username) + if user is None: + current_app.logger.info("create user: {}".format(username)) + from api.lib.perm.acl.user import UserCRUD + + user_dict = dict(username=username, email=email) + user_dict['password'] = uuid.uuid4().hex + + user = UserCRUD.add(**user_dict) + + # log the user in + login_user(user) + + from api.lib.perm.acl.acl import ACLManager + user_info = ACLManager.get_user_info(username) + + session["acl"] = dict(uid=user_info.get("uid"), + avatar=user.avatar if user else user_info.get("avatar"), + userId=user_info.get("uid"), + rid=user_info.get("rid"), + userName=user_info.get("username"), + nickName=user_info.get("nickname") or user_info.get("username"), + parentRoles=user_info.get("parents"), + childRoles=user_info.get("children"), + roleName=user_info.get("role")) + session["uid"] = user_info.get("uid") + + return redirect(redirect_url) + + +@blueprint.route('/api/oidc/logout') +@blueprint.route('/api/sso/logout') +def logout(): + "acl" in session and session.pop("acl") + "uid" in session and session.pop("uid") + 'oidc_state' in session and session.pop('oidc_state') + "next" in session and session.pop("next") + + redirect_url = url_for('oidc.login', _external=True, next=request.referrer) + + logout_user() + + current_app.logger.debug('Redirecting to: {0}'.format(redirect_url)) + + return redirect(redirect_url) diff --git a/cmdb-api/settings.example.py b/cmdb-api/settings.example.py index d734473..e125e3d 100644 --- a/cmdb-api/settings.example.py +++ b/cmdb-api/settings.example.py @@ -67,10 +67,12 @@ ONCE = { } } -# # SSO +# =============================== Authentication =========================================================== + +# # CAS AUTH_WITH_CAS = False -CAS_SERVER = "http://sso.xxx.com" -CAS_VALIDATE_SERVER = "http://sso.xxx.com" +CAS_SERVER = "https://{your-casdoor-hostname}" +CAS_VALIDATE_SERVER = "https://{your-casdoor-hostname}" CAS_LOGIN_ROUTE = "/cas/built-in/cas/login" CAS_LOGOUT_ROUTE = "/cas/built-in/cas/logout" CAS_VALIDATE_ROUTE = "/cas/built-in/cas/serviceValidate" @@ -83,11 +85,40 @@ CAS_USER_MAP = { "avatar": {"tag": "cas:attribute", "attrs": {"name": "avatar"}}, } -# # ldap +# # OAuth2.0 +AUTH_WITH_OAUTH2 = False +OAUTH2_CLIENT_ID = "" +OAUTH2_CLIENT_SECRET = "" +OAUTH2_AUTHORIZE_URL = "https://{your-casdoor-hostname}/login/oauth/authorize" +OAUTH2_TOKEN_URL = "https://{your-casdoor-hostname}/api/login/oauth/access_token" +OAUTH2_USER_INFO = { + "url": "https://{your-casdoor-hostname}/api/userinfo", + "email": lambda x: x['email'], + "username": lambda x: x['name'] +} +OAUTH2_SCOPES = ["profile email"] +OAUTH2_AFTER_LOGIN = "/" + +# # OIDC +AUTH_WITH_OIDC = False +OIDC_CLIENT_ID = "" +OIDC_CLIENT_SECRET = "" +OIDC_AUTHORIZE_URL = "https://{your-casdoor-hostname}/login/oauth/authorize" +OIDC_TOKEN_URL = "https://{your-casdoor-hostname}/api/login/oauth/access_token" +OIDC_USER_INFO = { + "url": "https://{your-casdoor-hostname}/api/userinfo", + "email": lambda x: x['email'], + "username": lambda x: x['name'] +} +OIDC_SCOPES = ["openid profile email"] +OIDC_AFTER_LOGIN = "/" + +# # LDAP AUTH_WITH_LDAP = False LDAP_SERVER = '' LDAP_DOMAIN = '' LDAP_USER_DN = 'cn={},ou=users,dc=xxx,dc=com' +# ========================================================================================================== # # pagination DEFAULT_PAGE_COUNT = 50