用 Python 实现一个简单的网站登录页面

2020-11-29
·
19 min read
banner image

黑历史文章系列

尝试实现一个能够前后端交互的简单的登陆页面,最终实现的效果如下, 点这里 (网站还没备案, 可能上不去) (现在网站已经挂了)可以体验.

login page

login info


后端部分

服务端部署在一个 CentOS 的企鹅云上, 目前还是不太熟悉 Java Web 开发的那套东西, 用 Flask 简单实现了登录和注册这两个与数据库交互的接口, 数据库用了 MariaDB, 因为不知道为啥企鹅云的 CentOS 上没有 MySQL. 部署上采用了 Gunicorn 作为 HTTP Server, 使用 Nginx 进行反向代理.

server overview

数据库的处理

首先在服务器上安装数据库, 先尝试了直接 sudo yum install mysql, 结果奇怪的是直接给我装了 mariadb 这个包, 尝试了下输入 mysql 命令但无解.

了解了下 MariaDB 是由 MySQL 的创始人创建的一个开源分支版本, 能够与 MySQL 兼容, 那就直接安装这个数据库好了.

搜了一下还需要安装 mariadb-server 这个包才行, 然后安装好后使用 sudo systemctl start mariadb & sudo systemctl enable mariadb 启用 server 服务, 之后可以用 sudo systemctl status mariadb 检查一下状态.

mariadb status

然后接下来执行 sudo mysql_secure_installation 来进行初始化的配置, 比如设置 root 用户密码之类的.

接下来试图在本地连接一下服务端的数据库, 首先在企鹅云的控制台防火墙里把 3306 端口打开, 结果登录的时候出现了下面这样的错误

ERROR 1130 (00000): Host ''xxx.xx.xxx.xxx'' is not allowed to connect to this MySQL servers

查了一下原因好像是不能直接用 root 账户登录... 于是按照这篇回答, 另外再新建了一个管理员账户

mysql> CREATE USER 'tunkshif'@'localhost' IDENTIFIED BY '<password>';
mysql> GRANT ALL PRIVILEGES ON *.* TO 'tunkshif'@'localhost'
    ->     WITH GRANT OPTION;
mysql> CREATE USER 'tunkshif'@'%' IDENTIFIED BY '<password>';
mysql> GRANT ALL PRIVILEGES ON *.* TO 'tunkshif'@'%'
    ->     WITH GRANT OPTION;

之后用新创建的管理员账户登录就行了.

然后再新建一个名为 test 的数据库, 新建一张 users 表, 分别有三个字段: 作为主键的能够自增的 user_id, 存储用户名的 user_name, 存储密码的 user_pwd.

CREATE DATABASE test;
USE test;
CREATE TABLE IF NOT EXISTS `users`(
    `user_id` INT UNSIGNED AUTO_INCREMENT,
    `user_name` VARCHAR(20) NOT NULL,
    `user_pwd` VARCHAR(30) NOT NULL,
    PRIMARY KEY (`user_id`)
)ENGINE=InnoDB DEFAULT CHARSET=utf8;

然后数据库准备就差不多到这里结束了.

Flask 后端编写

我们先在本地编写好后再上传到服务器, 首先新建一个虚拟环境, 安装需要的包

其中 flask-cors 是一个用来处理跨域请求问题的包, mysql-connector-python 提供了使用 python 操作数据库的接口, gunicorn 是一个 WSGI Server.

由于网络因素下载慢的话, 可以换用清华的镜像源, 临时使用的话只需要用 pip install -i https://pypi.tuna.tsinghua.edu.cn/simple <package-name> 就好了

mkdir login-demo && cd login-demo
python3 -m venv venv
source ./venv/bin/activate
pip3 install flask flask-cors mysql-connector-python gunicorn

然后我们的项目结构如下, app 包内的 __init__.py 文件里写 Flask 的路由部分, config.py 里写数据库的用户名密码什么的, db_utils.py 里具体写封装好的在登陆注册时会用到的对数据库进行操作的逻辑.

login-demo
├── app
│   ├── __init__.py
│   ├── config.py
│   └── db_utils.py
├── venv
│   ├── Include
│   ├── Lib
│   ├── Scripts
│   └── pyvenv.cfg
└── wsgi.py

