今天是我没找到工作的第 18 天,还在慢慢找。当然找的时候我也在练习给自己充会电,有项目做我才好学习相关知识,不然完全学不进去。就像以前用 Blender 做模型,半路开始做完全零基础真的是乱做,系统学习后就轻轻松松完成改模。所以有需求的学习对我来说更有效率。这次系统是我以等保要求做的一个登陆系统,主要是给没有登录功能的开源或者薄弱认证的系统用的。虽然我离职了不会碰等保项目了,但未来肯定会遇到这类需求的,所以还是先做一个练下手吧。

在这次项目中,也了解很多前后端、容器和数据库的相关知识、特别是数据库方面,用 SQLAlchemy 很方便我去理解数据库的原理和操作,我对数据库的知识非常薄弱。

项目我命名为 LogintoRedirct,意思就是登录了再重定向。大概流程是登录了这个系统后,自动跳转到目标系统,就用 iframe 套个壳而已。整套代码我是用 Flask 实现后端功能(暂时不会django);数据库用简单的 SQLite,数据库交互就用 SQLAlchemy 对象化数据,不用自己写复杂的 SQL 语句了;前端还是用 jquery 做,我不会其他 JS 框架呀,所以我没去找前端的工作做。

这次很多内容都用到 ChatGPT 帮我解决很多难题,有些不想花时间的验证的代码我让 GPT 帮我生成,提升了很多效率。

需求

  • 背景:为了应付等保或给没有登录界面的开源程序使用的伪登录界面
  • 模块

    • 登录验证+安全码验证
    • 新建用户
    • 超时登录、页面过期重新认证、密码有效期、限制IP访问
    • 设置画面
  • 前端

    • 账户名、密码、验证码是否为空

      • 密码是否符合规则(特殊字符、大小写、数字、长度..)
  • 服务端

    • 验证码是否正确(对应时间戳是否过期)
    • 账户是否存在(未注册、已注销)
    • 密码是否正确(记录连续输入错误次数,超过5次,账号锁定4小时。或提升验证等级,采取账号+密码+验证码+短信验证)

界面展示

首页

QQ截图20230918134445.png

访问模板平台

QQ截图20230918134813.png

设置页面

QQ截图20230918134859.png

后端方面

我前端写得很差,拿出来讨论丢人现眼,所以这里不讨论前端遇到的问题。

如何实现安全令功能呢?

我是用 pyotp 模块的,它可以做一个基于时间的动态口令,可以在谷歌验证器或其他 OTP 小程序扫个码就能用了。而且我看了下示例也挺简单的,我就分成了生成,验证和创建二维码 uri 这三个模块。

class OTP:
    def __init__(self, sec=None):
        if sec is None:
            pass
        else:
            self.sec = sec

    def generate(self):
        sec = pyotp.random_base32()
        return sec

    def verify(self, code):
        totp = pyotp.TOTP(self.sec)
        return totp.verify(code)

    def create_uri(self):
        uri = pyotp.totp.TOTP(self.sec).provisioning_uri('login_dengbao')
        return uri

生成二维码的话,我用 qrcode.js 在 前端生成二维码的,然后塞到 bootstrap 的气泡框中。

function create_link(str) {
    var base64_img = jrQrcode.getQrBase64('otpauth://totp/login_dengbao?secret='+str);
    var over = '<a href="#" class="qrcode-href text-muted " data-bs-container="body" data-bs-toggle="popover" data-bs-placement="right" data-bs-html="true" data-bs-content="<div id=\'qrcode\'><p>请用谷歌验证器或T盾其他令牌器扫描</p><img src='+base64_img+'></div>">'+str+'</a>'
    return over
}

怎么做密码复杂度呢?

用正则表达式去判断即可,这个我是直接问 ChatGPT 的,我不想花时间写正则去判断。要求是密码满足 大写+小写+特殊字符 和 8 个字符以上,但 GPT 生成的正则只支持部分特殊符号,因此要在 (?=.*[@$!%*?&])[A-Za-z\d@$!%*?&] 这段补充不常用的特殊字符。

