第 9 章:用户登录注册
约 4329 字大约 14 分钟
2025-12-19
本章学习目标
- 设计用户数据表
- 理解密码安全与哈希
- 学习 JWT 认证机制
- 实现注册和登录功能
- 🎉 成果:用户系统上线!
文章可以发布了,但现在谁都能发——你的博客可能被陌生人发满广告!这一章我们来实现用户系统,让只有注册登录的用户才能发文章。
本章结束后你会得到什么
一个完整的用户认证系统——注册、登录、记住登录状态,并且只有登录后才能发布文章!
9.1 为什么需要用户系统
9.1.1 当前系统的问题
目前我们的博客:
- ❌ 任何人都能发布文章
- ❌ 无法区分文章是谁写的
- ❌ 没有个人中心
- ❌ 无法管理自己的文章
9.1.2 认证 vs 授权
在实现用户系统之前,先理解两个概念:
| 概念 | 英文 | 问题 | 例子 |
|---|---|---|---|
| 认证 | Authentication | 你是谁? | 登录验证用户名密码 |
| 授权 | Authorization | 你能干什么? | 只有作者能编辑自己的文章 |
简单记忆
- 认证:验证身份(是不是这个人)
- 授权:检查权限(这个人能不能做这件事)
本章重点是认证,第 10 章会涉及授权。
9.2 用户数据库设计
9.2.1 用户表字段设计
一个用户需要哪些信息?
| 字段 | 类型 | 说明 |
|---|---|---|
id | INTEGER | 主键 |
username | STRING | 用户名(唯一) |
email | STRING | 邮箱(唯一) |
hashed_password | STRING | 加密后的密码 |
created_at | STRING | 注册时间 |
avatar | STRING | 头像 URL(可选) |
为什么是 hashed_password 而不是 password?
永远不要明文存储密码!
如果数据库泄露,明文密码会让所有用户的账号都处于危险中。我们需要把密码"加密"后再存储。
9.2.2 密码安全:为什么要哈希
历史趣事:密码泄露事件
2012 年,LinkedIn 被黑客攻击,650 万用户密码泄露。由于 LinkedIn 当时使用的是不安全的 SHA-1 哈希(没有加盐),黑客很快就破解了大量密码。
2016 年,这个数据又被扩大到 1.17 亿条。许多用户因为在多个网站使用相同密码,导致其他账号也被盗。
这就是为什么密码安全如此重要!
密码存储的演进:
1. 明文存储 ❌
password = "123456"
→ 数据库泄露 = 密码泄露
2. 简单哈希 ❌
password = MD5("123456") = "e10adc3949ba59abbe56e057f20f883e"
→ 彩虹表攻击可破解
3. 加盐哈希 ✅
password = bcrypt("123456" + 随机盐)
→ 每个密码都有独特的盐,无法用彩虹表9.2.3 bcrypt 简介
历史趣事:bcrypt 的设计哲学
bcrypt 由 Niels Provos 和 David Mazières 在 1999 年设计,基于 Blowfish 加密算法。
它有一个独特的设计:故意很慢。
为什么要慢?因为验证一次密码对用户来说 0.1 秒和 0.001 秒没区别,但对暴力破解的黑客来说,慢 100 倍意味着破解时间从 1 天变成 100 天!
而且 bcrypt 可以调整"慢的程度"(cost factor),随着计算机越来越快,可以增加 cost 来保持安全。
bcrypt 的特点:
- ✅ 自动生成随机盐
- ✅ 可调节的计算成本
- ✅ 业界标准,广泛使用
9.2.4 添加 User 模型
修改 backend/src/backend/models.py,添加 User 模型:
# backend/src/backend/models.py
from sqlalchemy import Column, Integer, String, Text, ForeignKey
from sqlalchemy.orm import relationship
from .database import Base
class User(Base):
"""用户模型"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(100), unique=True, nullable=False, index=True)
hashed_password = Column(String(200), nullable=False)
created_at = Column(String(20))
avatar = Column(String(500), default="")
# 关联:一个用户可以有多篇文章
articles = relationship("Article", back_populates="user")
def to_dict(self):
return {
"id": self.id,
"username": self.username,
"email": self.email,
"created_at": self.created_at,
"avatar": self.avatar
}
class Article(Base):
"""文章模型(更新版)"""
__tablename__ = "articles"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(200), nullable=False)
summary = Column(Text)
content = Column(Text)
author = Column(String(100), default="博主")
created_at = Column(String(20))
tags = Column(String(200))
# 新增:关联用户
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
user = relationship("User", back_populates="articles")
def to_dict(self):
return {
"id": self.id,
"title": self.title,
"summary": self.summary,
"content": self.content,
"author": self.user.username if self.user else self.author,
"created_at": self.created_at,
"tags": self.tags.split(",") if self.tags else []
}relationship 是什么?
relationship 定义了模型之间的关联关系。这里:
- 一个 User 可以有多个 Article(一对多)
back_populates让两边都能访问对方
这样我们可以通过 user.articles 获取用户的所有文章,也可以通过 article.user 获取文章的作者。
9.3 安装认证相关依赖
在 backend 目录下,我们需要安装两个包:
# 在 backend 目录下
pdm add python-jose[cryptography] # JWT 处理
pdm add passlib[bcrypt] # 密码哈希这两个包是干什么的?
- python-jose:处理 JWT(JSON Web Token),用于生成和验证登录凭证
- passlib:密码哈希库,支持 bcrypt 等多种算法
9.4 创建认证模块
9.4.1 创建 auth.py
在 backend/src/backend/ 目录下创建 auth.py:
# backend/src/backend/auth.py
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from .database import get_db
from .models import User
# 密码哈希配置
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# JWT 配置
SECRET_KEY = "your-secret-key-change-this-in-production" # 生产环境要换成随机字符串!
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 天
# Bearer Token 认证
security = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
"""对密码进行哈希"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""创建 JWT Token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> Optional[dict]:
"""解码 JWT Token"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> Optional[User]:
"""获取当前登录用户(可选)"""
if not credentials:
return None
token = credentials.credentials
payload = decode_token(token)
if not payload:
return None
user_id = payload.get("sub")
if not user_id:
return None
user = db.query(User).filter(User.id == int(user_id)).first()
return user
async def get_current_user_required(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""获取当前登录用户(必须登录)"""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未登录",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
payload = decode_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 无效或已过期",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 无效",
headers={"WWW-Authenticate": "Bearer"},
)
user = db.query(User).filter(User.id == int(user_id)).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在",
headers={"WWW-Authenticate": "Bearer"},
)
return userSECRET_KEY 很重要!
SECRET_KEY 是用来签名 JWT 的密钥。在生产环境中:
- 必须使用随机生成的长字符串
- 不要提交到代码仓库
- 可以用环境变量来配置
示例:openssl rand -hex 32 可以生成一个安全的随机密钥。
9.4.2 JWT 是什么?
历史趣事:Session 到 Token 的演变
早期的 Web 认证使用 Session:用户登录后,服务器保存一个 Session ID,浏览器用 Cookie 保存这个 ID。但这种方式有问题:
- 服务器要存储大量 Session 数据
- 多服务器部署时 Session 同步困难
- 移动端和 API 不方便使用 Cookie
2010 年代,JWT(JSON Web Token)逐渐流行。它的思路是:把用户信息直接编码在 Token 里,服务器只需要验证签名,不用存储任何东西。
2015 年,JWT 成为 RFC 7519 标准,如今已是 API 认证的主流方案。
JWT 的结构:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
│ │ │
Header Payload Signature
(算法信息) (用户数据) (签名验证)三部分用 . 分隔:
- Header:声明使用的算法
- Payload:存储用户 ID 等信息
- Signature:用密钥签名,防止篡改
9.5 添加用户相关的 Schema
修改 backend/src/backend/schemas.py,添加用户相关的模型:
# 在 schemas.py 中添加
class UserCreate(BaseModel):
"""用户注册模型"""
username: str = Field(..., min_length=3, max_length=50, description="用户名")
email: str = Field(..., description="邮箱")
password: str = Field(..., min_length=6, description="密码")
class UserLogin(BaseModel):
"""用户登录模型"""
username: str = Field(..., description="用户名或邮箱")
password: str = Field(..., description="密码")
class UserResponse(BaseModel):
"""用户信息响应"""
id: int
username: str
email: str
created_at: str
avatar: str
class TokenResponse(BaseModel):
"""登录成功响应"""
access_token: str
token_type: str = "bearer"
user: UserResponse9.6 实现注册和登录 API
修改 backend/src/backend/__init__.py,添加用户相关的 API:
# 在文件顶部添加导入
from .auth import (
hash_password,
verify_password,
create_access_token,
get_current_user,
get_current_user_required
)
from .schemas import UserCreate, UserLogin, TokenResponse
from .models import User
# 添加注册 API
@app.post("/api/auth/register")
def register(user_data: UserCreate, db: Session = Depends(get_db)):
"""用户注册"""
# 检查用户名是否已存在
if db.query(User).filter(User.username == user_data.username).first():
raise HTTPException(status_code=400, detail="用户名已被使用")
# 检查邮箱是否已存在
if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException(status_code=400, detail="邮箱已被注册")
# 创建用户
user = User(
username=user_data.username,
email=user_data.email,
hashed_password=hash_password(user_data.password),
created_at=datetime.now().strftime("%Y-%m-%d")
)
db.add(user)
db.commit()
db.refresh(user)
# 生成 Token
access_token = create_access_token(data={"sub": str(user.id)})
return {
"message": "注册成功",
"access_token": access_token,
"token_type": "bearer",
"user": user.to_dict()
}
@app.post("/api/auth/login")
def login(login_data: UserLogin, db: Session = Depends(get_db)):
"""用户登录"""
# 支持用户名或邮箱登录
user = db.query(User).filter(
(User.username == login_data.username) |
(User.email == login_data.username)
).first()
if not user:
raise HTTPException(status_code=401, detail="用户名或密码错误")
if not verify_password(login_data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="用户名或密码错误")
# 生成 Token
access_token = create_access_token(data={"sub": str(user.id)})
return {
"message": "登录成功",
"access_token": access_token,
"token_type": "bearer",
"user": user.to_dict()
}
@app.get("/api/auth/me")
def get_me(current_user: User = Depends(get_current_user_required)):
"""获取当前登录用户信息"""
return current_user.to_dict()为什么登录失败不区分"用户不存在"和"密码错误"?
这是安全最佳实践!
如果返回"用户不存在",攻击者就能确认某个用户名是否已注册。统一返回"用户名或密码错误"可以防止用户枚举攻击。
9.6.1 修改发布文章 API
现在修改发布文章的 API,关联当前登录用户:
@app.post("/api/articles")
def create_article(
article: ArticleCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user) # 可选登录
):
"""发布新文章"""
db_article = Article(
title=article.title,
summary=article.summary or article.content[:100] + "...",
content=article.content,
author=current_user.username if current_user else "匿名",
created_at=datetime.now().strftime("%Y-%m-%d"),
tags=article.tags,
user_id=current_user.id if current_user else None
)
db.add(db_article)
db.commit()
db.refresh(db_article)
return {
"message": "发布成功",
"article": db_article.to_dict()
}9.7 前端:登录注册页面
9.7.1 创建认证状态管理
在 frontend/app/composables/ 目录下创建 useAuth.ts:
// frontend/app/composables/useAuth.ts
interface User {
id: number
username: string
email: string
created_at: string
avatar: string
}
interface AuthState {
user: User | null
token: string | null
}
export const useAuth = () => {
const user = useState<User | null>('auth_user', () => null)
const token = useState<string | null>('auth_token', () => null)
// 初始化:从 localStorage 恢复登录状态
const initAuth = () => {
if (process.client) {
const savedToken = localStorage.getItem('token')
const savedUser = localStorage.getItem('user')
if (savedToken && savedUser) {
token.value = savedToken
user.value = JSON.parse(savedUser)
}
}
}
// 登录
const login = async (username: string, password: string) => {
const response = await $fetch<{
access_token: string
user: User
}>('http://localhost:8000/api/auth/login', {
method: 'POST',
body: { username, password }
})
token.value = response.access_token
user.value = response.user
// 保存到 localStorage
if (process.client) {
localStorage.setItem('token', response.access_token)
localStorage.setItem('user', JSON.stringify(response.user))
}
return response
}
// 注册
const register = async (username: string, email: string, password: string) => {
const response = await $fetch<{
access_token: string
user: User
}>('http://localhost:8000/api/auth/register', {
method: 'POST',
body: { username, email, password }
})
token.value = response.access_token
user.value = response.user
// 保存到 localStorage
if (process.client) {
localStorage.setItem('token', response.access_token)
localStorage.setItem('user', JSON.stringify(response.user))
}
return response
}
// 退出登录
const logout = () => {
token.value = null
user.value = null
if (process.client) {
localStorage.removeItem('token')
localStorage.removeItem('user')
}
}
// 是否已登录
const isLoggedIn = computed(() => !!token.value && !!user.value)
return {
user,
token,
isLoggedIn,
initAuth,
login,
register,
logout
}
}composables 是什么?
在 Nuxt/Vue 3 中,composables 是可复用的组合式函数。我们把认证逻辑封装在 useAuth 中,任何组件都可以通过 const { user, login } = useAuth() 来使用。
9.7.2 创建登录页面
在 frontend/app/pages/auth/ 目录下创建 login.vue:
<!-- frontend/app/pages/auth/login.vue -->
<template>
<div class="auth-page">
<div class="auth-card">
<h1 class="auth-title">🔐 登录</h1>
<p class="auth-subtitle">欢迎回来!</p>
<form @submit.prevent="handleLogin" class="auth-form">
<div class="form-group">
<label for="username">用户名 / 邮箱</label>
<input
id="username"
v-model="form.username"
type="text"
placeholder="请输入用户名或邮箱"
required
/>
</div>
<div class="form-group">
<label for="password">密码</label>
<input
id="password"
v-model="form.password"
type="password"
placeholder="请输入密码"
required
/>
</div>
<div v-if="errorMessage" class="error-message">
❌ {{ errorMessage }}
</div>
<button type="submit" class="btn-submit" :disabled="isLoading">
{{ isLoading ? '登录中...' : '登录' }}
</button>
</form>
<p class="auth-link">
还没有账号?
<NuxtLink to="/auth/register">立即注册</NuxtLink>
</p>
</div>
</div>
</template>
<script setup>
const { login } = useAuth()
const form = reactive({
username: '',
password: ''
})
const isLoading = ref(false)
const errorMessage = ref('')
async function handleLogin() {
errorMessage.value = ''
isLoading.value = true
try {
await login(form.username, form.password)
navigateTo('/blog')
} catch (error) {
errorMessage.value = error.data?.detail || '登录失败,请检查用户名和密码'
} finally {
isLoading.value = false
}
}
</script>
<style scoped>
.auth-page {
min-height: 80vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: white;
border-radius: 1rem;
padding: 2.5rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
}
.auth-title {
font-size: 1.75rem;
font-weight: 700;
color: #1a202c;
margin-bottom: 0.5rem;
text-align: center;
}
.auth-subtitle {
color: #6b7280;
text-align: center;
margin-bottom: 2rem;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 500;
color: #374151;
}
.form-group input {
padding: 0.75rem 1rem;
border: 2px solid #e5e7eb;
border-radius: 0.5rem;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.error-message {
padding: 0.75rem;
background: #fef2f2;
color: #dc2626;
border-radius: 0.5rem;
text-align: center;
font-size: 0.875rem;
}
.btn-submit {
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn-submit:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-link {
text-align: center;
margin-top: 1.5rem;
color: #6b7280;
}
.auth-link a {
color: #667eea;
text-decoration: none;
font-weight: 500;
}
.auth-link a:hover {
text-decoration: underline;
}
</style>9.7.3 创建注册页面
在 frontend/app/pages/auth/ 目录下创建 register.vue:
<!-- frontend/app/pages/auth/register.vue -->
<template>
<div class="auth-page">
<div class="auth-card">
<h1 class="auth-title">🎉 注册</h1>
<p class="auth-subtitle">加入我们,开始你的博客之旅</p>
<form @submit.prevent="handleRegister" class="auth-form">
<div class="form-group">
<label for="username">用户名</label>
<input
id="username"
v-model="form.username"
type="text"
placeholder="3-50 个字符"
minlength="3"
maxlength="50"
required
/>
</div>
<div class="form-group">
<label for="email">邮箱</label>
<input
id="email"
v-model="form.email"
type="email"
placeholder="your@email.com"
required
/>
</div>
<div class="form-group">
<label for="password">密码</label>
<input
id="password"
v-model="form.password"
type="password"
placeholder="至少 6 个字符"
minlength="6"
required
/>
</div>
<div class="form-group">
<label for="confirmPassword">确认密码</label>
<input
id="confirmPassword"
v-model="form.confirmPassword"
type="password"
placeholder="再次输入密码"
required
/>
</div>
<div v-if="errorMessage" class="error-message">
❌ {{ errorMessage }}
</div>
<button type="submit" class="btn-submit" :disabled="isLoading">
{{ isLoading ? '注册中...' : '注册' }}
</button>
</form>
<p class="auth-link">
已有账号?
<NuxtLink to="/auth/login">立即登录</NuxtLink>
</p>
</div>
</div>
</template>
<script setup>
const { register } = useAuth()
const form = reactive({
username: '',
email: '',
password: '',
confirmPassword: ''
})
const isLoading = ref(false)
const errorMessage = ref('')
async function handleRegister() {
errorMessage.value = ''
// 验证两次密码是否一致
if (form.password !== form.confirmPassword) {
errorMessage.value = '两次输入的密码不一致'
return
}
isLoading.value = true
try {
await register(form.username, form.email, form.password)
navigateTo('/blog')
} catch (error) {
errorMessage.value = error.data?.detail || '注册失败,请稍后重试'
} finally {
isLoading.value = false
}
}
</script>
<style scoped>
/* 样式与登录页面相同 */
.auth-page {
min-height: 80vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: white;
border-radius: 1rem;
padding: 2.5rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
}
.auth-title {
font-size: 1.75rem;
font-weight: 700;
color: #1a202c;
margin-bottom: 0.5rem;
text-align: center;
}
.auth-subtitle {
color: #6b7280;
text-align: center;
margin-bottom: 2rem;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 500;
color: #374151;
}
.form-group input {
padding: 0.75rem 1rem;
border: 2px solid #e5e7eb;
border-radius: 0.5rem;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.error-message {
padding: 0.75rem;
background: #fef2f2;
color: #dc2626;
border-radius: 0.5rem;
text-align: center;
font-size: 0.875rem;
}
.btn-submit {
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn-submit:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-link {
text-align: center;
margin-top: 1.5rem;
color: #6b7280;
}
.auth-link a {
color: #667eea;
text-decoration: none;
font-weight: 500;
}
.auth-link a:hover {
text-decoration: underline;
}
</style>9.7.4 更新导航栏
修改 frontend/app/layouts/default.vue,显示登录状态:
<!-- 在 Header 的 nav 部分添加 -->
<nav class="nav">
<NuxtLink to="/" class="nav-link">首页</NuxtLink>
<NuxtLink to="/blog" class="nav-link">博客</NuxtLink>
<!-- 根据登录状态显示不同内容 -->
<template v-if="isLoggedIn">
<span class="nav-user">👤 {{ user?.username }}</span>
<button @click="handleLogout" class="nav-link logout-btn">退出</button>
</template>
<template v-else>
<NuxtLink to="/auth/login" class="nav-link">登录</NuxtLink>
<NuxtLink to="/auth/register" class="nav-link nav-register">注册</NuxtLink>
</template>
</nav>在 <script setup> 中:
const { user, isLoggedIn, logout, initAuth } = useAuth()
// 组件挂载时初始化认证状态
onMounted(() => {
initAuth()
})
function handleLogout() {
logout()
navigateTo('/')
}添加样式:
.nav-user {
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
.logout-btn {
background: none;
border: none;
cursor: pointer;
font-size: inherit;
}
.nav-register {
background: rgba(255, 255, 255, 0.2);
padding: 0.5rem 1rem;
border-radius: 0.5rem;
}9.8 测试用户系统
9.8.1 测试流程
- 访问
http://localhost:3000/auth/register - 填写用户名、邮箱、密码,点击注册
- 注册成功后自动跳转到博客列表
- 导航栏显示用户名
- 点击"退出",恢复为"登录/注册"按钮
- 访问
http://localhost:3000/auth/login - 用刚才注册的账号登录
🎉 恭喜!用户系统上线!
现在你的博客:
- ✅ 支持用户注册
- ✅ 支持用户登录
- ✅ 记住登录状态
- ✅ 显示当前用户
- ✅ 文章关联作者
你的博客正在变成一个真正的社区!
9.9 小结
本章回顾
本章我们完成了:
- 理解了认证与授权的区别
- 学习了密码安全和 bcrypt 哈希
- 了解了 JWT 的原理和用法
- 实现了用户注册和登录 API
- 创建了前端登录注册页面
- 实现了登录状态管理
动手练习
试着扩展一下:
- 记住我功能:登录时添加"记住我"选项,延长 Token 有效期
- 修改密码:添加修改密码功能(需要验证旧密码)
- 头像上传:让用户可以上传自己的头像
- 邮箱验证:注册后发送验证邮件(进阶)
这些练习能帮你更深入理解用户系统!
安全提醒
本章的实现适合学习,但在生产环境中还需要:
- 🔐 使用 HTTPS 加密传输
- 🔑 把 SECRET_KEY 放到环境变量
- ⏰ 实现 Token 刷新机制
- 🚫 添加登录失败次数限制
- 📝 记录登录日志
下一章预告
用户系统有了,但还差点什么——登录后应该有自己的个人中心,可以管理自己发布的文章。下一章我们来实现:
- 👤 个人中心页面
- 📝 我的文章列表
- 🛡️ 路由守卫(未登录自动跳转)
- ✏️ 编辑和删除自己的文章
准备好打造个人空间了吗?