db_utils.py 里具体实现了这样一些函数, is_user_existing(username: str) -> bool 判断用户名是否存在, add_new_user(username: str, password: str) -> None 用来向数据库里存储新的用户, is_password_match(username: str, password: str) -> bool 用来判断所提及的用户名和密码是否和数据库里面存的数据相匹配.

这里的添加用户函数里面没有预先对用户名已存在的情况进行判定, 具体判定写在了视图函数里面.

db_utils.py

然后接下来的 __init__.py 里面开始具体写接口, 我们想实现的接口大致设计如下:

http://xxx.xxx/api/login?username=xxx&password=xxx 或者是 http://xxx.xxx/api/signup?username=xxx&password=xxx 发送 GET/POST 请求, 获得一个返回的 json 数据, 里面给出一个操作成功与否的状态码和具体的提示信息, 大概就像这样 { "code": 200, "msg": "Logged in successfully!" }

所以具体写出来的 __init__.py 大概就像这样的

首先为了方便写了一个 message() 函数, 返回一个要作为相应的字典. (其实直接把这个函数写在跟路由一起好像不太好, 但再单独再起一个 utils.py 也不太好, 就随便写在这里算了...)

我们可以用 flask 提供的 request.args 来获取到用户调用 API 时传过来的参数, 再用 flask 提供的 jsonify 将字典转为 json 数据作为相应返回. 视图函数里面根据用户所参数的参数来作出相应.

(手动忽略下面那个默认的 Hello World.)

init.py

另外, 还要处理一下跨域的问题。

由于浏览器受同源策略的限制,在使用 XMLHttpRequest 对象进行跨域请求时,通常会报 No 'Access-Control-Allow-Origin' header is present on the requested resource. 的错误,导致请求失败。

直接用 flask-cors 这个库提供的 cross_origin 函数, 这是一个用在视图函数的装饰器.

写好了之后可以在本地运行测试一下。

前端部分

前端界面大致设计为一个居中的卡片样式的盒子, 里面放上标题, 表单, 按钮之类的. 另外还实现一个弹窗, 用于展示按下登录注册按钮后相应的消息. 登录注册之前先对表单中输入的用户名和密码进行验证, 然后发送 Ajax 请求给服务端, 再把返回过来的信息展示在弹窗上, 这里会用到 JQuery 来方便进行操作.

界面

界面的主体的 HTML 结构如下

<div class="main">
  <div class="header">LOGIN</div>
  <form id="form" class="form">
    <input
      id="username-input"
      class="input"
      type="text"
      required
      placeholder="Username"
      name="username"
    />
    <input
      id="password-input"
      class="input"
      type="password"
      required
      placeholder="Password"
      name="password"
    />
  </form>
  <button id="login-button" class="button" onclick="login()">Login</button>
  <p class="link"><a href="signup.html">Not have an account yet?</a></p>
</div>

可能会比较关键的部分 CSS 如下

.main {
  height: 450px; /* 指定高度 */
  width: 450px; /* 指定宽度 */
  margin: 8em auto; /* 通过设置 margin 来实现居中 */
  border-radius: 1.5em; /* 设置圆角 */
  /* offset-x | offset-y | blur-radius | spread-radius | color */
  box-shadow: 2px 2px 20px 2px rgba(0, 0, 0, 0.15); /* 设置阴影, 具体各项参数的含义见上方注释 */
}

.input {
  /* 其它与 .main 类似的属性不再说明*/
  outline: none; /* 隐藏输入框的 outline , 这个 outline 具体是啥看下面的图 */
}

.input:focus {
  /* 通过伪类设置输入框在聚焦的时候加深边框颜色 */
  border: 2px solid rgba(0, 0, 0, 0.5);
}

下面的左右两图分别是有无 outline 的情况下聚焦输入框的样式.

input outline

另外是弹窗的实现, 用一个带有透明度的黑色的 overlay 覆盖住整个页面, 然后再在里面定义一个 popup 弹窗, 里面分别展示标题, 关闭按钮, 需要显示的消息. 然后整个 overlay 默认的 display 属性设置为 none, 等按下登陆或注册按钮后再显示.

