1. 项目背景与核心价值
最近在开发一个需要用户登录的Web应用时,我发现市面上大多数登录方案要么过于复杂不适合新手,要么安全性存疑。于是决定自己动手实现一套既简单易懂又足够安全的登录系统,包含短信验证码登录和账号密码登录两种方式,并且把整个过程完全开源分享出来。
这个项目最大的特点就是"全网独家首创"——不是说我发明了什么新技术,而是把两种登录方式以最简单清晰的方式整合在一起,并且提供了完整可运行的代码和详细说明。对于刚入行的开发者来说,可以直接拿来就用,对于有经验的开发者也能快速理解其中的设计思路。
2. 系统架构设计
2.1 整体技术栈选择
我选择了以下技术组合:
- 前端:Vue.js + Element UI
- 后端:Node.js + Express
- 数据库:MySQL
- 短信服务:阿里云短信API
选择这些技术主要基于几个考虑:
- 它们是目前最主流的技术组合,学习资源丰富
- 前后端分离架构更符合现代Web开发趋势
- 这些技术对新手友好,入门门槛低
2.2 数据库设计
用户表设计如下:
sql复制CREATE TABLE `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`password` varchar(255) NOT NULL,
`phone` varchar(20) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`),
UNIQUE KEY `phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这个设计有几个关键点:
- 密码字段使用varchar(255)为密码哈希预留足够空间
- 对用户名和手机号都加了唯一索引
- 自动维护创建和更新时间
3. 短信验证码登录实现
3.1 短信发送接口
javascript复制// 短信发送接口
router.post('/send-sms', async (req, res) => {
const { phone } = req.body;
// 1. 验证手机号格式
if (!/^1[3-9]\d{9}$/.test(phone)) {
return res.status(400).json({ code: 400, message: '手机号格式不正确' });
}
// 2. 生成6位随机验证码
const code = Math.random().toString().slice(2, 8);
// 3. 调用阿里云短信API
try {
await sendSMS(phone, code);
// 4. 将验证码存入Redis,设置5分钟过期
await redisClient.set(`sms:${phone}`, code, 'EX', 300);
res.json({ code: 200, message: '验证码发送成功' });
} catch (err) {
console.error('短信发送失败:', err);
res.status(500).json({ code: 500, message: '短信发送失败' });
}
});
3.2 验证码验证与登录
javascript复制// 验证码登录接口
router.post('/login-by-sms', async (req, res) => {
const { phone, code } = req.body;
// 1. 从Redis获取验证码
const savedCode = await redisClient.get(`sms:${phone}`);
if (!savedCode || savedCode !== code) {
return res.status(400).json({ code: 400, message: '验证码错误或已过期' });
}
// 2. 查询用户是否存在
let user = await User.findOne({ where: { phone } });
// 3. 如果用户不存在则自动注册
if (!user) {
user = await User.create({
phone,
username: `user_${Date.now()}`,
password: '' // 短信登录用户初始无密码
});
}
// 4. 生成JWT token
const token = jwt.sign({ userId: user.id }, config.jwtSecret, { expiresIn: '7d' });
// 5. 删除已使用的验证码
await redisClient.del(`sms:${phone}`);
res.json({
code: 200,
message: '登录成功',
data: { token, user }
});
});
4. 账号密码登录实现
4.1 密码加密存储
我使用了bcryptjs来加密存储密码:
javascript复制// 用户注册时加密密码
const salt = bcrypt.genSaltSync(10);
const hashedPassword = bcrypt.hashSync(password, salt);
// 密码验证
const isPasswordValid = bcrypt.compareSync(inputPassword, storedPassword);
选择bcrypt的原因:
- 专门为密码哈希设计
- 内置盐值防止彩虹表攻击
- 计算速度可调,可以随着硬件性能提升增加计算成本
4.2 账号密码登录接口
javascript复制// 账号密码登录接口
router.post('/login-by-password', async (req, res) => {
const { username, password } = req.body;
// 1. 查询用户
const user = await User.findOne({ where: { username } });
if (!user) {
return res.status(400).json({ code: 400, message: '用户不存在' });
}
// 2. 验证密码
if (!bcrypt.compareSync(password, user.password)) {
return res.status(400).json({ code: 400, message: '密码错误' });
}
// 3. 生成JWT token
const token = jwt.sign({ userId: user.id }, config.jwtSecret, { expiresIn: '7d' });
res.json({
code: 200,
message: '登录成功',
data: { token, user }
});
});
5. 安全防护措施
5.1 防止暴力破解
javascript复制// 在登录接口中添加限流
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 10, // 每个IP最多10次请求
handler: (req, res) => {
res.status(429).json({
code: 429,
message: '请求过于频繁,请稍后再试'
});
}
});
router.post('/login-by-password', limiter, async (req, res) => {
// 登录逻辑
});
5.2 JWT安全配置
javascript复制// JWT配置
const jwt = require('jsonwebtoken');
const config = {
jwtSecret: process.env.JWT_SECRET || 'your-secret-key',
jwtExpiresIn: '7d'
};
// 生成token时设置合适的过期时间
const token = jwt.sign(payload, config.jwtSecret, {
expiresIn: config.jwtExpiresIn
});
安全建议:
- 生产环境一定要使用足够复杂的JWT密钥
- 设置合理的token过期时间
- 考虑使用refresh token机制
6. 前端实现要点
6.1 登录页面设计
vue复制<template>
<div class="login-container">
<el-tabs v-model="activeTab">
<el-tab-pane label="短信登录" name="sms">
<el-form :model="smsForm" :rules="smsRules">
<el-form-item prop="phone">
<el-input v-model="smsForm.phone" placeholder="请输入手机号"></el-input>
</el-form-item>
<el-form-item prop="code">
<div class="code-input">
<el-input v-model="smsForm.code" placeholder="请输入验证码"></el-input>
<el-button
:disabled="countdown > 0"
@click="sendSmsCode"
>
{{ countdown > 0 ? `${countdown}s后重试` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
<el-button type="primary" @click="handleSmsLogin">登录</el-button>
</el-form>
</el-tab-pane>
<el-tab-pane label="账号密码登录" name="password">
<el-form :model="passwordForm" :rules="passwordRules">
<el-form-item prop="username">
<el-input v-model="passwordForm.username" placeholder="请输入用户名"></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="passwordForm.password"
type="password"
placeholder="请输入密码"
></el-input>
</el-form-item>
<el-button type="primary" @click="handlePasswordLogin">登录</el-button>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</template>
6.2 验证码倒计时实现
javascript复制// 发送验证码逻辑
methods: {
sendSmsCode() {
this.$refs.smsForm.validateField('phone', async (error) => {
if (error) return;
try {
await api.sendSmsCode({ phone: this.smsForm.phone });
this.$message.success('验证码发送成功');
// 开始倒计时
this.countdown = 60;
const timer = setInterval(() => {
this.countdown--;
if (this.countdown <= 0) {
clearInterval(timer);
}
}, 1000);
} catch (err) {
this.$message.error(err.message || '验证码发送失败');
}
});
}
}
7. 部署与上线注意事项
7.1 环境变量配置
创建.env文件:
code复制# 数据库配置
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=yourpassword
DB_NAME=login_demo
# JWT配置
JWT_SECRET=your_jwt_secret_key
# 短信服务配置
SMS_ACCESS_KEY_ID=your_access_key
SMS_ACCESS_KEY_SECRET=your_secret
SMS_SIGN_NAME=your_sign_name
SMS_TEMPLATE_CODE=your_template_code
7.2 Nginx配置建议
code复制server {
listen 80;
server_name yourdomain.com;
location / {
root /path/to/your/frontend/dist;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
8. 常见问题与解决方案
8.1 短信发送失败排查
-
检查阿里云权限配置:
- 确保AccessKey有短信服务权限
- 检查短信签名和模板是否审核通过
-
检查手机号格式:
- 确保手机号是11位数字
- 检查是否有国际区号(国内手机号不需要)
-
检查频率限制:
- 阿里云对同一手机号有发送频率限制
- 建议在生产环境添加自己的频率限制逻辑
8.2 JWT失效问题
-
检查token过期时间:
- 确保客户端时间与服务器时间同步
- 检查token生成时的expiresIn配置
-
检查密钥一致性:
- 确保生成和验证token使用相同的密钥
- 重启服务后密钥不应改变
-
检查token存储:
- 前端应妥善存储token,避免localStorage被清除
- 考虑使用httpOnly cookie增强安全性
9. 性能优化建议
9.1 数据库索引优化
除了基本的用户表索引外,可以考虑:
sql复制-- 为常用查询字段添加复合索引
ALTER TABLE `users` ADD INDEX `idx_phone_password` (`phone`, `password`);
ALTER TABLE `users` ADD INDEX `idx_username_password` (`username`, `password`);
9.2 Redis缓存优化
- 使用连接池管理Redis连接
- 对频繁访问的数据设置适当过期时间
- 考虑使用Redis集群应对高并发场景
javascript复制// Redis连接池配置
const redis = require('redis');
const { promisify } = require('util');
const client = redis.createClient({
host: '127.0.0.1',
port: 6379,
password: 'yourpassword',
db: 0,
retry_strategy: (options) => {
if (options.error && options.error.code === 'ECONNREFUSED') {
return new Error('Redis服务器拒绝连接');
}
if (options.total_retry_time > 1000 * 60 * 60) {
return new Error('重试时间超过1小时');
}
if (options.attempt > 10) {
return undefined; // 停止重试
}
return Math.min(options.attempt * 100, 5000); // 重试间隔
}
});
// 将回调方法转为Promise
const getAsync = promisify(client.get).bind(client);
const setAsync = promisify(client.set).bind(client);
const delAsync = promisify(client.del).bind(client);
10. 扩展功能思路
10.1 第三方登录集成
可以考虑集成微信、QQ等第三方登录方式:
- 在用户表添加第三方登录相关字段
- 实现OAuth2.0授权流程
- 处理第三方登录回调
10.2 多因素认证
提升安全性可以考虑:
- 登录后要求短信二次验证
- 支持Google Authenticator
- 生物识别认证集成
10.3 登录日志记录
记录用户登录行为用于安全审计:
sql复制CREATE TABLE `login_logs` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`login_type` enum('password','sms','oauth') NOT NULL,
`ip_address` varchar(45) NOT NULL,
`user_agent` varchar(255) DEFAULT NULL,
`login_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`status` enum('success','failed') NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_login_at` (`login_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这个登录系统虽然看起来简单,但包含了现代Web应用登录的核心要素。在实际开发中,我建议根据具体业务需求和安全要求进行调整。比如金融类应用需要更强的安全措施,而内部系统可能可以简化一些步骤。