import re

def check_password_complexity(password):
    # 使用正则表达式进行匹配
    pattern = r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$'
    if re.match(pattern, password):
        return True
    else:
        return False

# 测试密码
password = "Abcdefg1@"
if check_password_complexity(password):
    print("密码符合复杂度要求")
else:
    print("密码不符合复杂度要求")

如何实现部分页面只能登录才能访问

我参考了网上的教程,新增了一个装饰器来限制部分页面只能登录才可以访问,详情可以看看 github 的 main.pylogin_required 的钩子。

实现日志记录

我使用 logging 模块来记录日志,python 自带这个模块,用起来挺像以前做日志收集的。

import logging

if __name__ == "__main__":
    handler = logging.FileHandler('log//flask.log', encoding='UTF-8')
    handler.setLevel(logging.DEBUG)  # 设置日志记录最低级别为DEBUG,低于DEBUG级别的日志记录会被忽略,不设置setLevel()则默认为NOTSET级别。
    logging_format = logging.Formatter(
        '%(asctime)s - %(levelname)s - %(filename)s - %(funcName)s - %(lineno)s - %(message)s')
    handler.setFormatter(logging_format)
    app.logger.addHandler(handler)

# 日志写入
def logwrite(str):
    ip = request.remote_addr
    app.logger.info(f'{ip} -  {str}')

# 调用
logwrite(str)

一些报错

我有时候有些全局常量需要查询下数据库才行,但我是用 SQLAlchemy 做数据库查询的,不能直接主入口或全局变量中直接查询,否则会报错以下内容

RuntimeError: Working outside of application context.

This typically means that you attempted to use functionality that needed
the current application. To solve this, set up an application context
with app.app_context(). See the documentation for more information.

根据报错,需要加个 CM with app.app_context() 来初始化需要查询数据库的数据,运行 Flask 的时候会先调用这个方法。

容器方面

因为我想给以前的同事测试一下这个平台,也不想麻烦他去部署环境,所以我也借此做了个容器给我同事用,趁机学习一下 Docker 怎么做镜像。由于我这个环境有 Python 就行,我就找了个 python 镜像来基础镜像。本来想弄 centos 镜像的,但是考虑还要在里面部署环境会变成胖容器,这违背容器的初衷。我就找了个 python:latest 镜像,但是这镜像居然要 1G 多,我导出的镜像就要 1G 多。slim 版就是瘦身版,我就用 slim 标签做基础镜像。

FROM python:slim
WORKDIR /app
ADD . /app
RUN pip install --trusted-host  pypi.tuna.tsinghua.edu.cn -r requirements.txt
EXPOSE 5000
ENV MODE="main"
CMD ["sh","-c","python $MODE.py "]

WORKDIR 是切换容器内系统的工作目录,我把所有文件丢到容器的根目录就用到 ADD,把本目录的所有文件丢进去。然后用 RUN 来提前安装好依赖,至于 requirements.txt 的依赖我是用 pipreqs 导出本项目的所有第三方依赖。EXPOSE 是用来映射端口的,但后面觉得没什么用,我可以在命令行生成容器的时候自己映射呀。

后面我为了实现反向代理功能,需要分出另一个端口和脚本来单独跑服务,但容器一般只跑单个进程。虽然可以用 python 的多线程,一个容器跑两个进程,但我代码稀烂跑不成功。就想让容器跑两个服务得了,所以 ENV 我定义了个环境变量,默认是跑 main.py 就行了。再生成一个容器,用 docker run -d -e MODE="proxy" 就能跑 proxy.py 这个脚本了。但最终我把代码整合在一起了。

最后 docker build -t 镜像名 . 就能创建镜像了。