<div id="overlay" class="overlay">
  <div class="popup">
    <span id="title">TITLE</span>
    <a href="#" id="close-btn" onclick="$('#overlay').hide()">&times;</a>
    <div id="message">This is a message.</div>
  </div>
</div>

下面只展示比较关键的 overlaypopup 的样式

.overlay {
  display: none; /* 默认不显示 */
  position: fixed; /* 将位置固定, 不会因其它元素变动 */
  z-index: 1; /* 设置固定元素的堆叠层级 */
  left: 0; /* 设置该元素的外边距到其所在的元素的距离为 0 */
  top: 0;
  width: 100%; /* 将该元素铺满整个页面 */
  height: 100%;
  overflow: auto; /*  对象内容超出指定宽高时出现滚动条 */
  background-color: rgba(0, 0, 0, 0.1); /* 设置颜色为带有透明度的黑色 */
}

.popup {
  width: 280px; /* 设置弹窗的宽高 */
  height: 150px;
  margin: 15% auto; /* 将弹窗居中 */
  background: #fafafa;
  border-radius: 1em;
}

逻辑

先写一个展示弹窗的函数, 将对应的元素改为传入的标题和消息, 然后将遮层展示出来. 相应的给关闭按钮绑定一个点击事件, 点击关闭按钮的时候将遮层隐藏, 这样就实现了一个简陋的伪弹窗.

function showPopup(title, msg) {
  $("#title").text(title)
  $("#message").text(msg)
  $("#overlay").show()
}

在用户按下登录注册按钮, 完成提交请求之前, 先对表单内的用户名和密码进行验证.

下面用了从网上抄来的正则表达式来对用户名和密码进行检测, 用户名只能是大小写字母、短横线或是下划线, 长度必须在 3 ~ 20 之间, 密码至少必须含有大小写字母和数字, 且长度在 8 ~ 30 之间. 不符合要求的弹窗显示提示消息.

function checkForm() {
  let userPattern = /^[a-zA-Z0-9_-]{3,20}$/
  let pwdPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[^]{8,30}$/
  let username = $("#username-input").val()
  let password = $("#password-input").val()

  if (!userPattern.test(username)) {
    showPopup("Error", "User name can only be letters, dash or underline in 3 to 16 characters!")
    return false
  }
  if (!pwdPattern.test(password)) {
    showPopup(
      "Error",
      "Password must include both uppercase and lowercase letters and numbers with at least 8 characters!"
    )
    return false
  }

  return true
}

按下登录注册按钮后的事件函数逻辑相似, 只贴一个就行了.

首先对表单进行检查, 如果通过检查的话使用 Ajax 发送异步请求, 使用 JQuery 提供的 ajax 函数, 其参数是一个字典, 其中 type 指明请求类型, dataType 指明预期得到的返回数据的类型, url 指明目标请求链接, success 指明了一个请求成功后执行的回调函数, 其参数 data 即是返回过来的 json 数据.

这里的 $("#form").serialize() 方法是将表单中填入的数据序列化, 在写 HTML 的时候, 表单里的 input 元素有一个 name 的属性, 该方法会根据 name 属性的内容生成参数, 就像 username=xxx&password=xxx 这种格式的.

其实本来也可以直接在字典中用 data 来指定要传送的参数, 但试了下不知道为什么不行, 可能是因为 Flask 那里 request.args 的原因, 目前还是直接拼接 URL 算了.

function login() {
  if (checkForm()) {
    $.ajax({
      type: "POST",
      dataType: "json",
      url: "http://logindemo.tunkshif.design/api/login?" + $("#form").serialize(),
      success: function (data) {
        showPopup("Info", data["msg"])
      }
    })
  }
}

部署部分

Gunicorn

网页的前后端部分都实现好了, 接下来要部署到服务器上了, 这一步反而是遇到的坑最多的地方 (=_=||)

先配合 Nginx 部署 Flask 写的后端服务, 搜到了一篇教程, 企鹅云里的这台 CentOS 也是基于 systemed 的发行版, 可以照着这篇教程做

先用 scp 把需要的文件传到服务器上, 接下来要用 service 的方式用 gunicorn 把 flask 项目跑起来, 需要编写一个 systemed Unit File.

sudo nvim /etc/systemd/system/logindemo-app.service

