2.登录模块与前后端认证体系

2.登录模块与前后端认证体系

Lionlnuc
Lionlnuc Lv4

登录模块与前后端认证体系

一、目标

在项目基础结构上实现完整的用户认证流程,包括:

  • 用户注册;
  • 用户登录;
  • 用户退出;
  • Access Token 与 Refresh Token 双 Token 机制;
  • 刷新页面后的登录状态恢复;
  • Axios 自动携带 Token;
  • 401 自动刷新 Token;
  • Vue Router 路由守卫;
  • 导航栏根据登录状态动态显示。

登录模块是后续创建角色、编辑资料、聊天、语音等所有需要用户身份的功能基础。


二、认证模块总体架构

2.1 数据流概览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
用户输入账号密码
↓
Vue 登录/注册页面
↓
Axios 调用 Django API
↓
Django authenticate / create_user
↓
生成 Refresh Token 和 Access Token
↓
Access Token 返回给前端内存保存
Refresh Token 写入 HttpOnly Cookie
↓
前端 Pinia 更新用户状态
↓
导航栏、路由守卫、页面权限随状态变化

2.2 双 Token 设计

Token 保存位置 生命周期 作用
Access Token 前端内存 / Pinia 较短,例如 2 小时 每次 API 请求认证
Refresh Token HttpOnly Cookie 较长,例如 7 天 Access Token 过期后刷新

为什么不用一个 Token?

如果只用长期 Token,一旦泄露风险较大。双 Token 的好处是:

  • Access Token 短期有效,即使泄露影响有限;
  • Refresh Token 放在 HttpOnly Cookie,JavaScript 不能读取,降低 XSS 风险;
  • 页面刷新后 Pinia 状态丢失,但 Cookie 还在,可以自动恢复登录状态。

三、后端用户数据模型

3.1 Django 内置 User

Django 自带 User 模型,包含:

  • username;
  • password;
  • email;
  • is_staff;
  • is_superuser 等。

本项目使用内置 User 处理登录认证。

3.2 UserProfile 扩展模型

为了保存头像、简介等业务字段,需要创建 UserProfile。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import uuid

from django.contrib.auth.models import User
from django.db import models
from django.utils.timezone import now, localtime


def photo_upload_to(instance, filename):
ext = filename.split('.')[-1]
filename = f'{uuid.uuid4().hex[:10]}.{ext}'
return f'user/photos/{instance.user_id}/{filename}'


class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
photo = models.ImageField(default='user/photos/default.png', upload_to=photo_upload_to)
profile = models.TextField(default='谢谢你的关注', max_length=500)
create_time = models.DateTimeField(default=now)
update_time = models.DateTimeField(default=now)

def __str__(self):
return f"{self.user.username} - {localtime(self.create_time).strftime('%Y-%m-%d %H:%M:%S')}"

3.3 OneToOneField 的作用

UserProfile 与 User 是一对一关系:

1
User 1  ─────  1 UserProfile

这样既能复用 Django 自带认证系统,又能扩展项目业务字段。

3.4 注册到 Admin

1
2
3
4
5
6
7
from django.contrib import admin
from web.models.user import UserProfile


@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
raw_id_fields = ('user',)

raw_id_fields 的作用:

  • 外键或一对一字段显示为 ID 输入框;
  • 避免用户数量大时后台下拉框加载过慢;
  • 提升 Django Admin 管理性能。

四、数据库迁移

修改模型后执行:

1
2
3
cd backend
python manage.py makemigrations
python manage.py migrate

4.1 makemigrations

生成迁移文件,不直接修改数据库。

4.2 migrate

将迁移文件真正应用到数据库,创建或修改表结构。

4.3 标准流程

1
2
3
4
5
6
7
8
9
修改 models.py
↓
python manage.py makemigrations
↓
检查 migrations 文件
↓
python manage.py migrate
↓
数据库结构更新完成

五、后端账号 API

5.1 注册接口

功能:

  1. 获取用户名、密码;
  2. 校验非空;
  3. 检查用户名是否已存在;
  4. 创建 User;
  5. 创建 UserProfile;
  6. 生成 JWT;
  7. 返回用户信息。

关键逻辑:

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
37
38
39
from django.contrib.auth.models import User
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken

from web.models.user import UserProfile


class RegisterView(APIView):
def post(self, request):
username = request.data.get('username', '').strip()
password = request.data.get('password', '').strip()

if not username or not password:
return Response({'result': '用户名和密码不能为空'})