说到后面跑双脚本,因为我之前都是直接跑两个脚本,以为用 127.0.0.1 互相访问端口就能通信了,但是有个大问题就是容器都是独立网络的,直接访问 127.0.0.1 没办法互通两个容器。不过可以用自己建立一个 docker 网络,用 hosts 指定两边容器的 IP 来实现互通,或者偷懒一点用 -net host。但我没有去做,主要觉得好麻烦好麻烦...

数据库方面

按照上面的需求,我们需要提前设计表,表应该要什么字段呢?下面的 SQL 语句是我复刻的内容,因为我在用 SQLAlchemy 做数据库交互并不需要自己写 SQL 语句。

表设计

创建一个用户表叫 user,字段应该要这样设计

字段名称数据类型备注
idint(10)主键,当作编号
usernameString(20) 等效于 varchar(20)用户名
passwordString(20)密码
OTP_idString(30)动态口令,允许空值
otp_enableBoolean开启动态口令,默认值为 False
failure_countInt(2)登录失败次数,默认值为 0
failure_last_timeDatetime最后登录失败时间
password_final_timedatetime密码到期时间,允许空值
user_enableBoolean用户时候开启,默认值为启用
characterString(20)角色

转换成 SQL 语句就是这样的,我踩过一个坑就是,前面的字段我没用 反引号 ,会直接报错 1064 - You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'AUTO_INCREMENT KEY COMMIT '主键和ID值',,因此语句要这样改才可以!

CREATE TABLE `USER`(
`id` int(11) PRIMARY KEY AUTO_INCREMENT COMMENT '主键和ID值',
`username` VARCHAR(20) COMMENT "用户名",
`password` VARCHAR(20) COMMENT "密码",
`OTP_id` VARCHAR(30) NULL COMMENT "动态口令 ID",
`otp_enable` TINYINT(1) DEFAULT 0 COMMENT "动态口令状态",
`failure_count` INT(2) DEFAULT 0  COMMENT "登录错误次数",
`failure_last_time` DATETIME NULL COMMENT "最后登录时间",
`password_final_time` DATETIME NULL COMMENT "密码到期时间",
`user_enable` TINYINT(1) DEFAULT 1 COMMENT "用户开启状态",
`character` VARCHAR(20) not null COMMENT "用户角色"
);

创建一个设置表 Settings,

字段名称数据类型备注
idint(2)主键,当作编号
titileString(30) 等效于 varchar(30)系统标题
white_IP_listtext限制 IP,允许空值
session_longString(3)保活时间,这里我觉得可以用int的
set_login_failure_timeString(3)登录失败次数限制
failure_countset_login_lock_timeString(3)登录锁定时长
dest_urlvarchar(100)跳转 URL
url_modestring(10)访问模式

改写成 SQL 语句就是

CREATE TABLE `Settings`(
`id` INT(4) PRIMARY KEY COMMENT "主键",
`title` VARCHAR(30) COMMENT "标题",
`white_IP_list` TEXT COMMENT "白名单IP",
`session_long` VARCHAR(30) COMMENT "保活时间",
`set_login_failure_time` VARCHAR(3) COMMENT "登录失败次数限制",
`set_login_lock_time` VARCHAR(3) COMMENT "登录锁定时长",
`dest_url` VARCHAR(100) COMMENT "最终登录系统系统", 
`url_mode` varchar(100) COMMENT "访问模式"
);

如果未来我又新的想法,需要用 ALERT TABLE Settings ADD COLUMN_NAME 列名

初始化设置和初始用户

访问系统首页的时候,会查询一次 Settings 表你有没有 id=1 的字段,如果没有就说明这个系统很干净,会自动跳转到我做的安装界面,那应该怎么判断呢?我在 python 中是直接获取 Settings 表的列数量是否为0 if Settings.query.count() == 0: ,或者 Settings.query.filter_by(id=1).first() ,如果抛出 AttributeError 错误就是不存在结果。

换作 SQL 是这样判断,如果不存在就输出 0.

SELECT EXISTS(SELECT 1 FROM settings where id = 1) AS exists;