其中的 After 指明的是在网络服务启动完成后再启动该服务, [Service] 块里面, User 表示使用哪个用户来运行进程, 填入自己的用户名.

Group 这里按照描述是需要填写一个用户组的名称, 即 Nginx 运行所使用的用户组, 便于获取用户所有权进行通信, 但按照教程里面填写 www-data 后, 服务一直启动不起来, 查看日志提示的是该用户组不存在. 遂 cat /etc/group 发现的确没有 www-data 这个用户组. 搜索了解了一下这个用户组是干啥用的之后, 发现不同发行版这个用户组名可能不同, 有可能是 www-data 或者 apache 还有可能是 nobody, 看了下系统里有个 nobody 用户组, 填进去试了下还是跑不起来, 于是看了下 ps aux 发现 Nginx 是以 nginx 用户运行的...改成 nginx 之后就好了.

接着后面分别指定运行的环境, 和运行服务执行的命令等等. 注意这里我们的 Gunicorn 服务是运行在 5000 端口上的.

logindemo-app.service

写好后直接 sudo systemctl start logindemo-app && sudo systemctl enable logindemo-app 运行启动服务, 然后 sudo systemctl status logindemo-app 检查一下状态.

还可以再用 curl 请求测试一下, curl http://localhost:5000/api/login?username=xxx&password=xxx, 运行成功的话可以看到所返回的 json 数据.

Nginx

接下来配置 Nginx, 安装过程就不再叙述, 只写一下配置的过程.

首先在网上搜出来的一些基于 Ubuntu 讲解的教程, 比如这篇, 都会叫我们去 /etc/nginx 下面的 sites-availablesites-enabled 文件夹里面写配置, 这其实是为了将配置拆解开进行模块化, 但并不是所有的发行版安装来的 Nginx 都默认提供了这个给功能, 最开始配置 Nginx 的就坑在了这里...因为我用的 CentOS 里的 Nginx 默认根本不提供上面这套, 而是默认将不同的配置放在 /etc/nginx/conf.d 文件夹里面, 然后再在 /etc/nginx/nginx.conf 里面的 http 块里面加上 include /etc/nginx/conf.d/*.conf, 将自己单写的配置也包括进去.

另外注意在 include 语句之前加了一块 server 段, 表示默认访问都返回 403.

nginx.conf

然后开始准备写配置文件, 在之前如果有域名的话, 先去 DNS 管理里面添加一条让二级域名指向服务器公网 IP 的 A 记录, 下面将使用 logindemo.tunkshif.design 这个域名.

/etc/nginx/conf.d 路径里面新建一个 logindemo.tunkshif.design.conf 的配置文件, 同时将前端页面的所有静态资源文件放在 /var/www/logindemo.tunkshif.design 路径下.

服务器中的 gunicorn 服务是跑在 5000 端口上的, 而浏览器访问到的是 80 端口, 所以要实现在浏览器中通过 logindemo.tunkshif.design 域名能够访问到服务器上 5000 端口的服务

配置文件中的 listen 80 指的是监听 80 端口, server_name 指定域名, 表示只有通过该域名访问时该配置文件才生效, root 用来设置资源文件所在的根目录, index 指定首页, 即访问 http://logindemo.tunkshif.design 时展示的页面.

然后下面是两个用来匹配路径的 location 配置, 第一个 location /api/ 表示通过 http://logindemo.tunkshif.design/api/ 访问时走该条配置, 前面几行的 set_proxy_header 是按教程设置的请求头, 具体什么作用不清楚了... 最关键的是 proxy_pass 这一条配置, 表示把通过 http://logindemo.tunkshif.design/api/ 的请求都转发到服务器的 5000 端口上, 也就是之前跑的那个 gunicorn 服务.

下面的 location / 匹配的是访问 HTML 页面, 即如果访问 http://logindemo.tunkshif.design/xxx.xxx, 会先从 root 指定的根目录里寻找 xxx.xxx 文件, 如果没找到再找有没有 xxx.xxx/ 文件夹, 再没有的话就返回 404.

site conf

配置好之后先运行 sudo nginx -t 检查下配置文件是否有错误, 没问题之后再用 sudo systemctl restart nginx 重启 Nginx 服务, 之后就可以在浏览器里面访问到部署好的页面辣.