if User.objects.filter(username=username).exists():
return Response({'result': '用户名已存在'})

user = User.objects.create_user(username=username, password=password)
user_profile = UserProfile.objects.create(user=user)
refresh = RefreshToken.for_user(user)

response = Response({
'result': 'success',
'access': str(refresh.access_token),
'user_id': user.id,
'username': user.username,
'photo': user_profile.photo.url,
'profile': user_profile.profile,
})
response.set_cookie(
key='refresh_token',
value=str(refresh),
httponly=True,
samesite='Lax',
max_age=86400 * 7,
)
return response

5.2 用户名重复检查易错点

错误写法:

1
User.objects.get(username=username).exists()

原因:

  • get() 返回一个对象,或者抛异常;
  • 对象没有 .exists() 方法。

正确写法:

1
User.objects.filter(username=username).exists()

5.3 登录接口

功能:

  1. 获取用户名和密码;
  2. 使用 authenticate 校验;
  3. 查询 UserProfile;
  4. 生成 Token;
  5. 返回用户信息。
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
37
38
39
from django.contrib.auth import authenticate
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken

from web.models.user import UserProfile


class LoginView(APIView):
def post(self, request):
username = request.data.get('username', '').strip()
password = request.data.get('password', '').strip()

if not username or not password:
return Response({'result': '用户名和密码不能为空'})

user = authenticate(username=username, password=password)
if not user:
return Response({'result': '用户名或密码错误'})

user_profile = UserProfile.objects.get(user=user)
refresh = RefreshToken.for_user(user)

response = Response({
'result': 'success',
'access': str(refresh.access_token),
'user_id': user.id,
'username': user.username,
'photo': user_profile.photo.url,
'profile': user_profile.profile,
})
response.set_cookie(
key='refresh_token',
value=str(refresh),
httponly=True,
samesite='Lax',
max_age=86400 * 7,
)
return response

5.4 退出登录接口

1
2
3
4
5
6
7
8
9
10
11
12
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated


class LogoutView(APIView):
permission_classes = [IsAuthenticated]

def post(self, request):
response = Response({'result': 'success'})
response.delete_cookie('refresh_token')
return response

5.5 刷新 Token 接口

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
from django.conf import settings
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken


class RefreshTokenView(APIView):
def post(self, request):
try:
refresh_token = request.COOKIES.get('refresh_token')
if not refresh_token:
return Response({'result': 'refresh token 不存在'}, status=status.HTTP_401_UNAUTHORIZED)

refresh = RefreshToken(refresh_token)
response = Response({
'result': 'success',
'access': str(refresh.access_token),
})

if settings.SIMPLE_JWT.get('ROTATE_REFRESH_TOKENS'):
refresh.set_jti()
refresh.set_exp()
response.set_cookie(
key='refresh_token',
value=str(refresh),
httponly=True,
samesite='Lax',
max_age=86400 * 7,
)
return response
except Exception:
return Response({'result': 'refresh token 已过期'}, status=status.HTTP_401_UNAUTHORIZED)

5.6 获取用户信息接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated

from web.models.user import UserProfile


class GetUserInfoView(APIView):
permission_classes = [IsAuthenticated]

def get(self, request):
user = request.user
user_profile = UserProfile.objects.get(user=user)
return Response({
'result': 'success',
'user_id': user.id,
'username': user.username,
'photo': user_profile.photo.url,
'profile': user_profile.profile,
})

六、后端路由配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from django.urls import path, re_path

from web.views.index import index
from web.views.user.account.login import LoginView
from web.views.user.account.logout import LogoutView
from web.views.user.account.register import RegisterView
from web.views.user.account.refresh_token import RefreshTokenView
from web.views.user.account.get_user_info import GetUserInfoView

urlpatterns = [
path('api/user/account/login/', LoginView.as_view()),
path('api/user/account/logout/', LogoutView.as_view()),
path('api/user/account/register/', RegisterView.as_view()),
path('api/user/account/refresh_token/', RefreshTokenView.as_view()),
path('api/user/account/get_user_info/', GetUserInfoView.as_view()),
path('', index),
re_path(r'^(?!media/|static/|assets/).*$', index),
]

七、前端 Pinia 用户状态