我现在就要安装了,就要插入初始数据。我在 python 写的时候是直接获取 json 后,把整个字典直接提交到数据库就搞定了,毕竟前端的字段和数据库的字段我都设置一样的,不用一个一个加了。但是在 SQL 语句只能老实写数据了

设置的 SQL 应该这样设置,用户我就不插入了。

INSERT INTO `settings`
(id,title,white_IP_list,session_long, set_login_failure_time,set_login_lock_time,dest_url,url_mode)
VALUES(1,'私有网站','','30','5','5','http://127.0.0.1/','iframe');

修改数据

设置页面肯定会对用户和设置的数据进行变更的。我在 python 中设置交互中,获取到 POST 过来的请求后的字典跟数据库本身的数据进行对比,将需要变更的字段列出新的字典,再遍历提交到数据库。

这里也是个问题。SQLAlchemy 的字段是直接对象的属性表示的,我提交的时候没办法 Settings.key = value ,因为 Settings 都没有key这个值,key 在对象中是固定的,不会变更的,所以我问 GPT,GPT 跟我说可以用 setAttr 方法。我是这样写的 setattr(settings, key, new_setting_dict[key]) 就顺利提交数据了!

在 SQL 中是这样子,UPDATE settings SET dest_url='http://127.0.0.1:500/' where id = 1; ,如果我不输入 where 的话,会把所有行的的 dest_url 都会变更,不过这个表也就只有一条,无所谓的。

有待解决的问题

反向代理不知道怎么做

因为用 iframe 做跳转,本质上还是套了个壳而已,访问源地址也能访问,有些敏锐的等保人员也可能会发现。当然我们可以在原系统自己做 NGINX 的 auth_basicallow deny 来加一层用户认证和限制 IP 访问,但也是自欺欺人,做反向代理才有用,才能做到安全。

我之前做过 Kibana 的反向代理,因为要套个 https。想着 flask 能不能实现反向代理呢,可以是可以,用 requests 去访问源站再返回到 flask 的路由上。我测试过,我自己做的简单平台是可以正常反向代理的,但是给 ELK 用的话会白屏,因为我没处理好路由问题,而且带跳转的。上网抄作业也找不到想要的内容,我就暂时放弃了。

验证码存活问题

验证码我是抄网上的案例的,但是有个问题,上一个验证码会一直存在 session 的,直到下一次访问才会被替换,导致我每次登录系统会报错验证码出错,但我明明没有写错的,因为前端的验证码是最新的,但是系统一直用着上一个 session 的验证码。我用了一个偷懒的方法处理了,因为这个问题刷新一次验证码就可以了。所以每次到登录界面的时候,用户点击输入框用 tab 键切换到验证码输入框的话,用 JS 来刷新验证码图像,就可以正常验证了。用 global 不行,这样验证码的话只能用在一个会话,另一个用户要认证会报错。

这这只是治标不治本,虽然我有设置 session 过期时间但没用!上网看解决方法的话,要用 redis 存储验证码的 token,redis 有过期策略,可以从根本解决这个问题。所以我又要学习 redis 了!但现阶段只用这个方法先混过去吧。

开发流程有待优化

我是没有系统学习编程规范的,也没参与过部门组织的项目组,全是跟着自己的想法做的。有时候数据库的字段做到一半又新增别的,弄得每次都要 drop_all();写代码有些逻辑明明脑子里想好了,但是实现起来又混乱了;前后端的代码复用率很低,尤其是前端其实很多生成类的东西可以写个方法,但我非要手动又新增几条,一调整都得 ctrl+f 一个一个找才行。以前班网管写 CMDB 的时候至少还会导航栏,主页和底部栏分开模块块方便修改调用,这次却每个页面都复制一份再修改,修改个导航栏全部页面都要改一次,退步了。写后端的时候反而自己提前造好轮子,复用率比较高,但依然跟前端比只是五十步笑百步了。

以后做这些需要提前规划好逻辑内容与流程,提升自己的效率。