diff --git a/cmdb-api/api/app.py b/cmdb-api/api/app.py index 757a5da..6c9d15f 100644 --- a/cmdb-api/api/app.py +++ b/cmdb-api/api/app.py @@ -21,7 +21,6 @@ from api.extensions import (bcrypt, cache, celery, cors, db, es, login_manager, from api.extensions import inner_secrets 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 @@ -98,7 +97,6 @@ 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/database.py b/cmdb-api/api/lib/database.py index d991d1a..634a99f 100644 --- a/cmdb-api/api/lib/database.py +++ b/cmdb-api/api/lib/database.py @@ -94,7 +94,7 @@ class CRUDMixin(FormatMixin): if any((isinstance(_id, six.string_types) and _id.isdigit(), isinstance(_id, (six.integer_types, float))), ): obj = getattr(cls, "query").get(int(_id)) - if obj and not obj.deleted: + if obj and not getattr(obj, 'deleted', False): return obj @classmethod diff --git a/cmdb-api/api/lib/perm/acl/audit.py b/cmdb-api/api/lib/perm/acl/audit.py index 4732b9c..bfce104 100644 --- a/cmdb-api/api/lib/perm/acl/audit.py +++ b/cmdb-api/api/lib/perm/acl/audit.py @@ -1,14 +1,19 @@ # -*- coding:utf-8 -*- + +import datetime import itertools import json from enum import Enum from typing import List -from flask import has_request_context, request +from flask import has_request_context +from flask import request from flask_login import current_user from sqlalchemy import func +from api.extensions import db from api.lib.perm.acl import AppCache +from api.models.acl import AuditLoginLog from api.models.acl import AuditPermissionLog from api.models.acl import AuditResourceLog from api.models.acl import AuditRoleLog @@ -283,6 +288,27 @@ class AuditCRUD(object): return data + @staticmethod + def search_login(_, q=None, page=1, page_size=10, start=None, end=None): + query = db.session.query(AuditLoginLog) + + if start: + query = query.filter(AuditLoginLog.login_at >= start) + if end: + query = query.filter(AuditLoginLog.login_at <= end) + + if q: + query = query.filter(AuditLoginLog.username == q) + + records = query.order_by( + AuditLoginLog.id.desc()).offset((page - 1) * page_size).limit(page_size).all() + + data = { + 'data': [r.to_dict() for r in records], + } + + return data + @classmethod def add_role_log(cls, app_id, operate_type: AuditOperateType, scope: AuditScope, link_id: int, origin: dict, current: dict, extra: dict, @@ -348,3 +374,24 @@ class AuditCRUD(object): AuditTriggerLog.create(app_id=app_id, trigger_id=trigger_id, operate_uid=user_id, operate_type=operate_type.value, origin=origin, current=current, extra=extra, source=source.value) + + @classmethod + def add_login_log(cls, username, is_ok, description, _id=None, logout_at=None): + if _id is not None: + existed = AuditLoginLog.get_by_id(_id) + if existed is not None: + existed.update(logout_at=logout_at) + return + + payload = dict(username=username, + is_ok=is_ok, + description=description, + logout_at=logout_at, + ip=request.headers.get('X-Real-IP') or request.remote_addr, + browser=request.headers.get('User-Agent'), + ) + + if logout_at is None: + payload['login_at'] = datetime.datetime.now() + + return AuditLoginLog.create(**payload).id diff --git a/cmdb-api/api/lib/perm/acl/resp_format.py b/cmdb-api/api/lib/perm/acl/resp_format.py index 25f6bdc..9ea6c5b 100644 --- a/cmdb-api/api/lib/perm/acl/resp_format.py +++ b/cmdb-api/api/lib/perm/acl/resp_format.py @@ -4,6 +4,9 @@ from api.lib.resp_format import CommonErrFormat class ErrFormat(CommonErrFormat): + login_succeed = "登录成功" + ldap_connection_failed = "连接LDAP服务失败" + invalid_password = "密码验证失败" auth_only_with_app_token_failed = "应用 Token验证失败" session_invalid = "您不是应用管理员 或者 session失效(尝试一下退出重新登录)" @@ -17,11 +20,11 @@ class ErrFormat(CommonErrFormat): role_exists = "角色 {} 已经存在!" global_role_not_found = "全局角色 {} 不存在!" global_role_exists = "全局角色 {} 已经存在!" - user_role_delete_invalid = "删除用户角色, 请在 用户管理 页面操作!" resource_no_permission = "您没有资源: {} 的 {} 权限" admin_required = "需要管理员权限" role_required = "需要角色: {}" + user_role_delete_invalid = "删除用户角色, 请在 用户管理 页面操作!" app_is_ready_existed = "应用 {} 已经存在" app_not_found = "应用 {} 不存在!" diff --git a/cmdb-api/api/lib/perm/auth.py b/cmdb-api/api/lib/perm/auth.py index 76e2481..c00f8b0 100644 --- a/cmdb-api/api/lib/perm/auth.py +++ b/cmdb-api/api/lib/perm/auth.py @@ -93,6 +93,9 @@ def _auth_with_token(): def _auth_with_ip_white_list(): + if request.url.endswith("acl/users/info"): + return False + ip = request.headers.get('X-Real-IP') or request.remote_addr key = request.values.get('_key') secret = request.values.get('_secret') diff --git a/cmdb-api/api/lib/perm/authentication/cas/routing.py b/cmdb-api/api/lib/perm/authentication/cas/routing.py index 27fc635..271010b 100644 --- a/cmdb-api/api/lib/perm/authentication/cas/routing.py +++ b/cmdb-api/api/lib/perm/authentication/cas/routing.py @@ -1,4 +1,5 @@ # -*- coding:utf-8 -*- +import datetime import uuid import bs4 @@ -12,7 +13,11 @@ from flask_login import login_user from flask_login import logout_user from six.moves.urllib_request import urlopen +from api.lib.common_setting.common_data import AuthenticateDataCRUD +from api.lib.common_setting.const import AuthenticateType +from api.lib.perm.acl.audit import AuditCRUD from api.lib.perm.acl.cache import UserCache +from api.lib.perm.acl.resp_format import ErrFormat from .cas_urls import create_cas_login_url from .cas_urls import create_cas_logout_url from .cas_urls import create_cas_validate_url @@ -21,7 +26,7 @@ blueprint = Blueprint('cas', __name__) @blueprint.route('/api/cas/login') -# @blueprint.route('/api/sso/login') +@blueprint.route('/api/sso/login') def login(): """ This route has two purposes. First, it is used by the user @@ -34,6 +39,7 @@ def login(): If validation was successful the logged in username is saved in the user's session under the key `CAS_USERNAME_SESSION_KEY`. """ + config = AuthenticateDataCRUD(AuthenticateType.CAS).get() cas_token_session_key = current_app.config['CAS_TOKEN_SESSION_KEY'] if request.values.get("next"): @@ -41,8 +47,8 @@ def login(): _service = url_for('cas.login', _external=True) redirect_url = create_cas_login_url( - current_app.config['CAS_SERVER'], - current_app.config['CAS_LOGIN_ROUTE'], + config['cas_server'], + config['cas_login_route'], _service) if 'ticket' in request.args: @@ -51,30 +57,38 @@ def login(): if request.args.get('ticket'): if validate(request.args['ticket']): - redirect_url = session.get("next") or current_app.config.get("CAS_AFTER_LOGIN") + redirect_url = session.get("next") or config.get("cas_after_login") or "/" username = session.get("CAS_USERNAME") user = UserCache.get(username) login_user(user) session.permanent = True + _id = AuditCRUD.add_login_log(username, True, ErrFormat.login_succeed) + session['LOGIN_ID'] = _id + else: del session[cas_token_session_key] redirect_url = create_cas_login_url( - current_app.config['CAS_SERVER'], - current_app.config['CAS_LOGIN_ROUTE'], + config['cas_server'], + config['cas_login_route'], url_for('cas.login', _external=True), renew=True) + + AuditCRUD.add_login_log(session.get("CAS_USERNAME"), False, ErrFormat.invalid_password) + current_app.logger.info("redirect to: {0}".format(redirect_url)) return redirect(redirect_url) @blueprint.route('/api/cas/logout') -# @blueprint.route('/api/sso/logout') +@blueprint.route('/api/sso/logout') def logout(): """ When the user accesses this route they are logged out. """ + config = AuthenticateDataCRUD(AuthenticateType.CAS).get() + current_app.logger.info(config) cas_username_session_key = current_app.config['CAS_USERNAME_SESSION_KEY'] cas_token_session_key = current_app.config['CAS_TOKEN_SESSION_KEY'] @@ -86,12 +100,14 @@ def logout(): "next" in session and session.pop("next") redirect_url = create_cas_logout_url( - current_app.config['CAS_SERVER'], - current_app.config['CAS_LOGOUT_ROUTE'], + config['cas_server'], + config['cas_logout_route'], url_for('cas.login', _external=True, next=request.referrer)) logout_user() + AuditCRUD.add_login_log(None, None, None, _id=session.get('LOGIN_ID'), logout_at=datetime.datetime.now()) + current_app.logger.debug('Redirecting to: {0}'.format(redirect_url)) return redirect(redirect_url) @@ -104,14 +120,15 @@ def validate(ticket): and the validated username is saved in the session under the key `CAS_USERNAME_SESSION_KEY`. """ + config = AuthenticateDataCRUD(AuthenticateType.CAS).get() cas_username_session_key = current_app.config['CAS_USERNAME_SESSION_KEY'] current_app.logger.debug("validating token {0}".format(ticket)) cas_validate_url = create_cas_validate_url( - current_app.config['CAS_VALIDATE_SERVER'], - current_app.config['CAS_VALIDATE_ROUTE'], + config['cas_validate_server'], + config['cas_validate_route'], url_for('cas.login', _external=True), ticket) @@ -138,14 +155,12 @@ def validate(ticket): current_app.logger.info("create user: {}".format(username)) from api.lib.perm.acl.user import UserCRUD soup = bs4.BeautifulSoup(response) - cas_user_map = current_app.config.get('CAS_USER_MAP') - + cas_user_map = config.get('cas_user_map') user_dict = dict() for k in cas_user_map: v = soup.find(cas_user_map[k]['tag'], cas_user_map[k].get('attrs', {})) user_dict[k] = v and v.text or None user_dict['password'] = uuid.uuid4().hex - UserCRUD.add(**user_dict) from api.lib.perm.acl.acl import ACLManager diff --git a/cmdb-api/api/lib/perm/authentication/ldap.py b/cmdb-api/api/lib/perm/authentication/ldap.py new file mode 100644 index 0000000..a747ae6 --- /dev/null +++ b/cmdb-api/api/lib/perm/authentication/ldap.py @@ -0,0 +1,67 @@ +# -*- coding:utf-8 -*- + +import uuid + +from flask import abort +from flask import current_app +from flask import session +from ldap3 import ALL +from ldap3 import AUTO_BIND_NO_TLS +from ldap3 import Connection +from ldap3 import Server +from ldap3.core.exceptions import LDAPBindError +from ldap3.core.exceptions import LDAPCertificateError +from ldap3.core.exceptions import LDAPSocketOpenError + +from api.lib.common_setting.common_data import AuthenticateDataCRUD +from api.lib.common_setting.const import AuthenticateType +from api.lib.perm.acl.audit import AuditCRUD +from api.lib.perm.acl.resp_format import ErrFormat +from api.models.acl import User + + +def authenticate_with_ldap(username, password): + config = AuthenticateDataCRUD(AuthenticateType.CAS).get() + + server = Server(config.get('LDAP').get('ldap_server'), get_info=ALL, connect_timeout=3) + if '@' in username: + email = username + who = config['LDAP'].get('ldap_user_dn').format(username.split('@')[0]) + else: + who = config['LDAP'].get('ldap_user_dn').format(username) + email = "{}@{}".format(who, config['LDAP'].get('ldap_domain')) + + username = username.split('@')[0] + user = User.query.get_by_username(username) + try: + if not password: + raise LDAPCertificateError + + try: + conn = Connection(server, user=who, password=password, auto_bind=AUTO_BIND_NO_TLS) + except LDAPBindError: + conn = Connection(server, + user=f"{username}@{config['LDAP'].get('ldap_domain')}", + password=password, + auto_bind=AUTO_BIND_NO_TLS) + + if conn.result['result'] != 0: + AuditCRUD.add_login_log(username, False, ErrFormat.invalid_password) + raise LDAPBindError + else: + _id = AuditCRUD.add_login_log(username, True, ErrFormat.login_succeed) + session['LOGIN_ID'] = _id + + if not user: + from api.lib.perm.acl.user import UserCRUD + user = UserCRUD.add(username=username, email=email, password=uuid.uuid4().hex) + + return user, True + + except LDAPBindError as e: + current_app.logger.info(e) + return user, False + + except LDAPSocketOpenError as e: + current_app.logger.info(e) + return abort(403, ErrFormat.ldap_connection_failed) diff --git a/cmdb-api/api/lib/perm/authentication/oauth2/__init__.py b/cmdb-api/api/lib/perm/authentication/oauth2/__init__.py index cb45334..1b7d02e 100644 --- a/cmdb-api/api/lib/perm/authentication/oauth2/__init__.py +++ b/cmdb-api/api/lib/perm/authentication/oauth2/__init__.py @@ -18,6 +18,10 @@ class OAuth2(object): app.config.setdefault('OAUTH2_RESPONSE_TYPE', 'code') app.config.setdefault('OAUTH2_AFTER_LOGIN', '/') + 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) diff --git a/cmdb-api/api/lib/perm/authentication/oauth2/routing.py b/cmdb-api/api/lib/perm/authentication/oauth2/routing.py index ca5c0f7..828855e 100644 --- a/cmdb-api/api/lib/perm/authentication/oauth2/routing.py +++ b/cmdb-api/api/lib/perm/authentication/oauth2/routing.py @@ -1,5 +1,6 @@ # -*- coding:utf-8 -*- +import datetime import secrets import uuid @@ -14,69 +15,80 @@ from flask import url_for from flask_login import login_user, logout_user from six.moves.urllib.parse import urlencode +from api.lib.common_setting.common_data import AuthenticateDataCRUD +from api.lib.perm.acl.audit import AuditCRUD from api.lib.perm.acl.cache import UserCache +from api.lib.perm.acl.resp_format import ErrFormat blueprint = Blueprint('oauth2', __name__) -@blueprint.route('/api/oauth2/login') -@blueprint.route('/api/sso/login') -def login(): +@blueprint.route('/api//login') +def login(auth_type): + config = AuthenticateDataCRUD(auth_type.upper()).get() + if request.values.get("next"): session["next"] = request.values.get("next") - session['oauth2_state'] = secrets.token_urlsafe(16) + session[f'{auth_type}_state'] = secrets.token_urlsafe(16) + + auth_type = auth_type.upper() 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'], + 'client_id': config['client_id'], + 'redirect_uri': url_for('oauth2.callback', auth_type=auth_type.lower(), _external=True), + 'response_type': current_app.config[f'{auth_type}_RESPONSE_TYPE'], + 'scope': ' '.join(config['scopes'] or []), + 'state': session[f'{auth_type.lower()}_state'], }) - return redirect("{}?{}".format(current_app.config['OAUTH2_AUTHORIZE_URL'].split('?')[0], qs)) + return redirect("{}?{}".format(config['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") +@blueprint.route('/api//callback') +def callback(auth_type): + auth_type = auth_type.upper() + config = AuthenticateDataCRUD(auth_type).get() - if request.values['state'] != session.get('oauth2_state'): + redirect_url = session.get("next") or config.get('after_login') or '/' + + if request.values['state'] != session.get(f'{auth_type.lower()}_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'], + response = requests.post(config['token_url'], data={ + 'client_id': config['client_id'], + 'client_secret': config['client_secret'], 'code': request.values['code'], - 'grant_type': current_app.config['OAUTH2_GRANT_TYPE'], - 'redirect_uri': url_for('oauth2.callback', _external=True), + 'grant_type': current_app.config[f'{auth_type}_GRANT_TYPE'], + 'redirect_uri': url_for('oauth2.callback', auth_type=auth_type.lower(), _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: + access_token = response.json().get('access_token') + if not access_token: return abort(401) - response = requests.get(current_app.config['OAUTH2_USER_INFO']['url'], headers={ - 'Authorization': 'Bearer {}'.format(oauth2_token), + response = requests.get(config['user_info']['url'], headers={ + 'Authorization': 'Bearer {}'.format(access_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()) + res = response.json() + email = res.get(config['user_info']['email']) + username = res.get(config['user_info']['username']) + avatar = res.get(config['user_info'].get('avatar')) 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 = dict(username=username, email=email, avatar=avatar) user_dict['password'] = uuid.uuid4().hex user = UserCRUD.add(**user_dict) @@ -98,21 +110,25 @@ def callback(): roleName=user_info.get("role")) session["uid"] = user_info.get("uid") + _id = AuditCRUD.add_login_log(username, True, ErrFormat.login_succeed) + session['LOGIN_ID'] = _id + return redirect(redirect_url) -@blueprint.route('/api/oauth2/logout') -@blueprint.route('/api/sso/logout') -def logout(): +@blueprint.route('/api//logout') +def logout(auth_type): "acl" in session and session.pop("acl") "uid" in session and session.pop("uid") - 'oauth2_state' in session and session.pop('oauth2_state') + f'{auth_type}_state' in session and session.pop(f'{auth_type}_state') "next" in session and session.pop("next") - redirect_url = url_for('oauth2.login', _external=True, next=request.referrer) + redirect_url = url_for('oauth2.login', auth_type=auth_type, _external=True, next=request.referrer) logout_user() current_app.logger.debug('Redirecting to: {0}'.format(redirect_url)) + AuditCRUD.add_login_log(None, None, None, _id=session.get('LOGIN_ID'), logout_at=datetime.datetime.now()) + 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 deleted file mode 100644 index 67a5925..0000000 --- a/cmdb-api/api/lib/perm/authentication/oidc/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- 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 deleted file mode 100644 index fce284a..0000000 --- a/cmdb-api/api/lib/perm/authentication/oidc/routing.py +++ /dev/null @@ -1,118 +0,0 @@ -# -*- 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/api/models/acl.py b/cmdb-api/api/models/acl.py index b0683ec..d4c9efa 100644 --- a/cmdb-api/api/models/acl.py +++ b/cmdb-api/api/models/acl.py @@ -5,17 +5,18 @@ import copy import hashlib from datetime import datetime -from ldap3 import Server, Connection, ALL -from ldap3.core.exceptions import LDAPBindError, LDAPCertificateError from flask import current_app +from flask import session from flask_sqlalchemy import BaseQuery from api.extensions import db from api.lib.database import CRUDModel from api.lib.database import Model +from api.lib.database import Model2 from api.lib.database import SoftDeleteMixin from api.lib.perm.acl.const import ACL_QUEUE from api.lib.perm.acl.const import OperateType +from api.lib.perm.acl.resp_format import ErrFormat class App(Model): @@ -28,21 +29,26 @@ class App(Model): class UserQuery(BaseQuery): - def _join(self, *args, **kwargs): - super(UserQuery, self)._join(*args, **kwargs) def authenticate(self, login, password): + from api.lib.perm.acl.audit import AuditCRUD + user = self.filter(db.or_(User.username == login, User.email == login)).filter(User.deleted.is_(False)).filter(User.block == 0).first() if user: - current_app.logger.info(user) authenticated = user.check_password(password) if authenticated: - from api.tasks.acl import op_record - op_record.apply_async(args=(None, login, OperateType.LOGIN, ["ACL"]), queue=ACL_QUEUE) + _id = AuditCRUD.add_login_log(login, True, ErrFormat.login_succeed) + session['LOGIN_ID'] = _id + else: + AuditCRUD.add_login_log(login, False, ErrFormat.invalid_password) else: authenticated = False + AuditCRUD.add_login_log(login, False, ErrFormat.user_not_found.format(login)) + + current_app.logger.info(("login", login, user, authenticated)) + return user, authenticated def authenticate_with_key(self, key, secret, args, path): @@ -57,38 +63,6 @@ class UserQuery(BaseQuery): return user, authenticated - def authenticate_with_ldap(self, username, password): - server = Server(current_app.config.get('LDAP_SERVER'), get_info=ALL) - if '@' in username: - email = username - who = current_app.config.get('LDAP_USER_DN').format(username.split('@')[0]) - else: - who = current_app.config.get('LDAP_USER_DN').format(username) - email = "{}@{}".format(who, current_app.config.get('LDAP_DOMAIN')) - - username = username.split('@')[0] - user = self.get_by_username(username) - try: - if not password: - raise LDAPCertificateError - - conn = Connection(server, user=who, password=password) - conn.bind() - if conn.result['result'] != 0: - raise LDAPBindError - conn.unbind() - - if not user: - from api.lib.perm.acl.user import UserCRUD - user = UserCRUD.add(username=username, email=email) - - from api.tasks.acl import op_record - op_record.apply_async(args=(None, username, OperateType.LOGIN, ["ACL"]), queue=ACL_QUEUE) - - return user, True - except LDAPBindError: - return user, False - def search(self, key): query = self.filter(db.or_(User.email == key, User.nickname.ilike('%' + key + '%'), @@ -138,6 +112,7 @@ class User(CRUDModel, SoftDeleteMixin): wx_id = db.Column(db.String(32)) employee_id = db.Column(db.String(16), index=True) avatar = db.Column(db.String(128)) + # apps = db.Column(db.JSON) def __str__(self): @@ -168,8 +143,6 @@ class User(CRUDModel, SoftDeleteMixin): class RoleQuery(BaseQuery): - def _join(self, *args, **kwargs): - super(RoleQuery, self)._join(*args, **kwargs) def authenticate(self, login, password): role = self.filter(Role.name == login).first() @@ -377,3 +350,16 @@ class AuditTriggerLog(Model): current = db.Column(db.JSON, default=dict(), comment='当前数据') extra = db.Column(db.JSON, default=dict(), comment='权限名') source = db.Column(db.String(16), default='', comment='来源') + + +class AuditLoginLog(Model2): + __tablename__ = "acl_audit_login_logs" + + username = db.Column(db.String(64), index=True) + channel = db.Column(db.Enum('web', 'api'), default="web") + ip = db.Column(db.String(15)) + browser = db.Column(db.String(256)) + description = db.Column(db.String(128)) + is_ok = db.Column(db.Boolean) + login_at = db.Column(db.DateTime) + logout_at = db.Column(db.DateTime) diff --git a/cmdb-api/api/views/acl/audit.py b/cmdb-api/api/views/acl/audit.py index ae4c20e..9826bb9 100644 --- a/cmdb-api/api/views/acl/audit.py +++ b/cmdb-api/api/views/acl/audit.py @@ -24,6 +24,7 @@ class AuditLogView(APIView): 'role': AuditCRUD.search_role, 'trigger': AuditCRUD.search_trigger, 'resource': AuditCRUD.search_resource, + 'login': AuditCRUD.search_login, } if name not in func_map: abort(400, f'wrong {name}, please use {func_map.keys()}') diff --git a/cmdb-api/api/views/acl/login.py b/cmdb-api/api/views/acl/login.py index 09ee89a..7164d54 100644 --- a/cmdb-api/api/views/acl/login.py +++ b/cmdb-api/api/views/acl/login.py @@ -8,11 +8,15 @@ from flask import abort from flask import current_app from flask import request from flask import session -from flask_login import login_user, logout_user +from flask_login import login_user +from flask_login import logout_user +from api.lib.common_setting.common_data import AuthenticateDataCRUD +from api.lib.common_setting.const import AuthenticateType from api.lib.decorator import args_required from api.lib.decorator import args_validate from api.lib.perm.acl.acl import ACLManager +from api.lib.perm.acl.audit import AuditCRUD from api.lib.perm.acl.cache import RoleCache from api.lib.perm.acl.cache import User from api.lib.perm.acl.cache import UserCache @@ -34,8 +38,10 @@ class LoginView(APIView): username = request.values.get("username") or request.values.get("email") password = request.values.get("password") _role = None - if current_app.config.get('AUTH_WITH_LDAP'): - user, authenticated = User.query.authenticate_with_ldap(username, password) + config = AuthenticateDataCRUD(AuthenticateType.LDAP).get() + if config.get('LDAP', {}).get('enabled'): + from api.lib.perm.authentication.ldap import authenticate_with_ldap + user, authenticated = authenticate_with_ldap(username, password) else: user, authenticated = User.query.authenticate(username, password) if not user: @@ -176,4 +182,7 @@ class LogoutView(APIView): @auth_abandoned def post(self): logout_user() + + AuditCRUD.add_login_log(None, None, None, _id=session.get('LOGIN_ID'), logout_at=datetime.datetime.now()) + self.jsonify(code=200) diff --git a/cmdb-api/settings.example.py b/cmdb-api/settings.example.py index e125e3d..4887092 100644 --- a/cmdb-api/settings.example.py +++ b/cmdb-api/settings.example.py @@ -11,10 +11,10 @@ from environs import Env env = Env() env.read_env() -ENV = env.str("FLASK_ENV", default="production") -DEBUG = ENV == "development" -SECRET_KEY = env.str("SECRET_KEY") -BCRYPT_LOG_ROUNDS = env.int("BCRYPT_LOG_ROUNDS", default=13) +ENV = env.str('FLASK_ENV', default='production') +DEBUG = ENV == 'development' +SECRET_KEY = env.str('SECRET_KEY') +BCRYPT_LOG_ROUNDS = env.int('BCRYPT_LOG_ROUNDS', default=13) DEBUG_TB_ENABLED = DEBUG DEBUG_TB_INTERCEPT_REDIRECTS = False @@ -23,7 +23,7 @@ ERROR_CODES = [400, 401, 403, 404, 405, 500, 502] # # database SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://{user}:{password}@127.0.0.1:3306/{db}?charset=utf8' SQLALCHEMY_BINDS = { - "user": 'mysql+pymysql://{user}:{password}@127.0.0.1:3306/{db}?charset=utf8' + 'user': 'mysql+pymysql://{user}:{password}@127.0.0.1:3306/{db}?charset=utf8' } SQLALCHEMY_ECHO = False SQLALCHEMY_TRACK_MODIFICATIONS = False @@ -32,11 +32,11 @@ SQLALCHEMY_ENGINE_OPTIONS = { } # # cache -CACHE_TYPE = "redis" -CACHE_REDIS_HOST = "127.0.0.1" +CACHE_TYPE = 'redis' +CACHE_REDIS_HOST = '127.0.0.1' CACHE_REDIS_PORT = 6379 -CACHE_REDIS_PASSWORD = "" -CACHE_KEY_PREFIX = "CMDB::" +CACHE_REDIS_PASSWORD = '' +CACHE_KEY_PREFIX = 'CMDB::' CACHE_DEFAULT_TIMEOUT = 3000 # # log @@ -55,10 +55,10 @@ DEFAULT_MAIL_SENDER = '' # # queue CELERY = { - "broker_url": 'redis://127.0.0.1:6379/2', - "result_backend": "redis://127.0.0.1:6379/2", - "broker_vhost": "/", - "broker_connection_retry_on_startup": True + 'broker_url': 'redis://127.0.0.1:6379/2', + 'result_backend': 'redis://127.0.0.1:6379/2', + 'broker_vhost': '/', + 'broker_connection_retry_on_startup': True } ONCE = { 'backend': 'celery_once.backends.Redis', @@ -70,68 +70,78 @@ ONCE = { # =============================== Authentication =========================================================== # # CAS -AUTH_WITH_CAS = False -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" -CAS_AFTER_LOGIN = "/" -CAS_USER_MAP = { - "username": {"tag": "cas:user"}, - "nickname": {"tag": "cas:attribute", "attrs": {"name": "displayName"}}, - "email": {"tag": "cas:attribute", "attrs": {"name": "email"}}, - "mobile": {"tag": "cas:attribute", "attrs": {"name": "phone"}}, - "avatar": {"tag": "cas:attribute", "attrs": {"name": "avatar"}}, -} +CAS = dict( + enabled=False, + cas_server='https://{your-CASServer-hostname}', + cas_validate_server='https://{your-CASServer-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', + cas_after_login='/', + cas_user_map={ + 'username': {'tag': 'cas:user'}, + 'nickname': {'tag': 'cas:attribute', 'attrs': {'name': 'displayName'}}, + 'email': {'tag': 'cas:attribute', 'attrs': {'name': 'email'}}, + 'mobile': {'tag': 'cas:attribute', 'attrs': {'name': 'phone'}}, + 'avatar': {'tag': 'cas:attribute', 'attrs': {'name': 'avatar'}}, + } +) # # 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 = "/" +OAUTH2 = dict( + enabled=False, + client_id='', + client_secret='', + authorize_url='https://{your-OAuth2Server-hostname}/login/oauth/authorize', + token_url='https://{your-OAuth2Server-hostname}/api/login/oauth/access_token', + scopes=['profile', 'email'], + user_info={ + 'url': 'https://{your-OAuth2Server-hostname}/api/userinfo', + 'email': 'email', + 'username': 'name', + 'avatar': 'picture' + }, + 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 = "/" +OIDC = dict( + enabled=False, + client_id='', + client_secret='', + authorize_url='https://{your-OIDCServer-hostname}/login/oauth/authorize', + token_url='https://{your-OIDCServer-hostname}/api/login/oauth/access_token', + scopes=['openid', 'profile', 'email'], + user_info={ + 'url': 'https://{your-OIDCServer-hostname}/api/userinfo', + 'email': 'email', + 'username': 'name', + 'avatar': 'picture' + }, + after_login='/' +) # # LDAP -AUTH_WITH_LDAP = False -LDAP_SERVER = '' -LDAP_DOMAIN = '' -LDAP_USER_DN = 'cn={},ou=users,dc=xxx,dc=com' +LDAP = dict( + enabled=False, + ldap_server='', + ldap_domain='', + ldap_user_dn='cn={},ou=users,dc=xxx,dc=com' +) # ========================================================================================================== # # pagination DEFAULT_PAGE_COUNT = 50 # # permission -WHITE_LIST = ["127.0.0.1"] +WHITE_LIST = ['127.0.0.1'] USE_ACL = True # # elastic search ES_HOST = '127.0.0.1' USE_ES = False -BOOL_TRUE = ['true', 'TRUE', 'True', True, '1', 1, "Yes", "YES", "yes", 'Y', 'y'] +BOOL_TRUE = ['true', 'TRUE', 'True', True, '1', 1, 'Yes', 'YES', 'yes', 'Y', 'y'] # # messenger USE_MESSENGER = True diff --git a/docker-compose.yml b/docker-compose.yml index 9465697..7d33251 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.5' services: cmdb-db: - image: registry.cn-hangzhou.aliyuncs.com/veops/cmdb-db:3.0 + image: registry.cn-hangzhou.aliyuncs.com/veops/cmdb-db:2.3 container_name: cmdb-db environment: TZ: Asia/Shanghai @@ -22,7 +22,7 @@ services: - '23306:3306' cmdb-cache: - image: registry.cn-hangzhou.aliyuncs.com/veops/cmdb-cache:3.0 + image: registry.cn-hangzhou.aliyuncs.com/veops/cmdb-cache:2.3 container_name: cmdb-cache environment: TZ: Asia/Shanghai