frontend/src/stores/user.js:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useUserStore = defineStore('user', () => {
const id = ref(0)
const username = ref('')
const photo = ref('')
const profile = ref('')
const accessToken = ref('')
const hasPulledUserInfo = ref(false)

function isLogin() {
return !!accessToken.value
}

function setAccessToken(token) {
accessToken.value = token
}

function setUserInfo(data) {
id.value = data.user_id
username.value = data.username
photo.value = data.photo
profile.value = data.profile
}

function logout() {
id.value = 0
username.value = ''
photo.value = ''
profile.value = ''
accessToken.value = ''
hasPulledUserInfo.value = false
}

function setHasPulledUserInfo(value) {
hasPulledUserInfo.value = value
}

return {
id,
username,
photo,
profile,
accessToken,
hasPulledUserInfo,
isLogin,
setAccessToken,
setUserInfo,
logout,
setHasPulledUserInfo,
}
})

7.1 为什么需要 hasPulledUserInfo?

页面刷新后:

  • Pinia 中 accessToken 会变成空;
  • 但 Cookie 中的 refresh_token 仍在;
  • 前端还没请求完用户信息时,不能立刻判断用户未登录。

hasPulledUserInfo 表示:前端已经尝试过从后端恢复登录状态。


八、Axios 请求封装

frontend/src/js/http/api.js:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import axios from 'axios'
import { useUserStore } from '@/stores/user.js'

const BASE_URL = 'http://127.0.0.1:8000'

const api = axios.create({
baseURL: BASE_URL,
withCredentials: true,
})

api.interceptors.request.use(config => {
const user = useUserStore()
if (user.accessToken) {
config.headers.Authorization = `Bearer ${user.accessToken}`
}
return config
})

let isRefreshing = false
let refreshSubscribers = []

function subscribeTokenRefresh(callback) {
refreshSubscribers.push(callback)
}

function onRefreshed(token) {
refreshSubscribers.forEach(callback => callback(token))
refreshSubscribers = []
}

function onRefreshFailed(error) {
refreshSubscribers.forEach(callback => callback(null, error))
refreshSubscribers = []
}

api.interceptors.response.use(
response => response,
async error => {
const user = useUserStore()
const originalRequest = error?.config

if (!originalRequest) return Promise.reject(error)

if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true

return new Promise((resolve, reject) => {
subscribeTokenRefresh((token, err) => {
if (err) {
reject(err)
} else {
originalRequest.headers.Authorization = `Bearer ${token}`
resolve(api(originalRequest))
}
})

if (!isRefreshing) {
isRefreshing = true
axios.post(`${BASE_URL}/api/user/account/refresh_token/`, {}, { withCredentials: true })
.then(res => {
user.setAccessToken(res.data.access)
onRefreshed(res.data.access)
})
.catch(err => {
user.logout()
onRefreshFailed(err)
reject(err)
})
.finally(() => {
isRefreshing = false
})
}
})
}

return Promise.reject(error)
}
)

export default api

8.1 拦截器流程

1
2
3
4
5
6
7
8
9
10
11
12
发送请求
↓
请求拦截器附加 Authorization: Bearer accessToken
↓
后端校验 Token
↓
如果返回 401
↓
响应拦截器调用 refresh_token
↓
刷新成功:重发原请求
刷新失败:清空用户状态

8.2 多个请求同时 401 的处理

isRefreshing + refreshSubscribers 用于避免并发刷新多次 Token。

流程:

  1. 第一个 401 请求触发刷新;
  2. 其他 401 请求加入等待队列;
  3. 新 Token 返回后,统一重发队列中的请求。

九、登录页面

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user.js'
import api from '@/js/http/api.js'

const username = ref('')
const password = ref('')
const errorMessage = ref('')
const router = useRouter()
const user = useUserStore()

async function handleLogin() {
errorMessage.value = ''

if (!username.value.trim()) {
errorMessage.value = '用户名不能为空'
return
}
if (!password.value.trim()) {
errorMessage.value = '密码不能为空'
return
}

const res = await api.post('/api/user/account/login/', {
username: username.value,
password: password.value,
})

const data = res.data
if (data.result === 'success') {
user.setAccessToken(data.access)
user.setUserInfo(data)
await router.push({ name: 'homepage-index' })
} else {
errorMessage.value = data.result
}
}
</script>

<template>
<div class="flex justify-center mt-30">
<form @submit.prevent="handleLogin" class="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4">
<label class="label">用户名</label>
<input v-model="username" type="text" class="input" placeholder="用户名" />

<label class="label">密码</label>
<input v-model="password" type="password" class="input" placeholder="密码" />

<p v-if="errorMessage" class="font-bold text-red-500 text-base">{{ errorMessage }}</p>

