pref(api): authentication and login log (#308)

* pref(api): authentication and login log

* feat(api): ldap and OAuth2.0
This commit is contained in:
pycook 2023-12-14 19:53:08 +08:00 committed by GitHub
parent ee0b74bec7
commit 6aef26b82c
16 changed files with 312 additions and 296 deletions

View File

@ -21,7 +21,6 @@ from api.extensions import (bcrypt, cache, celery, cors, db, es, login_manager,
from api.extensions import inner_secrets from api.extensions import inner_secrets
from api.lib.perm.authentication.cas import CAS from api.lib.perm.authentication.cas import CAS
from api.lib.perm.authentication.oauth2 import OAuth2 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.lib.secrets.secrets import InnerKVManger
from api.models.acl import User from api.models.acl import User
@ -98,7 +97,6 @@ def create_app(config_object="settings"):
register_shell_context(app) register_shell_context(app)
register_commands(app) register_commands(app)
CAS(app) CAS(app)
OIDC(app)
OAuth2(app) OAuth2(app)
app.wsgi_app = ReverseProxy(app.wsgi_app) app.wsgi_app = ReverseProxy(app.wsgi_app)
configure_upload_dir(app) configure_upload_dir(app)

View File

@ -94,7 +94,7 @@ class CRUDMixin(FormatMixin):
if any((isinstance(_id, six.string_types) and _id.isdigit(), if any((isinstance(_id, six.string_types) and _id.isdigit(),
isinstance(_id, (six.integer_types, float))), ): isinstance(_id, (six.integer_types, float))), ):
obj = getattr(cls, "query").get(int(_id)) obj = getattr(cls, "query").get(int(_id))
if obj and not obj.deleted: if obj and not getattr(obj, 'deleted', False):
return obj return obj
@classmethod @classmethod

View File

@ -1,14 +1,19 @@
# -*- coding:utf-8 -*- # -*- coding:utf-8 -*-
import datetime
import itertools import itertools
import json import json
from enum import Enum from enum import Enum
from typing import List 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 flask_login import current_user
from sqlalchemy import func from sqlalchemy import func
from api.extensions import db
from api.lib.perm.acl import AppCache from api.lib.perm.acl import AppCache
from api.models.acl import AuditLoginLog
from api.models.acl import AuditPermissionLog from api.models.acl import AuditPermissionLog
from api.models.acl import AuditResourceLog from api.models.acl import AuditResourceLog
from api.models.acl import AuditRoleLog from api.models.acl import AuditRoleLog
@ -283,6 +288,27 @@ class AuditCRUD(object):
return data 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 @classmethod
def add_role_log(cls, app_id, operate_type: AuditOperateType, def add_role_log(cls, app_id, operate_type: AuditOperateType,
scope: AuditScope, link_id: int, origin: dict, current: dict, extra: dict, 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, AuditTriggerLog.create(app_id=app_id, trigger_id=trigger_id, operate_uid=user_id,
operate_type=operate_type.value, operate_type=operate_type.value,
origin=origin, current=current, extra=extra, source=source.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

View File

@ -4,6 +4,9 @@ from api.lib.resp_format import CommonErrFormat
class ErrFormat(CommonErrFormat): class ErrFormat(CommonErrFormat):
login_succeed = "登录成功"
ldap_connection_failed = "连接LDAP服务失败"
invalid_password = "密码验证失败"
auth_only_with_app_token_failed = "应用 Token验证失败" auth_only_with_app_token_failed = "应用 Token验证失败"
session_invalid = "您不是应用管理员 或者 session失效(尝试一下退出重新登录)" session_invalid = "您不是应用管理员 或者 session失效(尝试一下退出重新登录)"
@ -17,11 +20,11 @@ class ErrFormat(CommonErrFormat):
role_exists = "角色 {} 已经存在!" role_exists = "角色 {} 已经存在!"
global_role_not_found = "全局角色 {} 不存在!" global_role_not_found = "全局角色 {} 不存在!"
global_role_exists = "全局角色 {} 已经存在!" global_role_exists = "全局角色 {} 已经存在!"
user_role_delete_invalid = "删除用户角色, 请在 用户管理 页面操作!"
resource_no_permission = "您没有资源: {}{} 权限" resource_no_permission = "您没有资源: {}{} 权限"
admin_required = "需要管理员权限" admin_required = "需要管理员权限"
role_required = "需要角色: {}" role_required = "需要角色: {}"
user_role_delete_invalid = "删除用户角色, 请在 用户管理 页面操作!"
app_is_ready_existed = "应用 {} 已经存在" app_is_ready_existed = "应用 {} 已经存在"
app_not_found = "应用 {} 不存在!" app_not_found = "应用 {} 不存在!"

View File

@ -93,6 +93,9 @@ def _auth_with_token():
def _auth_with_ip_white_list(): 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 ip = request.headers.get('X-Real-IP') or request.remote_addr
key = request.values.get('_key') key = request.values.get('_key')
secret = request.values.get('_secret') secret = request.values.get('_secret')

View File

@ -1,4 +1,5 @@
# -*- coding:utf-8 -*- # -*- coding:utf-8 -*-
import datetime
import uuid import uuid
import bs4 import bs4
@ -12,7 +13,11 @@ from flask_login import login_user
from flask_login import logout_user from flask_login import logout_user
from six.moves.urllib_request import urlopen 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.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_login_url
from .cas_urls import create_cas_logout_url from .cas_urls import create_cas_logout_url
from .cas_urls import create_cas_validate_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/cas/login')
# @blueprint.route('/api/sso/login') @blueprint.route('/api/sso/login')
def login(): def login():
""" """
This route has two purposes. First, it is used by the user 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 If validation was successful the logged in username is saved in
the user's session under the key `CAS_USERNAME_SESSION_KEY`. 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'] cas_token_session_key = current_app.config['CAS_TOKEN_SESSION_KEY']
if request.values.get("next"): if request.values.get("next"):
@ -41,8 +47,8 @@ def login():
_service = url_for('cas.login', _external=True) _service = url_for('cas.login', _external=True)
redirect_url = create_cas_login_url( redirect_url = create_cas_login_url(
current_app.config['CAS_SERVER'], config['cas_server'],
current_app.config['CAS_LOGIN_ROUTE'], config['cas_login_route'],
_service) _service)
if 'ticket' in request.args: if 'ticket' in request.args:
@ -51,30 +57,38 @@ def login():
if request.args.get('ticket'): if request.args.get('ticket'):
if validate(request.args['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") username = session.get("CAS_USERNAME")
user = UserCache.get(username) user = UserCache.get(username)
login_user(user) login_user(user)
session.permanent = True session.permanent = True
_id = AuditCRUD.add_login_log(username, True, ErrFormat.login_succeed)
session['LOGIN_ID'] = _id
else: else:
del session[cas_token_session_key] del session[cas_token_session_key]
redirect_url = create_cas_login_url( redirect_url = create_cas_login_url(
current_app.config['CAS_SERVER'], config['cas_server'],
current_app.config['CAS_LOGIN_ROUTE'], config['cas_login_route'],
url_for('cas.login', _external=True), url_for('cas.login', _external=True),
renew=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)) current_app.logger.info("redirect to: {0}".format(redirect_url))
return redirect(redirect_url) return redirect(redirect_url)
@blueprint.route('/api/cas/logout') @blueprint.route('/api/cas/logout')
# @blueprint.route('/api/sso/logout') @blueprint.route('/api/sso/logout')
def logout(): def logout():
""" """
When the user accesses this route they are logged out. 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_username_session_key = current_app.config['CAS_USERNAME_SESSION_KEY']
cas_token_session_key = current_app.config['CAS_TOKEN_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") "next" in session and session.pop("next")
redirect_url = create_cas_logout_url( redirect_url = create_cas_logout_url(
current_app.config['CAS_SERVER'], config['cas_server'],
current_app.config['CAS_LOGOUT_ROUTE'], config['cas_logout_route'],
url_for('cas.login', _external=True, next=request.referrer)) url_for('cas.login', _external=True, next=request.referrer))
logout_user() 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)) current_app.logger.debug('Redirecting to: {0}'.format(redirect_url))
return redirect(redirect_url) return redirect(redirect_url)
@ -104,14 +120,15 @@ def validate(ticket):
and the validated username is saved in the session under the and the validated username is saved in the session under the
key `CAS_USERNAME_SESSION_KEY`. key `CAS_USERNAME_SESSION_KEY`.
""" """
config = AuthenticateDataCRUD(AuthenticateType.CAS).get()
cas_username_session_key = current_app.config['CAS_USERNAME_SESSION_KEY'] cas_username_session_key = current_app.config['CAS_USERNAME_SESSION_KEY']
current_app.logger.debug("validating token {0}".format(ticket)) current_app.logger.debug("validating token {0}".format(ticket))
cas_validate_url = create_cas_validate_url( cas_validate_url = create_cas_validate_url(
current_app.config['CAS_VALIDATE_SERVER'], config['cas_validate_server'],
current_app.config['CAS_VALIDATE_ROUTE'], config['cas_validate_route'],
url_for('cas.login', _external=True), url_for('cas.login', _external=True),
ticket) ticket)
@ -138,14 +155,12 @@ def validate(ticket):
current_app.logger.info("create user: {}".format(username)) current_app.logger.info("create user: {}".format(username))
from api.lib.perm.acl.user import UserCRUD from api.lib.perm.acl.user import UserCRUD
soup = bs4.BeautifulSoup(response) 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() user_dict = dict()
for k in cas_user_map: for k in cas_user_map:
v = soup.find(cas_user_map[k]['tag'], cas_user_map[k].get('attrs', {})) 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[k] = v and v.text or None
user_dict['password'] = uuid.uuid4().hex user_dict['password'] = uuid.uuid4().hex
UserCRUD.add(**user_dict) UserCRUD.add(**user_dict)
from api.lib.perm.acl.acl import ACLManager from api.lib.perm.acl.acl import ACLManager

View File

@ -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)

View File

@ -18,6 +18,10 @@ class OAuth2(object):
app.config.setdefault('OAUTH2_RESPONSE_TYPE', 'code') app.config.setdefault('OAUTH2_RESPONSE_TYPE', 'code')
app.config.setdefault('OAUTH2_AFTER_LOGIN', '/') 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 # Register Blueprint
app.register_blueprint(routing.blueprint, url_prefix=url_prefix) app.register_blueprint(routing.blueprint, url_prefix=url_prefix)

View File

@ -1,5 +1,6 @@
# -*- coding:utf-8 -*- # -*- coding:utf-8 -*-
import datetime
import secrets import secrets
import uuid import uuid
@ -14,69 +15,80 @@ from flask import url_for
from flask_login import login_user, logout_user from flask_login import login_user, logout_user
from six.moves.urllib.parse import urlencode 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.cache import UserCache
from api.lib.perm.acl.resp_format import ErrFormat
blueprint = Blueprint('oauth2', __name__) blueprint = Blueprint('oauth2', __name__)
@blueprint.route('/api/oauth2/login') @blueprint.route('/api/<string:auth_type>/login')
@blueprint.route('/api/sso/login') def login(auth_type):
def login(): config = AuthenticateDataCRUD(auth_type.upper()).get()
if request.values.get("next"): if request.values.get("next"):
session["next"] = 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({ qs = urlencode({
'client_id': current_app.config['OAUTH2_CLIENT_ID'], 'client_id': config['client_id'],
'redirect_uri': url_for('oauth2.callback', _external=True), 'redirect_uri': url_for('oauth2.callback', auth_type=auth_type.lower(), _external=True),
'response_type': current_app.config['OAUTH2_RESPONSE_TYPE'], 'response_type': current_app.config[f'{auth_type}_RESPONSE_TYPE'],
'scope': ' '.join(current_app.config['OAUTH2_SCOPES'] or []), 'scope': ' '.join(config['scopes'] or []),
'state': session['oauth2_state'], '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') @blueprint.route('/api/<string:auth_type>/callback')
def callback(): def callback(auth_type):
redirect_url = session.get("next") or current_app.config.get("OAUTH2_AFTER_LOGIN") 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") return abort(401, "state is invalid")
if 'code' not in request.values: if 'code' not in request.values:
return abort(401, 'code is invalid') return abort(401, 'code is invalid')
response = requests.post(current_app.config['OAUTH2_TOKEN_URL'], data={ response = requests.post(config['token_url'], data={
'client_id': current_app.config['OAUTH2_CLIENT_ID'], 'client_id': config['client_id'],
'client_secret': current_app.config['OAUTH2_CLIENT_SECRET'], 'client_secret': config['client_secret'],
'code': request.values['code'], 'code': request.values['code'],
'grant_type': current_app.config['OAUTH2_GRANT_TYPE'], 'grant_type': current_app.config[f'{auth_type}_GRANT_TYPE'],
'redirect_uri': url_for('oauth2.callback', _external=True), 'redirect_uri': url_for('oauth2.callback', auth_type=auth_type.lower(), _external=True),
}, headers={'Accept': 'application/json'}) }, headers={'Accept': 'application/json'})
if response.status_code != 200: if response.status_code != 200:
current_app.logger.error(response.text) current_app.logger.error(response.text)
return abort(401) return abort(401)
oauth2_token = response.json().get('access_token') access_token = response.json().get('access_token')
if not oauth2_token: if not access_token:
return abort(401) return abort(401)
response = requests.get(current_app.config['OAUTH2_USER_INFO']['url'], headers={ response = requests.get(config['user_info']['url'], headers={
'Authorization': 'Bearer {}'.format(oauth2_token), 'Authorization': 'Bearer {}'.format(access_token),
'Accept': 'application/json', 'Accept': 'application/json',
}) })
if response.status_code != 200: if response.status_code != 200:
return abort(401) return abort(401)
email = current_app.config['OAUTH2_USER_INFO']['email'](response.json()) res = response.json()
username = current_app.config['OAUTH2_USER_INFO']['username'](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) user = UserCache.get(username)
if user is None: if user is None:
current_app.logger.info("create user: {}".format(username)) current_app.logger.info("create user: {}".format(username))
from api.lib.perm.acl.user import UserCRUD 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_dict['password'] = uuid.uuid4().hex
user = UserCRUD.add(**user_dict) user = UserCRUD.add(**user_dict)
@ -98,21 +110,25 @@ def callback():
roleName=user_info.get("role")) roleName=user_info.get("role"))
session["uid"] = user_info.get("uid") session["uid"] = user_info.get("uid")
_id = AuditCRUD.add_login_log(username, True, ErrFormat.login_succeed)
session['LOGIN_ID'] = _id
return redirect(redirect_url) return redirect(redirect_url)
@blueprint.route('/api/oauth2/logout') @blueprint.route('/api/<string:auth_type>/logout')
@blueprint.route('/api/sso/logout') def logout(auth_type):
def logout():
"acl" in session and session.pop("acl") "acl" in session and session.pop("acl")
"uid" in session and session.pop("uid") "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") "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() logout_user()
current_app.logger.debug('Redirecting to: {0}'.format(redirect_url)) 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) return redirect(redirect_url)

View File

@ -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

View File

@ -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)

View File

@ -5,17 +5,18 @@ import copy
import hashlib import hashlib
from datetime import datetime 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 current_app
from flask import session
from flask_sqlalchemy import BaseQuery from flask_sqlalchemy import BaseQuery
from api.extensions import db from api.extensions import db
from api.lib.database import CRUDModel from api.lib.database import CRUDModel
from api.lib.database import Model from api.lib.database import Model
from api.lib.database import Model2
from api.lib.database import SoftDeleteMixin from api.lib.database import SoftDeleteMixin
from api.lib.perm.acl.const import ACL_QUEUE from api.lib.perm.acl.const import ACL_QUEUE
from api.lib.perm.acl.const import OperateType from api.lib.perm.acl.const import OperateType
from api.lib.perm.acl.resp_format import ErrFormat
class App(Model): class App(Model):
@ -28,21 +29,26 @@ class App(Model):
class UserQuery(BaseQuery): class UserQuery(BaseQuery):
def _join(self, *args, **kwargs):
super(UserQuery, self)._join(*args, **kwargs)
def authenticate(self, login, password): def authenticate(self, login, password):
from api.lib.perm.acl.audit import AuditCRUD
user = self.filter(db.or_(User.username == login, user = self.filter(db.or_(User.username == login,
User.email == login)).filter(User.deleted.is_(False)).filter(User.block == 0).first() User.email == login)).filter(User.deleted.is_(False)).filter(User.block == 0).first()
if user: if user:
current_app.logger.info(user)
authenticated = user.check_password(password) authenticated = user.check_password(password)
if authenticated: if authenticated:
from api.tasks.acl import op_record _id = AuditCRUD.add_login_log(login, True, ErrFormat.login_succeed)
op_record.apply_async(args=(None, login, OperateType.LOGIN, ["ACL"]), queue=ACL_QUEUE) session['LOGIN_ID'] = _id
else:
AuditCRUD.add_login_log(login, False, ErrFormat.invalid_password)
else: else:
authenticated = False 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 return user, authenticated
def authenticate_with_key(self, key, secret, args, path): def authenticate_with_key(self, key, secret, args, path):
@ -57,38 +63,6 @@ class UserQuery(BaseQuery):
return user, authenticated 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): def search(self, key):
query = self.filter(db.or_(User.email == key, query = self.filter(db.or_(User.email == key,
User.nickname.ilike('%' + key + '%'), User.nickname.ilike('%' + key + '%'),
@ -138,6 +112,7 @@ class User(CRUDModel, SoftDeleteMixin):
wx_id = db.Column(db.String(32)) wx_id = db.Column(db.String(32))
employee_id = db.Column(db.String(16), index=True) employee_id = db.Column(db.String(16), index=True)
avatar = db.Column(db.String(128)) avatar = db.Column(db.String(128))
# apps = db.Column(db.JSON) # apps = db.Column(db.JSON)
def __str__(self): def __str__(self):
@ -168,8 +143,6 @@ class User(CRUDModel, SoftDeleteMixin):
class RoleQuery(BaseQuery): class RoleQuery(BaseQuery):
def _join(self, *args, **kwargs):
super(RoleQuery, self)._join(*args, **kwargs)
def authenticate(self, login, password): def authenticate(self, login, password):
role = self.filter(Role.name == login).first() role = self.filter(Role.name == login).first()
@ -377,3 +350,16 @@ class AuditTriggerLog(Model):
current = db.Column(db.JSON, default=dict(), comment='当前数据') current = db.Column(db.JSON, default=dict(), comment='当前数据')
extra = db.Column(db.JSON, default=dict(), comment='权限名') extra = db.Column(db.JSON, default=dict(), comment='权限名')
source = db.Column(db.String(16), default='', 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)

View File

@ -24,6 +24,7 @@ class AuditLogView(APIView):
'role': AuditCRUD.search_role, 'role': AuditCRUD.search_role,
'trigger': AuditCRUD.search_trigger, 'trigger': AuditCRUD.search_trigger,
'resource': AuditCRUD.search_resource, 'resource': AuditCRUD.search_resource,
'login': AuditCRUD.search_login,
} }
if name not in func_map: if name not in func_map:
abort(400, f'wrong {name}, please use {func_map.keys()}') abort(400, f'wrong {name}, please use {func_map.keys()}')

View File

@ -8,11 +8,15 @@ from flask import abort
from flask import current_app from flask import current_app
from flask import request from flask import request
from flask import session 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_required
from api.lib.decorator import args_validate from api.lib.decorator import args_validate
from api.lib.perm.acl.acl import ACLManager 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 RoleCache
from api.lib.perm.acl.cache import User from api.lib.perm.acl.cache import User
from api.lib.perm.acl.cache import UserCache 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") username = request.values.get("username") or request.values.get("email")
password = request.values.get("password") password = request.values.get("password")
_role = None _role = None
if current_app.config.get('AUTH_WITH_LDAP'): config = AuthenticateDataCRUD(AuthenticateType.LDAP).get()
user, authenticated = User.query.authenticate_with_ldap(username, password) if config.get('LDAP', {}).get('enabled'):
from api.lib.perm.authentication.ldap import authenticate_with_ldap
user, authenticated = authenticate_with_ldap(username, password)
else: else:
user, authenticated = User.query.authenticate(username, password) user, authenticated = User.query.authenticate(username, password)
if not user: if not user:
@ -176,4 +182,7 @@ class LogoutView(APIView):
@auth_abandoned @auth_abandoned
def post(self): def post(self):
logout_user() logout_user()
AuditCRUD.add_login_log(None, None, None, _id=session.get('LOGIN_ID'), logout_at=datetime.datetime.now())
self.jsonify(code=200) self.jsonify(code=200)

View File

@ -11,10 +11,10 @@ from environs import Env
env = Env() env = Env()
env.read_env() env.read_env()
ENV = env.str("FLASK_ENV", default="production") ENV = env.str('FLASK_ENV', default='production')
DEBUG = ENV == "development" DEBUG = ENV == 'development'
SECRET_KEY = env.str("SECRET_KEY") SECRET_KEY = env.str('SECRET_KEY')
BCRYPT_LOG_ROUNDS = env.int("BCRYPT_LOG_ROUNDS", default=13) BCRYPT_LOG_ROUNDS = env.int('BCRYPT_LOG_ROUNDS', default=13)
DEBUG_TB_ENABLED = DEBUG DEBUG_TB_ENABLED = DEBUG
DEBUG_TB_INTERCEPT_REDIRECTS = False DEBUG_TB_INTERCEPT_REDIRECTS = False
@ -23,7 +23,7 @@ ERROR_CODES = [400, 401, 403, 404, 405, 500, 502]
# # database # # database
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://{user}:{password}@127.0.0.1:3306/{db}?charset=utf8' SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://{user}:{password}@127.0.0.1:3306/{db}?charset=utf8'
SQLALCHEMY_BINDS = { 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_ECHO = False
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
@ -32,11 +32,11 @@ SQLALCHEMY_ENGINE_OPTIONS = {
} }
# # cache # # cache
CACHE_TYPE = "redis" CACHE_TYPE = 'redis'
CACHE_REDIS_HOST = "127.0.0.1" CACHE_REDIS_HOST = '127.0.0.1'
CACHE_REDIS_PORT = 6379 CACHE_REDIS_PORT = 6379
CACHE_REDIS_PASSWORD = "" CACHE_REDIS_PASSWORD = ''
CACHE_KEY_PREFIX = "CMDB::" CACHE_KEY_PREFIX = 'CMDB::'
CACHE_DEFAULT_TIMEOUT = 3000 CACHE_DEFAULT_TIMEOUT = 3000
# # log # # log
@ -55,10 +55,10 @@ DEFAULT_MAIL_SENDER = ''
# # queue # # queue
CELERY = { CELERY = {
"broker_url": 'redis://127.0.0.1:6379/2', 'broker_url': 'redis://127.0.0.1:6379/2',
"result_backend": "redis://127.0.0.1:6379/2", 'result_backend': 'redis://127.0.0.1:6379/2',
"broker_vhost": "/", 'broker_vhost': '/',
"broker_connection_retry_on_startup": True 'broker_connection_retry_on_startup': True
} }
ONCE = { ONCE = {
'backend': 'celery_once.backends.Redis', 'backend': 'celery_once.backends.Redis',
@ -70,68 +70,78 @@ ONCE = {
# =============================== Authentication =========================================================== # =============================== Authentication ===========================================================
# # CAS # # CAS
AUTH_WITH_CAS = False CAS = dict(
CAS_SERVER = "https://{your-casdoor-hostname}" enabled=False,
CAS_VALIDATE_SERVER = "https://{your-casdoor-hostname}" cas_server='https://{your-CASServer-hostname}',
CAS_LOGIN_ROUTE = "/cas/built-in/cas/login" cas_validate_server='https://{your-CASServer-hostname}',
CAS_LOGOUT_ROUTE = "/cas/built-in/cas/logout" cas_login_route='/cas/built-in/cas/login',
CAS_VALIDATE_ROUTE = "/cas/built-in/cas/serviceValidate" cas_logout_route='/cas/built-in/cas/logout',
CAS_AFTER_LOGIN = "/" cas_validate_route='/cas/built-in/cas/serviceValidate',
CAS_USER_MAP = { cas_after_login='/',
"username": {"tag": "cas:user"}, cas_user_map={
"nickname": {"tag": "cas:attribute", "attrs": {"name": "displayName"}}, 'username': {'tag': 'cas:user'},
"email": {"tag": "cas:attribute", "attrs": {"name": "email"}}, 'nickname': {'tag': 'cas:attribute', 'attrs': {'name': 'displayName'}},
"mobile": {"tag": "cas:attribute", "attrs": {"name": "phone"}}, 'email': {'tag': 'cas:attribute', 'attrs': {'name': 'email'}},
"avatar": {"tag": "cas:attribute", "attrs": {"name": "avatar"}}, 'mobile': {'tag': 'cas:attribute', 'attrs': {'name': 'phone'}},
} 'avatar': {'tag': 'cas:attribute', 'attrs': {'name': 'avatar'}},
}
)
# # OAuth2.0 # # OAuth2.0
AUTH_WITH_OAUTH2 = False OAUTH2 = dict(
OAUTH2_CLIENT_ID = "" enabled=False,
OAUTH2_CLIENT_SECRET = "" client_id='',
OAUTH2_AUTHORIZE_URL = "https://{your-casdoor-hostname}/login/oauth/authorize" client_secret='',
OAUTH2_TOKEN_URL = "https://{your-casdoor-hostname}/api/login/oauth/access_token" authorize_url='https://{your-OAuth2Server-hostname}/login/oauth/authorize',
OAUTH2_USER_INFO = { token_url='https://{your-OAuth2Server-hostname}/api/login/oauth/access_token',
"url": "https://{your-casdoor-hostname}/api/userinfo", scopes=['profile', 'email'],
"email": lambda x: x['email'], user_info={
"username": lambda x: x['name'] 'url': 'https://{your-OAuth2Server-hostname}/api/userinfo',
} 'email': 'email',
OAUTH2_SCOPES = ["profile email"] 'username': 'name',
OAUTH2_AFTER_LOGIN = "/" 'avatar': 'picture'
},
after_login='/'
)
# # OIDC # # OIDC
AUTH_WITH_OIDC = False OIDC = dict(
OIDC_CLIENT_ID = "" enabled=False,
OIDC_CLIENT_SECRET = "" client_id='',
OIDC_AUTHORIZE_URL = "https://{your-casdoor-hostname}/login/oauth/authorize" client_secret='',
OIDC_TOKEN_URL = "https://{your-casdoor-hostname}/api/login/oauth/access_token" authorize_url='https://{your-OIDCServer-hostname}/login/oauth/authorize',
OIDC_USER_INFO = { token_url='https://{your-OIDCServer-hostname}/api/login/oauth/access_token',
"url": "https://{your-casdoor-hostname}/api/userinfo", scopes=['openid', 'profile', 'email'],
"email": lambda x: x['email'], user_info={
"username": lambda x: x['name'] 'url': 'https://{your-OIDCServer-hostname}/api/userinfo',
} 'email': 'email',
OIDC_SCOPES = ["openid profile email"] 'username': 'name',
OIDC_AFTER_LOGIN = "/" 'avatar': 'picture'
},
after_login='/'
)
# # LDAP # # LDAP
AUTH_WITH_LDAP = False LDAP = dict(
LDAP_SERVER = '' enabled=False,
LDAP_DOMAIN = '' ldap_server='',
LDAP_USER_DN = 'cn={},ou=users,dc=xxx,dc=com' ldap_domain='',
ldap_user_dn='cn={},ou=users,dc=xxx,dc=com'
)
# ========================================================================================================== # ==========================================================================================================
# # pagination # # pagination
DEFAULT_PAGE_COUNT = 50 DEFAULT_PAGE_COUNT = 50
# # permission # # permission
WHITE_LIST = ["127.0.0.1"] WHITE_LIST = ['127.0.0.1']
USE_ACL = True USE_ACL = True
# # elastic search # # elastic search
ES_HOST = '127.0.0.1' ES_HOST = '127.0.0.1'
USE_ES = False 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 # # messenger
USE_MESSENGER = True USE_MESSENGER = True

View File

@ -2,7 +2,7 @@ version: '3.5'
services: services:
cmdb-db: 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 container_name: cmdb-db
environment: environment:
TZ: Asia/Shanghai TZ: Asia/Shanghai
@ -22,7 +22,7 @@ services:
- '23306:3306' - '23306:3306'
cmdb-cache: 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 container_name: cmdb-cache
environment: environment:
TZ: Asia/Shanghai TZ: Asia/Shanghai