今天是我没找到工作的第 18 天,还在慢慢找。当然找的时候我也在练习给自己充会电,有项目做我才好学习相关知识,不然完全学不进去。就像以前用 Blender 做模型,半路开始做完全零基础真的是乱做,系统学习后就轻轻松松完成改模。所以有需求的学习对我来说更有效率。这次系统是我以等保要求做的一个登陆系统,主要是给没有登录功能的开源或者薄弱认证的系统用的。虽然我离职了不会碰等保项目了,但未来肯定会遇到这类需求的,所以还是先做一个练下手吧。
在这次项目中,也了解很多前后端、容器和数据库的相关知识、特别是数据库方面,用 SQLAlchemy 很方便我去理解数据库的原理和操作,我对数据库的知识非常薄弱。
项目我命名为 LogintoRedirct,意思就是登录了再重定向。大概流程是登录了这个系统后,自动跳转到目标系统,就用 iframe 套个壳而已。整套代码我是用 Flask 实现后端功能(暂时不会django);数据库用简单的 SQLite,数据库交互就用 SQLAlchemy 对象化数据,不用自己写复杂的 SQL 语句了;前端还是用 jquery 做,我不会其他 JS 框架呀,所以我没去找前端的工作做。
这次很多内容都用到 ChatGPT 帮我解决很多难题,有些不想花时间的验证的代码我让 GPT 帮我生成,提升了很多效率。
需求
- 背景:为了应付等保或给没有登录界面的开源程序使用的伪登录界面
模块
- 登录验证+安全码验证
- 新建用户
- 超时登录、页面过期重新认证、密码有效期、限制IP访问
- 设置画面
前端
账户名、密码、验证码是否为空
- 密码是否符合规则(特殊字符、大小写、数字、长度..)
服务端
- 验证码是否正确(对应时间戳是否过期)
- 账户是否存在(未注册、已注销)
- 密码是否正确(记录连续输入错误次数,超过5次,账号锁定4小时。或提升验证等级,采取账号+密码+验证码+短信验证)
界面展示
首页
访问模板平台
设置页面
后端方面
我前端写得很差,拿出来讨论丢人现眼,所以这里不讨论前端遇到的问题。
如何实现安全令功能呢?
我是用 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.py
的 login_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
,字段应该要这样设计
字段名称 | 数据类型 | 备注 |
---|---|---|
id | int(10) | 主键,当作编号 |
username | String(20) 等效于 varchar(20) | 用户名 |
password | String(20) | 密码 |
OTP_id | String(30) | 动态口令,允许空值 |
otp_enable | Boolean | 开启动态口令,默认值为 False |
failure_count | Int(2) | 登录失败次数,默认值为 0 |
failure_last_time | Datetime | 最后登录失败时间 |
password_final_time | datetime | 密码到期时间,允许空值 |
user_enable | Boolean | 用户时候开启,默认值为启用 |
character | String(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,
字段名称 | 数据类型 | 备注 |
---|---|---|
id | int(2) | 主键,当作编号 |
titile | String(30) 等效于 varchar(30) | 系统标题 |
white_IP_list | text | 限制 IP,允许空值 |
session_long | String(3) | 保活时间,这里我觉得可以用int的 |
set_login_failure_time | String(3) | 登录失败次数限制 |
failure_countset_login_lock_time | String(3) | 登录锁定时长 |
dest_url | varchar(100) | 跳转 URL |
url_mode | string(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_basic
和 allow deny
来加一层用户认证和限制 IP 访问,但也是自欺欺人,做反向代理才有用,才能做到安全。
我之前做过 Kibana 的反向代理,因为要套个 https。想着 flask 能不能实现反向代理呢,可以是可以,用 requests
去访问源站再返回到 flask 的路由上。我测试过,我自己做的简单平台是可以正常反向代理的,但是给 ELK 用的话会白屏,因为我没处理好路由问题,而且带跳转的。上网抄作业也找不到想要的内容,我就暂时放弃了。
验证码存活问题
验证码我是抄网上的案例的,但是有个问题,上一个验证码会一直存在 session 的,直到下一次访问才会被替换,导致我每次登录系统会报错验证码出错,但我明明没有写错的,因为前端的验证码是最新的,但是系统一直用着上一个 session 的验证码。我用了一个偷懒的方法处理了,因为这个问题刷新一次验证码就可以了。所以每次到登录界面的时候,用户点击输入框用 tab 键切换到验证码输入框的话,用 JS 来刷新验证码图像,就可以正常验证了。用 global 不行,这样验证码的话只能用在一个会话,另一个用户要认证会报错。
这这只是治标不治本,虽然我有设置 session 过期时间但没用!上网看解决方法的话,要用 redis 存储验证码的 token,redis 有过期策略,可以从根本解决这个问题。所以我又要学习 redis 了!但现阶段只用这个方法先混过去吧。
开发流程有待优化
我是没有系统学习编程规范的,也没参与过部门组织的项目组,全是跟着自己的想法做的。有时候数据库的字段做到一半又新增别的,弄得每次都要 drop_all()
;写代码有些逻辑明明脑子里想好了,但是实现起来又混乱了;前后端的代码复用率很低,尤其是前端其实很多生成类的东西可以写个方法,但我非要手动又新增几条,一调整都得 ctrl+f
一个一个找才行。以前班网管写 CMDB 的时候至少还会导航栏,主页和底部栏分开模块块方便修改调用,这次却每个页面都复制一份再修改,修改个导航栏全部页面都要改一次,退步了。写后端的时候反而自己提前造好轮子,复用率比较高,但依然跟前端比只是五十步笑百步了。
以后做这些需要提前规划好逻辑内容与流程,提升自己的效率。
发表评论