<button class="btn btn-neutral mt-4">登录</button>
<div class="flex justify-end">
<RouterLink :to="{ name: 'register-index' }" class="btn btn-sm btn-ghost text-gray-500 mt-2">
注册
</RouterLink>
</div>
</form>
</div>
</template>

十、注册页面

注册页比登录页多一个确认密码字段。

校验:

  • 用户名不能为空;
  • 密码不能为空;
  • 两次密码一致;
  • 密码长度建议不少于 6 位。

成功后与登录相同:保存 Token 和用户信息,然后跳转首页。


十一、导航栏登录状态

导航栏根据用户状态动态渲染:

1
2
3
4
5
6
7
8
9
<RouterLink
v-if="user.hasPulledUserInfo && !user.isLogin()"
:to="{ name: 'login-index' }"
class="btn btn-ghost text-lg"
>
登录
</RouterLink>

<UserMenu v-else-if="user.isLogin()" />

11.1 避免刷新闪烁

如果不判断 hasPulledUserInfo,页面刷新瞬间可能先显示“登录”,随后又变成头像,视觉上会闪烁。


十二、用户菜单与退出登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup>
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user.js'
import api from '@/js/http/api.js'

const user = useUserStore()
const router = useRouter()

function closeMenu() {
const element = document.activeElement
if (element && element instanceof HTMLElement) element.blur()
}

async function handleLogout() {
const res = await api.post('/api/user/account/logout/')
if (res.data.result === 'success') {
user.logout()
await router.push({ name: 'homepage-index' })
}
}
</script>

重点:

  • 点击退出时调用后端删除 Cookie;
  • 前端调用 user.logout() 清空状态;
  • 下拉菜单点击后通过 blur() 自动关闭。

十三、路由守卫

13.1 路由 meta

1
2
3
4
5
6
{
path: '/friend',
component: FriendIndex,
name: 'friend-index',
meta: { needLogin: true },
}

13.2 beforeEach 守卫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
router.beforeEach((to, from, next) => {
const user = useUserStore()

if (!user.hasPulledUserInfo) {
return next()
}

if (to.meta.needLogin && !user.isLogin()) {
return next({ name: 'login-index', query: { redirect: to.fullPath } })
}

if (user.isLogin() && (to.name === 'login-index' || to.name === 'register-index')) {
return next({ name: 'homepage-index' })
}

return next()
})

13.3 为什么先判断 hasPulledUserInfo?

页面刚刷新时,用户信息还没恢复,如果马上根据 isLogin() 判断,会把本来已登录的用户错误踢到登录页。


十四、App.vue 中恢复登录状态

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
<script setup>
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user.js'
import api from '@/js/http/api.js'
import NavBar from '@/components/navbar/NavBar.vue'

const user = useUserStore()
const route = useRoute()
const router = useRouter()

onMounted(async () => {
try {
const res = await api.get('/api/user/account/get_user_info/')
const data = res.data
if (data.result === 'success') {
user.setUserInfo(data)
}
} catch (error) {
console.error(error)
} finally {
user.setHasPulledUserInfo(true)

if (route.meta.needLogin && !user.isLogin()) {
await router.replace({ name: 'login-index' })
}
}
})
</script>

<template>
<NavBar>
<RouterView />
</NavBar>
</template>

14.1 页面刷新恢复流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
刷新页面
↓
Pinia accessToken 丢失
↓
App.vue 请求 get_user_info
↓
请求无 accessToken,后端返回 401
↓
Axios 响应拦截器调用 refresh_token
↓
Cookie 中 refresh_token 有效
↓
获得新 accessToken
↓
重发 get_user_info
↓
恢复用户信息

十五、前端路由命名建议

建议统一命名规则,避免混乱:

1
2
3
4
5
6
7
8
homepage-index
friend-index
create-index
login-index
register-index
user-space-index
user-profile-index
not-found

如果项目中历史代码出现 home-index、user-account-login-index 等不同命名,复习时要注意以实际路由表为准。


十六、常见问题与易错点

开发环境一般不是 HTTPS:

1
secure=False

生产环境必须使用 HTTPS 时:

1
secure=True

16.2 withCredentials 必须开启

如果前端要携带 Cookie:

1
withCredentials: true

后端也要:

1
CORS_ALLOW_CREDENTIALS = True

16.3 Authorization 格式

必须是:

1
Authorization: Bearer <access_token>

16.4 refresh_token 拼写

接口路径要统一,例如:

1
/api/user/account/refresh_token/

不要写成 refersh_token。

16.5 图片字段返回 URL

后端返回图片时要使用:

1
user_profile.photo.url

不要直接返回 user_profile.photo。

16.6 捕获异常不要吞掉问题

开发阶段不建议写裸 except: 后完全不打印错误,否则难以调试。更好的方式是记录日志。


十七、小结

本节建立了完整认证闭环:

  1. 后端使用 Django User + UserProfile 管理账号和资料;
  2. 登录 / 注册后生成 JWT;
  3. Access Token 放前端内存,Refresh Token 放 HttpOnly Cookie;
  4. Axios 自动携带 Token,并在 401 时自动刷新;
  5. Pinia 保存用户信息;
  6. App.vue 刷新后自动恢复用户状态;
  7. Router 守卫保护需要登录的页面;
  8. 导航栏根据登录状态显示登录按钮或用户菜单。

十八、新手版:登录到底发生了什么

18.1 用生活例子理解 Token

可以把网站想象成一栋办公楼:

  • 用户名和密码:第一次进楼时出示身份证;
  • Access Token:临时门禁卡,有效期短;
  • Refresh Token:续卡凭证,放在更安全的地方;
  • 后端 API:不同办公室;
  • IsAuthenticated:门口保安,检查你有没有门禁卡。

登录成功后,前端每次访问需要权限的接口,都要带上门禁卡:

1
Authorization: Bearer <access_token>

后端验证通过后,request.user 就会变成当前登录用户。

18.2 为什么刷新页面后还能保持登录

刷新页面会导致 JavaScript 内存中的 Pinia 状态丢失。

但是 Refresh Token 保存在 HttpOnly Cookie 中,浏览器会自动带给后端。

所以恢复登录的过程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
刷新页面
↓
前端没有 access token
↓
请求用户信息失败,返回 401
↓
Axios 自动请求 refresh_token
↓
后端从 Cookie 里拿到 refresh token
↓
生成新的 access token
↓
前端重新请求用户信息
↓
登录状态恢复

十九、跟做步骤:如何验证登录模块

19.1 验证注册

  1. 启动后端;
  2. 启动前端;
  3. 打开注册页面;
  4. 输入用户名和密码;
  5. 点击注册;
  6. 注册成功后进入首页或登录状态页面。

验证数据库中是否创建用户:

1
2
cd backend
python manage.py shell
1
2
from django.contrib.auth.models import User
User.objects.all()

19.2 验证登录

打开浏览器开发者工具 Network,点击登录后观察:

1
POST /api/user/account/login/

如果成功,响应中应包含:

1
2
3
4
5
{
"result": "success",
"access": "...",
"username": "..."
}

同时在 Application -> Cookies 中可以看到 refresh_token。

19.3 验证自动刷新 Token

可以把 Access Token 有效期调短,例如 1 分钟,然后:

  1. 登录;
  2. 等 Access Token 过期;
  3. 再请求需要登录的页面;
  4. 观察 Network 中是否先出现 401,再出现 refresh_token 请求,再重发原请求。

二十、登录模块常见报错排查

20.1 登录后仍然显示未登录

检查:

  1. 后端是否返回了 access;
  2. 前端是否调用 user.setAccessToken(data.access);
  3. 前端是否调用 user.setUserInfo(data);
  4. 导航栏是否使用了正确的 store;
  5. hasPulledUserInfo 是否被正确设置。

检查:

  1. 后端 set_cookie 是否执行;
  2. 前端 Axios 是否设置 withCredentials: true;
  3. 后端是否设置 CORS_ALLOW_CREDENTIALS = True;
  4. 浏览器是否拦截第三方 Cookie;
  5. 开发环境下 secure=True 会导致 HTTP 无法写入 Cookie。

20.3 一直 401

检查:

  1. 请求头是否有 Authorization;
  2. 格式是否是 Bearer token;
  3. Token 是否过期;
  4. 后端 JWT 配置是否正确;
  5. 访问的接口是否加了 IsAuthenticated。

20.4 注册时报用户名已存在

检查数据库:

1
2
from django.contrib.auth.models import User
User.objects.filter(username='你的用户名').exists()

如果返回 True,说明这个用户名已经被注册。


二十一、本节复习问题

  1. 为什么项目要有 UserProfile,而不是只用 Django 自带 User?
  2. Access Token 和 Refresh Token 分别保存在什么地方?
  3. 为什么 Refresh Token 要放在 HttpOnly Cookie 中?
  4. Axios 请求拦截器做了什么?
  5. Axios 响应拦截器如何处理 401?
  6. IsAuthenticated 的作用是什么?
  7. 页面刷新后,前端如何恢复用户状态?
  8. 为什么需要 hasPulledUserInfo?

