Finer04's Blog
首页
乱写一通
脑洞破文
随便谈谈
当前播放.page
做登录系统(LogintoRedirect)的小笔记
Finer04
September 18, 2023
9913 字
文章目录
今天是我没找到工作的第 18 天,还在慢慢找。当然找的时候我也在练习给自己充会电,有项目做我才好学习相关知识,不然完全学不进去。就像以前用 Blender 做模型,半路开始做完全零基础真的是乱做,系统学习后就轻轻松松完成改模。所以有需求的学习对我来说更有效率。这次系统是我以等保要求做的一个登陆系统,主要是给没有登录功能的开源或者薄弱认证的系统用的。虽然我离职了不会碰等保项目了,但未来肯定会遇到这类需求的,所以还是先做一个练下手吧。 在这次项目中,也了解很多前后端、容器和数据库的相关知识、特别是数据库方面,用 SQLAlchemy 很方便我去理解数据库的原理和操作,我对数据库的知识非常薄弱。 项目我命名为 [LogintoRedirct](https://github.com/finer04/LogintoRedirect),意思就是登录了再重定向。大概流程是登录了这个系统后,自动跳转到目标系统,就用 iframe 套个壳而已。整套代码我是用 Flask 实现后端功能(暂时不会django);数据库用简单的 SQLite,数据库交互就用 SQLAlchemy 对象化数据,不用自己写复杂的 SQL 语句了;前端还是用 jquery 做,我不会其他 JS 框架呀,所以我没去找前端的工作做。 这次很多内容都用到 ChatGPT 帮我解决很多难题,有些不想花时间的验证的代码我让 GPT 帮我生成,提升了很多效率。 ## 需求 - 背景:为了应付等保或给没有登录界面的开源程序使用的伪登录界面 - 模块 - 登录验证+安全码验证 - 新建用户 - 超时登录、页面过期重新认证、密码有效期、限制IP访问 - 设置画面 - 前端 - 账户名、密码、验证码是否为空 - 密码是否符合规则(特殊字符、大小写、数字、长度..) - 服务端 - 验证码是否正确(对应时间戳是否过期) - 账户是否存在(未注册、已注销) - 密码是否正确(记录连续输入错误次数,超过5次,账号锁定4小时。或提升验证等级,采取账号+密码+验证码+短信验证) ## 界面展示 ### 首页  ### 访问模板平台  ### 设置页面  ## 后端方面 我前端写得很差,拿出来讨论丢人现眼,所以这里不讨论前端遇到的问题。 ### 如何实现安全令功能呢? 我是用 `pyotp` 模块的,它可以做一个基于时间的动态口令,可以在谷歌验证器或其他 OTP 小程序扫个码就能用了。而且我看了下示例也挺简单的,我就分成了生成,验证和创建二维码 uri 这三个模块。 ```python 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 的气泡框中。 ```javascript function create_link(str) { var base64_img = jrQrcode.getQrBase64('otpauth://totp/login_dengbao?secret='+str); var over = '
'+str+'
' return over } ``` ### 怎么做密码复杂度呢? 用正则表达式去判断即可,这个我是直接问 ChatGPT 的,我不想花时间写正则去判断。要求是密码满足 大写+小写+特殊字符 和 8 个字符以上,但 GPT 生成的正则只支持部分特殊符号,因此要在 `(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]` 这段补充不常用的特殊字符。 ```python 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 自带这个模块,用起来挺像以前做日志收集的。 ```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 的时候至少还会导航栏,主页和底部栏分开模块块方便修改调用,这次却每个页面都复制一份再修改,修改个导航栏全部页面都要改一次,退步了。写后端的时候反而自己提前造好轮子,复用率比较高,但依然跟前端比只是五十步笑百步了。 以后做这些需要提前规划好逻辑内容与流程,提升自己的效率。
评论已关闭
▲ Top
评论已关闭