这篇文章上次修改于 903 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

1. SSH Key 免密登录远程服务器

通过 SSH Key 免密登录远程服务器可避免服务器密码被人暴力破解。

操作方式详见:给远程服务器设置SSH Key免密码登录

2. HTTPS

HTTPS 协议是由 HTTP 加上 TLS/SSL 协议构建的可进行加密传输、身份认证的网络协议,主要通过数字证书加密算法、非对称密钥等技术完成互联网数据传输加密,实现互联网传输安全保护。

申请证书及配置 Nginx 步骤如下。

申请 SSL 证书:

yum -y -q install yum-utils
yum-config-manager --enable rhui-REGION-rhel-server-extras rhui-REGION-rhel-server-optional
yum -y -q install certbot
certbot certonly --standalone -n -m ${EMAIL} --agree-tos -d ${DOMAIN_NAME}

证书自动续期:

yum install python-certbot-nginx -y
sed -i "s/renew/renew --nginx/g" /usr/lib/systemd/system/certbot-renew.service
systemctl daemon-reload
systemctl start certbot-renew.service

上述申请的证书位于 /etc/letsencrypt/live/${your_domain} 下,用该证书配置 Nginx:

server {
    listen 443 ssl default_server;
    server_name aping-dev.com;
    server_tokens off;

    keepalive_timeout 5;

    # 证书
    ssl_certificate "/etc/letsencrypt/live/${your_domain}/fullchain.pem"; 
    ssl_certificate_key "/etc/letsencrypt/live/${your_domain}/privkey.pem";
    ssl_session_cache shared:SSL:1m;
    ssl_session_timeout  10m;
    ssl_protocols TLSv1.2;
    ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:!aNULL:!MD5:!RC4:!DHE;
    ssl_prefer_server_ciphers on;

    root /usr/local/lighthouse/softwares/nginx/html;;
    index index.php index.html;

    # 日志
    access_log logs/blog.log combinediox;
    error_log logs/blog.error.log;

    # 后端
    location ~* ^/back/(.*) {
        rewrite ^/back(.*)$ $1 break;

        # 支持 websocket
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        proxy_pass   http://127.0.0.1:9000; 
    }

    # 前端
    location / {
        # 支持 websocket
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        proxy_pass   http://127.0.0.1:8080;  
    }
}

# http 转 https
server {
    listen       80;
    server_name  ${your_domain};
    return 301 https://$host$request_uri;
}

3. JWT

JWT 用来对用户进行认证,可防止用户的数据被别的用户篡改。

3.1 原理

  • jwt 认证过程

    • 登录

      • 前端将用户名、密码传给后端。
      • 后端验证其合法后生成 token,返回给前端。注意,每次生成 token 时,都应该使用新的随机生成的 secret key。
      • 前端记录下 token。
    • 请求

      • 前端向后端发出请求时将 token 放在 header 中。
      • 后端对 header 中的 token 进行验证,如果正确且没有超时,则将请求结果返回给前端,否则将错误返回给前端。
      • 前端收到返回后,如果发现错误,则重新跳转到登录页面。
  • 详见

3.2 实现

3.2.1 后端

package utils

import (
    "errors"
    "fmt"
    "math/rand"
    "time"

    jwt "github.com/dgrijalva/jwt-go"
)

type UserClaim struct {
    jwt.StandardClaims
   
        // 要加密的字段
    Id       int64
    Username string
}

// 后端收到登录请求后,生成 token
func GenerateToken(id int64, username, secretKey string, expireSeconds int64) (string, error) {
    claims := &UserClaim{
        StandardClaims: jwt.StandardClaims{
            ExpiresAt: time.Now().Add(time.Duration(expireSeconds) * time.Second).Unix(),
        },

        Id:       id,
        Username: username,
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(secretKey))
}

// 后端收到非登录请求后,解析 token,如果 token 过期会报错
func ParseToken(tokenStr, secretKey string) (*UserClaim, error) {
    token, err := jwt.ParseWithClaims(tokenStr, &UserClaim{}, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("invalid signature method: %v", token.Header["alg"])
        }

        return []byte(secretKey), nil
    })

    if err != nil {
        return nil, err
    }

    if claims, ok := token.Claims.(*UserClaim); ok && token.Valid {
        return claims, nil
    } else {
        return nil, errors.New("user unauthorized")
    }
}

// 每次生成 token 时,都应该用新生成的随机 secret key
func GenSecretKey(length int) string {
    result := ""
    characters := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
    chaLen := len(characters)

    for i := 0; i < length; i++ {
        result += string(characters[rand.Intn(chaLen)])
    }

    return result
}

后端收到非登录请求后,验证 token

func (bc *BaseController) VerifyToken(context *ApiContext) error {
        // 从 header 中获取 token 并解析,如果 token 过期会报错
    token :=  context.Request().Header.Get("X-Token")
    userClaim, err := utils.ParseToken(token, conf.LoginSecretKey)
    if err != nil {
        err = errors.New("login expired")
        context.ApiData.Err = goerror.New(errno.EUserUnauthorized, err.Error())
        return err
    }

        // 从 db 中获取用户
    userSvc := user.NewSvc(context.TraceId)
    user, err := userSvc.GetByUsername(conf.AdminName)
    if err != nil {
        context.ApiData.Err = goerror.New(errno.EUserUnauthorized, err.Error())
        return err
    }

        // 如果从 token 中解析出的用户与 db 中的不符,则报错
    if userClaim.Id != user.Id || userClaim.Username != user.Username {
        err = errors.New("invalid user")
        context.ApiData.Err = goerror.New(errno.EUserUnauthorized, err.Error())
        return err
    }

    return nil
}

3.2.2 前端

import axios from 'axios'
import {Code} from '../const/code.js'
import {getToken, removeToken} from '../utils/auth.js'

// 创建 axios 实例
const service = axios.create({
  baseURL: '', // api 的 base_url
  timeout: 5000 // 请求超时时间
})

// request 拦截器
service.interceptors.request.use(
  config => {
    if (getToken() != '') {
        config.headers['X-Token'] = getToken() // 让每个请求携带 token
    }
    return config
  },
  error => {
    // Do something with request error
    console.log(error) // for debug
    Promise.reject(error)
  }
)

// response 拦截器
service.interceptors.response.use(
  response => {
    const res = response.data
    if (res.code != Code.EUserUnauthorized) {  // 如果后端返回 token 非法
      return response.data
    } else {
      MessageBox.confirm(
        '登录已过期,可以取消继续留在该页面,或者重新登录',
        '确定登出',
        {
          confirmButtonText: '重新登录',
          cancelButtonText: '取消',
          type: 'warning'
        }
      ).then(() => {
        removeToken();
        location.reload() // 为了重新实例化 vue-router 对象 避免 bug
      })
      return Promise.reject('error')
    }
  },
  error => {
    // 出现网络超时
    router.push('500')
    return Promise.reject(error)
  }
)

export default service

4. 密码加密存储

4.1 管理员密码存储

存储管理员密码时使用 MD5 加密,该加密算法是不可逆的。验证密码是否正确时,将请求中的密码采用 md5 加密后与存储的密码进行比较。

4.2 配置文件中的密码存储

比如 MySql 的密码存储采用 AES 加密,该加密算法是可逆的,使用的时候解密即可。

5. 防 SQL 注入

对输入的参数进行校验,包括类型校验和格式校验。

6. API 限频

如果用户登录了,通过用户 ID 限频;否则,通过真实 IP 限频。

7. 定期备份

定期备份 DB 中的数据。

8. 防火墙设置

只开放必要的端口。