背景
需求:实现自己的研发协同平台,可以一键免密登录禅道、自动创建禅道用户,从而打通各个系统,达到统一化管理。
禅道版本:专业版 9.0.3
python版本:3.8.0
实践
1. 打开禅道后台管理页面,查看禅道API接口
针对上述需求,开始进行摸索。因禅道数据库管理严格,不能直接操作数据库写入数据,且为了更友好,决定通过禅道提供的API,进行用户创建。
这里不得不吐槽一下禅道的API接口,虽然提供了创建用户的这个API地址,但是参数很明显不全,我创建用户的用户名,密码啥的呢?也是醉了...
看样子只查阅API文档,很明显解决不了问题。
2. 查看禅道创建用户的页面,看看是否可以通过后台接口请求,模拟提交表单创建用户
找到禅道创建用户的页面,并点击“保存”,触发提交表单
这里因为我没有填写任何信息,故意提交验证失败的表单,来观察下创建用户需要哪些参数,从上图可以看到包括不少参数,很多都是页面上的必填字段,字段内容也都很好理解,一眼就知道对应的是哪个输入框,我继续填写一些简单信息,再次点击“保存”提交表单试下。
这里我故意将我的密码输入错误,来看下对应的输入字段信息。在此发现了一个比较难办的事情,这个密码一看就是被加密过了,加密的算法是啥呢?我后台接口请求的话,肯定是要知道这个加密规则的,不然我提交明文过去肯定会有问题。(其实这里我还有一点想要吐槽,禅道登录也没有密码加密,我之前模拟登录没做密码加密,在这里却要加密。。。)
3. 打开F12,查看sources源码寻找密码加密规则
没办法了,现在首要任务是要解决,这个密码到底该怎么加密,这个加密规则如何获取呢。当时我想到了两种方案,第一,是看禅道源码,禅道是PHP写的,简单语法我还能看的明白,但是源码厂商没有提供,只能登录禅道服务器去看,而且我也不知道是哪台机器;第二,打开F12看下禅道加载的源码,先看看能不能找出点儿线索。这里我想着先F12本地看下,找不到在看源码吧。说干就干,打开sources,搜索password1
一番寻找过后,发现了一个很重要的线索,这坨代码,不就是加密密码的代码嘛,先将密码MD5,然后加上rand,等等,这个rand是啥,我记得页面上没有这个参数吧,再次寻找
嗯?是个表单里的隐藏字段,value是一坨数字,并不是时间戳,这个是啥呢?写死的吗?刷新了一下页面,我草,变了!!!
这个是个什么值呢,每次页面刷新后还不一样,这个rand的值的规则是啥,不会真如其名,就是个随机数吧???如果是这样,那我自己在后台代码里面随机个数字看看是否可行,附上代码
import os
import sys
import json
import time
import random
import hashlib
import logging
import requests
from enum import Enum
if not os.environ.get('DJANGO_SETTINGS_MODULE'):
import django
root_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.extend([root_path])
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.dev")
django.setup()
from django.conf import settings
from libs import file_lock
LOGGER = logging.getLogger(__name__)
class ZentaoAccountException(Exception):
"""
禅道账号异常
"""
def __init__(self, msg):
super().__init__(self)
self.msg = msg
def __str__(self):
return "zentao account exception, %s" % self.msg
class ZentaoApi(requests.Session):
"""
禅道API接口
"""
class BypassLoginStatusCodeChoices(Enum):
SUCCESS = 200
ACCOUNT_NOT_EXIST = 406
def __init__(self, account=settings.CONF_ATTR.zentao_web_user):
super().__init__()
self.base_params = {
't': 'json'
}
self.account = account
self.base_url = settings.CONF_ATTR.zentao_url
def _get(self, url, **kwargs):
"""
通用get方法
:param url:
:param params:
:return:
"""
resp = self.get(url, **kwargs)
LOGGER.info("GET %s status:%s, cookies:%s", url, resp.status_code, self.cookies)
resp.encoding = 'uft-8'
return resp.status_code, resp.text
def _post(self, url, data=None, **kwargs):
"""
通用post方法
:return:
:param url:
:param params:
:return:
"""
resp = self.post(url, data=data, **kwargs)
LOGGER.info("POST %s body:%s, status:%s, text:%s, cookies:%s",
url, data, resp.status_code, resp.text, self.cookies)
resp.encoding = 'uft-8'
return resp.status_code, resp.text
def _login_bypass(self, account=None):
"""
免密登录
:return:
"""
err_code = self.BypassLoginStatusCodeChoices.SUCCESS.value
account = self.account if account is None else account
lock_obj = file_lock.FileLock()
# 加锁
lock_obj.acquire()
serializer_zentao_cookie = lock_obj.read()
if account == settings.CONF_ATTR.zentao_web_user and serializer_zentao_cookie:
self.cookies.set('zentaosid', serializer_zentao_cookie)
LOGGER.info('already logged, skip...')
# 释放锁
lock_obj.release()
return err_code
# 禅道免密登录算法
timestamp = int(time.time())
token = self._md5(f'{settings.CONF_ATTR.zentao_bypass_code}{settings.CONF_ATTR.zentao_bypass_key}{timestamp}')
url = settings.CONF_ATTR.zentao_bypass_url.format(account=account,
code=settings.CONF_ATTR.zentao_bypass_code,
timestamp=timestamp,
token=token)
resp = self.get(url)
LOGGER.info("bypass login executed, url:%s, cookies:%s, text:%s", url, self.cookies, resp.text)
if 'self.location' not in resp.text:
err_code = resp.json().get('errcode')
# 禅道免密登录成功,设置cookies缓存
if account == settings.CONF_ATTR.zentao_web_user and \
err_code == self.BypassLoginStatusCodeChoices.SUCCESS.value:
zentaosid = requests.utils.dict_from_cookiejar(self.cookies).get('zentaosid')
lock_obj.write(zentaosid)
# 释放锁
lock_obj.release()
return err_code
def _md5(self, value):
"""
md5加密
:param value:
:return:
"""
m = hashlib.md5()
m.update(value.encode('utf-8'))
return m.hexdigest()
def create_zentao_account(self):
"""
创建禅道账号
:return:
"""
# 使用管理员登录禅道
self._login_bypass(account=settings.CONF_ATTR.zentao_web_user)
# 根据部门名称,获取部门id
dept_id = 47
# 根据用户所在部门,获取用户职位
role_name = '研发'
# 根据用户职位,获取用户组织
group_id = 2
# 随机数
verify_rand = str(random.randint(1000, 100000))
# 密码加密
password = self._md5(settings.CONF_ATTR.zentao_default_account_password) + verify_rand
verify_password = self._md5((self._md5(settings.CONF_ATTR.zentao_web_password) + verify_rand))
data = {
'dept': dept_id,
'account': self.account,
'password1': password,
'password2': password,
'realname': '朱宝',
'join': time.strftime('%Y-%m-%d'),
'role': role_name,
'group': group_id,
'email': '',
'gender': 'm',
'verifyPassword': verify_password,
'passwordStrength': 2,
}
params = dict(m='user', f='create')
status, result = self._post(self.base_url, data=data, params=params)
print('create_zentao_account executed, status:%s, result:%s' % (status, result))
if __name__ == "__main__":
obj = ZentaoApi()
print(obj.create_zentao_account())
执行后出现
很明显,这个随机数是不行的,那我怎么获取这个rand呢?看样子这个rand是每次页面加载后,后端代码生成的一个随机数,当我点击保存提交表单时,禅道后端代码会拿着这个随机数进行校验,我自己伪造的rand肯定和它后端代码生成的不一样,导致校验失败了,这下可咋办?真是让人头大...
4. 模拟页面加载,正则解析出rand值
抓抓耳,挠挠腮,上厕所,喝喝水。咦?我是不是可以模拟页面加载,然后拿到这个页面上的rand,在拿这个rand去加密密码呢?很简单,我只要在我创建用户的代码前,去访问下禅道创建用户的页面,然后正则解析拿到这个rand不就行了么?说干就干,附上代码
import os
import sys
import re
import json
import time
import random
import hashlib
import logging
import requests
from enum import Enum
if not os.environ.get('DJANGO_SETTINGS_MODULE'):
import django
root_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.extend([root_path])
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.dev")
django.setup()
from django.conf import settings
from libs import file_lock
LOGGER = logging.getLogger(__name__)
class ZentaoAccountException(Exception):
"""
禅道账号异常
"""
def __init__(self, msg):
super().__init__(self)
self.msg = msg
def __str__(self):
return "zentao account exception, %s" % self.msg
class ZentaoApi(requests.Session):
"""
禅道API接口
"""
class BypassLoginStatusCodeChoices(Enum):
SUCCESS = 200
ACCOUNT_NOT_EXIST = 406
def __init__(self, account=settings.CONF_ATTR.zentao_web_user):
super().__init__()
self.base_params = {
't': 'json'
}
self.account = account
self.base_url = settings.CONF_ATTR.zentao_url
def _get(self, url, **kwargs):
"""
通用get方法
:param url:
:param params:
:return:
"""
resp = self.get(url, **kwargs)
LOGGER.info("GET %s status:%s, cookies:%s", url, resp.status_code, self.cookies)
resp.encoding = 'uft-8'
return resp.status_code, resp.text
def _post(self, url, data=None, **kwargs):
"""
通用post方法
:return:
:param url:
:param params:
:return:
"""
resp = self.post(url, data=data, **kwargs)
LOGGER.info("POST %s body:%s, status:%s, text:%s, cookies:%s",
url, data, resp.status_code, resp.text, self.cookies)
resp.encoding = 'uft-8'
return resp.status_code, resp.text
def _login_bypass(self, account=None):
"""
免密登录
:return:
"""
err_code = self.BypassLoginStatusCodeChoices.SUCCESS.value
account = self.account if account is None else account
lock_obj = file_lock.FileLock()
# 加锁
lock_obj.acquire()
serializer_zentao_cookie = lock_obj.read()
if account == settings.CONF_ATTR.zentao_web_user and serializer_zentao_cookie:
self.cookies.set('zentaosid', serializer_zentao_cookie)
LOGGER.info('already logged, skip...')
# 释放锁
lock_obj.release()
return err_code
# 禅道免密登录算法
timestamp = int(time.time())
token = self._md5(f'{settings.CONF_ATTR.zentao_bypass_code}{settings.CONF_ATTR.zentao_bypass_key}{timestamp}')
url = settings.CONF_ATTR.zentao_bypass_url.format(account=account,
code=settings.CONF_ATTR.zentao_bypass_code,
timestamp=timestamp,
token=token)
resp = self.get(url)
LOGGER.info("bypass login executed, url:%s, cookies:%s, text:%s", url, self.cookies, resp.text)
if 'self.location' not in resp.text:
err_code = resp.json().get('errcode')
# 禅道免密登录成功,设置cookies缓存
if account == settings.CONF_ATTR.zentao_web_user and \
err_code == self.BypassLoginStatusCodeChoices.SUCCESS.value:
zentaosid = requests.utils.dict_from_cookiejar(self.cookies).get('zentaosid')
lock_obj.write(zentaosid)
# 释放锁
lock_obj.release()
return err_code
def _md5(self, value):
"""
md5加密
:param value:
:return:
"""
m = hashlib.md5()
m.update(value.encode('utf-8'))
return m.hexdigest()
def _get_account_verify_rand(self, content):
"""
获取创建禅道用户所需要的verify_rand值
:param content:
:return:
"""
verify_rand_list = re.findall(r"id='verifyRand' value='(\d+)'", content)
LOGGER.info('_get_account_verify_rand executed, verify_rand_list:%s', verify_rand_list)
if verify_rand_list:
return verify_rand_list[0]
return None
def create_zentao_account(self):
"""
创建禅道账号
:return:
"""
# 使用管理员登录禅道
self._login_bypass(account=settings.CONF_ATTR.zentao_web_user)
# 根据部门名称,获取部门id
dept_id = 47
# 根据用户所在部门,获取用户职位
role_name = '研发'
# 根据用户职位,获取用户组织
group_id = 2
# 获取禅道verify_rand,用于加密密码
status, result = self._get(self.base_url, params=dict(m='user', f='create'))
verify_rand = self._get_account_verify_rand(result)
# 密码加密
password = self._md5(settings.CONF_ATTR.zentao_default_account_password) + verify_rand
verify_password = self._md5((self._md5(settings.CONF_ATTR.zentao_web_password) + verify_rand))
data = {
'dept': dept_id,
'account': 'helloworld123',
'password1': password,
'password2': password,
'realname': '朱宝',
'join': time.strftime('%Y-%m-%d'),
'role': role_name,
'group': group_id,
'email': '',
'gender': 'm',
'verifyPassword': verify_password,
'passwordStrength': 2,
}
params = dict(m='user', f='create')
status, result = self._post(self.base_url, data=data, params=params)
print('create_zentao_account executed, status:%s, result:%s' % (status, result))
if __name__ == "__main__":
obj = ZentaoApi()
print(obj.create_zentao_account())
执行后,没有报错了
看下用户是否创建成功了
哈哈,用户已经创建成功了,在验证下是否可以正常登录
哎,可以登录,用户创建成功喽!!!
总结
1.上述解决问题的方案,可以给禅道二次开发的用户提供点经验,也可以给初级爬虫用户提供点思路;
2.遇到问题需要不断探索,一步一步地深挖最终解决问题的快乐,只有实践了才能体会到;
3.如果自己开发系统对外服务,接口文档还是要好好写的,不然会被吐槽、被骂娘,^_^。