二十二、核心接口完整请求 / 响应示例

22.1 注册接口

1
2
POST /api/user/account/register/
Content-Type: application/json

请求体:

1
2
3
4
5
{
"username": "alice",
"password": "123456",
"password_confirm": "123456"
}

成功响应:

1
2
3
{
"result": "success"
}

失败响应示例:

1
2
3
{
"result": "用户名已存在"
}

22.2 登录接口

1
2
POST /api/user/account/login/
Content-Type: application/json

请求体:

1
2
3
4
{
"username": "alice",
"password": "123456"
}

成功响应:

1
2
3
4
5
6
7
{
"result": "success",
"access": "eyJhbGciOi...",
"id": 1,
"username": "alice",
"photo": "/media/user/photo/default.png"
}

同时后端会通过响应头设置 Cookie:

1
Set-Cookie: refresh_token=eyJhbGciOi...; HttpOnly; Path=/

22.3 获取当前用户信息接口

1
2
GET /api/user/account/info/
Authorization: Bearer <access_token>

成功响应:

1
2
3
4
5
6
{
"result": "success",
"id": 1,
"username": "alice",
"photo": "/media/user/photo/alice.png"
}

未登录响应:

1
2
3
{
"detail": "Authentication credentials were not provided."
}

22.4 刷新 Token 接口

1
2
POST /api/user/account/token/refresh/
Cookie: refresh_token=<refresh_token>

成功响应:

1
2
3
4
{
"result": "success",
"access": "新的access_token"
}

失败响应:

1
2
3
{
"result": "refresh token无效或已过期"
}

二十三、关键代码逐行解释示例

以后看到类似代码时,可以这样拆开理解:

1
2
3
4
5
6
7
8
9
10
11
12
class InfoView(APIView):
permission_classes = [IsAuthenticated]

def get(self, request):
user = request.user
profile = user.userprofile
return Response({
"result": "success",
"id": user.id,
"username": user.username,
"photo": profile.photo.url,
})

逐行解释:

代码 新手解释
class InfoView(APIView): 定义一个 DRF 接口视图类
permission_classes = [IsAuthenticated] 这个接口必须登录后才能访问
def get(self, request): 当前端发送 GET 请求时执行这个函数
user = request.user DRF 根据 Token 找到当前用户
profile = user.userprofile 通过一对一关系找到用户扩展资料
return Response(...) 把用户信息以 JSON 格式返回给前端

二十四、Vue 登录状态数据流图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
LoginView.vue
│ 用户输入账号密码
↓
loginApi.js
│ POST /login/
↓
后端 LoginView
│ 校验成功,返回 access,写入 refresh cookie
↓
Pinia user store
│ 保存 access、id、username、photo
↓
NavBar.vue
│ 根据 store 显示头像或登录按钮
↓
Axios 请求拦截器
│ 后续请求自动带 Authorization

二十五、后端登录视图处理流程

1
2
3
4
5
6
7
8
9
10
11
12
13
收到 POST /login/
↓
读取 username 和 password
↓
调用 authenticate 校验账号密码
↓
如果失败,返回用户名或密码错误
↓
如果成功,生成 refresh 和 access
↓
把 refresh 写入 HttpOnly Cookie
↓
把 access 和用户信息放入 JSON 返回

二十六、小练习与自测答案

练习 1

问题:如果前端请求用户信息接口时没有携带 Authorization 请求头,会发生什么?

答案:后端的 IsAuthenticated 会拦截请求,返回 401,前端应尝试使用 refresh token 刷新 access token。

练习 2

问题:为什么 Refresh Token 不建议直接放在 LocalStorage?

答案:LocalStorage 可以被 JavaScript 读取,如果页面出现 XSS 漏洞,攻击者可能偷走 Refresh Token。HttpOnly Cookie 不能被 JS 直接读取,相对更安全。

练习 3

问题:为什么需要 Axios 响应拦截器?

答案:因为 access token 会过期。响应拦截器可以统一处理 401,自动刷新 token 并重发原请求,避免每个接口都写重复逻辑。

  • 标题: 2.登录模块与前后端认证体系
  • 作者: Lionlnuc
  • 创建于 : 2026-07-12 00:00:00
  • 更新于 : 2026-07-12 00:00:00
  • 链接: https://lionlnuc.github.io/2026/07/12/AgentTalk/2.登录模块/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论
目录
2.登录模块与前后端认证体系