技术栈与目标

  • Node.js + Express:Web 框架
  • mysql2:MySQL 驱动(使用 Promise 版本)
  • bcryptjs:密码加盐哈希,不存储明文密码
  • jsonwebtoken:签发与校验 JWT

最终实现三个接口:

接口 方法 说明
/api/register POST 用户注册,密码加密入库
/api/login POST 用户登录,签发 JWT
/api/user/info GET 携带 Token 获取用户信息(受保护)

一、项目初始化

1
2
3
mkdir user-auth && cd user-auth
npm init -y
npm install express mysql2 bcryptjs jsonwebtoken dotenv

目录结构:

1
2
3
4
5
user-auth/
├── app.js # 入口
├── db.js # 数据库连接池
├── auth.js # 鉴权中间件
└── .env # 环境变量

二、数据库与连接池

先建表:

1
2
3
4
5
6
7
8
9
10
CREATE DATABASE IF NOT EXISTS user_auth DEFAULT CHARSET utf8mb4;

USE user_auth;

CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(100) NOT NULL COMMENT 'bcrypt 哈希',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

db.js 使用连接池,避免频繁建立连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// db.js
const mysql = require('mysql2/promise')
require('dotenv').config()

const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'user_auth',
waitForConnections: true,
connectionLimit: 10,
charset: 'utf8mb4'
})

module.exports = pool

三、注册接口:密码必须加盐哈希

明文密码一旦数据库泄露就全部暴露,必须用 bcryptjs 加密(自带盐,同样的密码每次哈希结果都不同):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// app.js(部分)
const express = require('express')
const bcrypt = require('bcryptjs')
const jwt = require('jsonwebtoken')
const pool = require('./db')

const app = express()
app.use(express.json())

const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me'

// 注册
app.post('/api/register', async (req, res) => {
const { username, password } = req.body

// 1. 参数校验
if (!username || !password) {
return res.status(400).json({ message: '用户名和密码不能为空' })
}
if (password.length < 6) {
return res.status(400).json({ message: '密码至少 6 位' })
}

// 2. 检查用户名是否已存在
const [exist] = await pool.query('SELECT id FROM users WHERE username = ?', [username])
if (exist.length > 0) {
return res.status(409).json({ message: '用户名已存在' })
}

// 3. bcrypt 加盐哈希(10 是计算成本,越高越慢越安全)
const hash = await bcrypt.hash(password, 10)

// 4. 入库
await pool.query('INSERT INTO users (username, password) VALUES (?, ?)', [username, hash])
res.status(201).json({ message: '注册成功' })
})

四、登录接口:校验密码并签发 JWT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 登录
app.post('/api/login', async (req, res) => {
const { username, password } = req.body
if (!username || !password) {
return res.status(400).json({ message: '用户名和密码不能为空' })
}

const [rows] = await pool.query('SELECT * FROM users WHERE username = ?', [username])
if (rows.length === 0) {
// 不暴露"用户不存在",统一提示,防止用户名枚举
return res.status(401).json({ message: '用户名或密码错误' })
}

const user = rows[0]
const ok = await bcrypt.compare(password, user.password)
if (!ok) {
return res.status(401).json({ message: '用户名或密码错误' })
}

// 签发 JWT:payload 只放非敏感信息
const token = jwt.sign(
{ id: user.id, username: user.username },
JWT_SECRET,
{ expiresIn: '7d' }
)

res.json({ token })
})

五、鉴权中间件:保护受保护接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// auth.js
const jwt = require('jsonwebtoken')

function auth(req, res, next) {
// 约定格式:Authorization: Bearer <token>
const header = req.headers.authorization
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ message: '未登录' })
}

const token = header.slice(7)
try {
// 校验签名与过期时间,解析出 payload
req.user = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret-change-me')
next()
} catch (e) {
return res.status(401).json({ message: 'Token 无效或已过期' })
}
}

module.exports = auth

受保护接口只需要在路由上挂中间件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// app.js(部分)
const auth = require('./auth')

app.get('/api/user/info', auth, async (req, res) => {
const [rows] = await pool.query(
'SELECT id, username, created_at FROM users WHERE id = ?',
[req.user.id]
)
if (rows.length === 0) {
return res.status(404).json({ message: '用户不存在' })
}
res.json(rows[0])
})

app.listen(3000, () => console.log('Server running at http://localhost:3000'))

六、接口测试

用 curl 完整走一遍流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1. 注册
curl -X POST http://localhost:3000/api/register \
-H "Content-Type: application/json" \
-d '{"username":"zhangsan","password":"123456"}'

# 2. 登录,拿到 token
curl -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"username":"zhangsan","password":"123456"}'
# => {"token":"eyJhbGciOiJIUzI1NiIs..."}

# 3. 带 token 访问受保护接口
curl http://localhost:3000/api/user/info \
-H "Authorization: Bearer <上一步的token>"

安全要点总结

  1. 密码永远不存明文:用 bcrypt 加盐哈希,成本因子建议 10-12;
  2. 登录失败统一提示:不要区分”用户不存在”和”密码错误”,防止撞库枚举;
  3. JWT payload 不放敏感数据:只放 id、username 这类标识信息;
  4. JWT_SECRET 放环境变量:不要写死在代码里,更不要提交到 Git;
  5. SQL 全部用参数化查询(? 占位):防止 SQL 注入;
  6. Token 设置过期时间:expiresIn: '7d',生产环境还可考虑 refresh token 机制。

总结

到这里,一个可用的注册/登录/JWT 鉴权闭环就完成了。后续可以在这个基础上扩展:refresh token 刷新机制、角色权限(RBAC)、接口限流、统一错误处理中间件等。