JDWA 技术文档
首页
  • 数据库
  • 前端开发
  • 后端开发
  • 开发工具
  • 虚拟化技术
  • KVM显卡直通
  • FPGA仿真固件
  • 项目实战
  • 踩坑记录
  • 开发心得
  • 软件工具
  • 学习资料
  • 开发环境
更新日志
关于我
Gitee
GitHub
首页
  • 数据库
  • 前端开发
  • 后端开发
  • 开发工具
  • 虚拟化技术
  • KVM显卡直通
  • FPGA仿真固件
  • 项目实战
  • 踩坑记录
  • 开发心得
  • 软件工具
  • 学习资料
  • 开发环境
更新日志
关于我
Gitee
GitHub
  • 项目实战

    • 项目实战经验
    • JDWA Green-U 后端技术文档索引
    • Vue3+Spring Boot登录系统开发指南
    • 小程序开发实践
    • Web应用开发指南
  • 踩坑记录

    • NPM依赖管理问题
    • 浏览器兼容性问题
  • 开发心得

    • 代码审查方法
    • 团队协作最佳实践

Vue3+Spring Boot登录系统开发指南

Tips

本文适用于Web开发初学者,详细介绍基于Vue3和Spring Boot构建一个简单登录系统的完整过程。

1. 项目概述

源代码资源

本项目完整源代码可通过以下渠道获取:

  • GitHub:JDWA-vue-spring-boot-login
  • Gitee:vue-spring-boot-login
  • 蓝奏云:vue-spring-boot-login.zip(密码:jdwa)

1.1 功能描述

本项目是一个前后端分离的登录系统,主要功能包括:

  • 用户登录认证
  • JWT令牌生成与验证
  • 登录状态保持
  • 路由访问控制

1.2 技术栈

前端技术栈:

  • Vue 3.3.4 (组合式API)
  • Vite 4.3.8 (构建工具)
  • Vue Router 4.2.1 (路由)
  • Pinia 2.1.3 (状态管理)
  • Element Plus 2.3.5 (UI组件库)
  • Axios (HTTP客户端)

后端技术栈:

  • Spring Boot 2.7.11
  • Spring Security (安全框架)
  • MyBatis (持久层框架)
  • JWT (JSON Web Token)
  • MySQL (数据库)

1.3 项目架构

项目架构图

项目采用典型的前后端分离架构:

  • 前端负责界面渲染和用户交互
  • 后端负责业务逻辑处理和数据持久化
  • 通过RESTful API进行通信
  • 使用JWT进行身份认证

2. 环境准备

2.1 开发环境要求

  • Node.js (v14+)
  • JDK 8+ (本项目使用JDK 8)
  • Maven 3.6+
  • MySQL 5.7+
  • IDE推荐:
    • 前端:VSCode/WebStorm
    • 后端:IntelliJ IDEA/Eclipse

2.2 项目结构

vue3+spring-boot/
├── frontend/                  # 前端项目
│   ├── public/                # 静态资源
│   │   ├── assets/           # 资源文件
│   │   ├── src/                   # 源代码
│   │   │   ├── components/       # 组件
│   │   │   ├── router/           # 路由
│   │   │   ├── store/            # 状态管理
│   │   │   ├── utils/            # 工具类
│   │   │   ├── views/            # 视图
│   │   │   ├── App.vue           # 根组件
│   │   │   └── main.js           # 入口文件
│   │   ├── index.html            # HTML入口
│   │   ├── package.json          # 依赖管理
│   │   └── vite.config.js        # Vite配置
│   ├── backend/                   # 后端项目
│   │   ├── src/
│   │   │   ├── main/
│   │   │   │   ├── java/com/jdwa/login/
│   │   │   │   │   ├── controller/      # 控制器
│   │   │   │   │   ├── dto/             # 数据传输对象
│   │   │   │   │   ├── mapper/          # MyBatis映射接口
│   │   │   │   │   ├── model/           # 实体模型
│   │   │   │   │   ├── security/        # 安全相关
│   │   │   │   │   ├── service/         # 服务层
│   │   │   │   │   └── JDWALoginApplication.java  # 启动类
│   │   │   │   └── resources/
│   │   │   │       ├── mapper/          # MyBatis XML映射文件
│   │   │   │       ├── application.properties  # 配置文件
│   │   │   │       └── db/              # 数据库脚本
│   │   │   └── pom.xml                # Maven依赖
│   └── README.md                  # 项目说明

3. 前端开发

3.1 创建前端项目

前端项目使用Vite创建Vue3应用,步骤如下:

# 创建项目目录
mkdir -p frontend

# 创建package.json
cd frontend

package.json:

{
  "name": "jdwa-vue3-login-frontend",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "serve": "vite preview"
  },
  "dependencies": {
    "axios": "^1.4.0",
    "element-plus": "^2.3.5",
    "vue": "^3.3.4",
    "vue-router": "^4.2.1",
    "pinia": "^2.1.3"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.2.3",
    "vite": "^4.3.8"
  }
}

vite.config.js:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  },
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path
      }
    }
  }
})

3.2 配置前端路由

src/router/index.js:

import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    redirect: '/login'
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('../views/JDWALogin.vue')
  },
  {
    path: '/home',
    name: 'Home',
    component: () => import('../views/JDWAHome.vue'),
    meta: { requiresAuth: true }
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

// 路由守卫,检查是否已登录
router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token')
  
  if (to.matched.some(record => record.meta.requiresAuth)) {
    if (!token) {
      next({ name: 'Login' })
    } else {
      next()
    }
  } else {
    next()
  }
})

export default router

3.3 状态管理配置

使用Pinia管理用户状态和登录逻辑:

src/store/user.js:

import { defineStore } from 'pinia'
import axios from 'axios'

export const useUserStore = defineStore('user', {
  state: () => ({
    token: localStorage.getItem('token') || '',
    user: JSON.parse(localStorage.getItem('user') || '{}')
  }),
  
  getters: {
    isLoggedIn: (state) => !!state.token,
    username: (state) => state.user.username || ''
  },
  
  actions: {
    async login(username, password) {
      try {
        const response = await axios.post('/api/login', { username, password })
        const { token, user } = response.data
        
        this.token = token
        this.user = user
        
        localStorage.setItem('token', token)
        localStorage.setItem('user', JSON.stringify(user))
        
        return { success: true }
      } catch (error) {
        console.error('登录失败:', error)
        return { 
          success: false, 
          message: error.response?.data?.message || '登录失败,请检查用户名和密码' 
        }
      }
    },
    
    logout() {
      this.token = ''
      this.user = {}
      localStorage.removeItem('token')
      localStorage.removeItem('user')
    }
  }
})

3.4 HTTP请求配置

src/utils/request.js:

import axios from 'axios'
import { ElMessage } from 'element-plus'

// 创建axios实例
const service = axios.create({
  baseURL: '/',  // 不再添加/api前缀,因为后端已经配置了context-path
  timeout: 10000
})

// 请求拦截器
service.interceptors.request.use(
  config => {
    const token = localStorage.getItem('token')
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`
    }
    return config
  },
  error => {
    console.error('请求错误:', error)
    return Promise.reject(error)
  }
)

// 响应拦截器
service.interceptors.response.use(
  response => {
    return response.data
  },
  error => {
    if (error.response) {
      const { status, data } = error.response
      
      // 处理不同的错误状态码
      switch (status) {
        case 401:
          ElMessage.error('未授权,请重新登录')
          localStorage.removeItem('token')
          localStorage.removeItem('user')
          break
        case 403:
          ElMessage.error('没有权限访问该资源')
          break
        case 500:
          ElMessage.error('服务器错误,请稍后再试')
          break
        default:
          ElMessage.error(data.message || '请求失败')
      }
    } else {
      ElMessage.error('网络错误,请检查您的网络连接')
    }
    
    return Promise.reject(error)
  }
)

export default service

3.5 登录页面实现

src/views/JDWALogin.vue:

<template>
  <div class="login-container">
    <div class="login-box">
      <div class="login-title">
        <h2>JDWA登录系统</h2>
      </div>
      <el-form :model="loginForm" :rules="loginRules" ref="loginFormRef" class="login-form">
        <el-form-item prop="username">
          <el-input 
            v-model="loginForm.username" 
            placeholder="用户名" 
            prefix-icon="el-icon-user"
            @keyup.enter="handleLogin">
          </el-input>
        </el-form-item>
        
        <el-form-item prop="password">
          <el-input 
            v-model="loginForm.password" 
            type="password" 
            placeholder="密码" 
            prefix-icon="el-icon-lock"
            @keyup.enter="handleLogin">
          </el-input>
        </el-form-item>
        
        <el-form-item>
          <el-button 
            type="primary" 
            :loading="loading" 
            class="login-button" 
            @click="handleLogin">
            登录
          </el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '../store/user'

const router = useRouter()
const userStore = useUserStore()
const loginFormRef = ref(null)
const loading = ref(false)

const loginForm = reactive({
  username: '',
  password: ''
})

const loginRules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 3, max: 20, message: '用户名长度必须在3到20个字符之间', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, max: 20, message: '密码长度必须在6到20个字符之间', trigger: 'blur' }
  ]
}

const handleLogin = async () => {
  if (!loginFormRef.value) return
  
  try {
    await loginFormRef.value.validate()
    
    loading.value = true
    const { success, message } = await userStore.login(loginForm.username, loginForm.password)
    
    if (success) {
      ElMessage.success('登录成功')
      router.push('/home')
    } else {
      ElMessage.error(message)
    }
  } catch (error) {
    console.error('表单验证失败', error)
  } finally {
    loading.value = false
  }
}
</script>

<style scoped>
.login-container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  background-color: #f0f2f5;
}

.login-box {
  width: 400px;
  padding: 30px;
  background: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}

.login-title {
  text-align: center;
  margin-bottom: 30px;
}

.login-title h2 {
  font-weight: 600;
  font-size: 24px;
  color: #409EFF;
}

.login-form {
  margin-top: 20px;
}

.login-button {
  width: 100%;
}
</style>

3.6 主页实现

src/views/JDWAHome.vue:

<template>
  <div class="home-container">
    <el-container>
      <el-header>
        <div class="header-content">
          <div class="logo">JDWA系统</div>
          <div class="user-info">
            <span>欢迎, {{ username }}</span>
            <el-button type="text" @click="handleLogout">退出登录</el-button>
          </div>
        </div>
      </el-header>
      
      <el-main>
        <el-card class="welcome-card">
          <h2>登录成功!</h2>
          <p>这是一个基于Vue 3和Spring Boot的简单登录系统示例。</p>
          <p>您已成功登录系统。</p>
        </el-card>
      </el-main>
    </el-container>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { useUserStore } from '../store/user'

const router = useRouter()
const userStore = useUserStore()

const username = computed(() => userStore.username)

const handleLogout = () => {
  ElMessageBox.confirm('确定要退出登录吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    userStore.logout()
    router.push('/login')
  }).catch(() => {})
}
</script>

<style scoped>
.home-container {
  height: 100vh;
}

.el-header {
  background-color: #409EFF;
  color: white;
  line-height: 60px;
  padding: 0 20px;
}

.header-content {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.logo {
  font-size: 18px;
  font-weight: bold;
}

.user-info {
  display: flex;
  align-items: center;
}

.user-info span {
  margin-right: 10px;
}

.welcome-card {
  max-width: 600px;
  margin: 20px auto;
  text-align: center;
}

.welcome-card h2 {
  color: #409EFF;
  margin-bottom: 20px;
}
</style>

## 4. 后端开发

### 4.1 创建Spring Boot项目

首先,我们使用Spring Initializr来创建一个基础的Spring Boot项目。

**创建步骤**:
1. 访问 https://start.spring.io/ 
2. 配置项目参数:
   - 项目类型:Maven
   - 语言:Java
   - Spring Boot版本:2.7.11
   - 项目元数据:
     - Group: com.jdwa
     - Artifact: jdwa-login
     - Name: jdwa-login
     - Description: Spring Boot登录系统后端
     - Package Name: com.jdwa.login
     - Packaging: Jar
     - Java版本:8
   - 依赖选择:
     - Spring Web
     - Spring Security
     - MyBatis Framework
     - MySQL Driver
     - Lombok

3. 点击"Generate"下载项目压缩包
4. 解压并导入IDE

### 4.2 配置数据库连接

**src/main/resources/application.properties**:
```properties
# 应用配置
spring.application.name=jdwa-login
server.port=8080

# 数据库连接配置
spring.datasource.url=jdbc:mysql://localhost:3306/jdwa_login?useSSL=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# MyBatis配置
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.jdwa.login.model
mybatis.configuration.map-underscore-to-camel-case=true

# 日志配置
logging.level.com.jdwa.login=debug

# JWT配置
jdwa.jwt.secret=jdwaSecretKey1234567890abcdefghijklmnopqrstuvwxyz
jdwa.jwt.expiration=86400000

4.3 数据库设计

创建数据库和用户表:

src/main/resources/db/schema.sql:

-- 创建数据库
CREATE DATABASE IF NOT EXISTS jdwa_login DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

USE jdwa_login;

-- 创建用户表
CREATE TABLE IF NOT EXISTS jdwa_user (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  username VARCHAR(50) NOT NULL UNIQUE,
  password VARCHAR(100) NOT NULL,
  email VARCHAR(100),
  phone VARCHAR(20),
  role VARCHAR(50) NOT NULL DEFAULT 'USER',
  status TINYINT NOT NULL DEFAULT 1,
  create_time DATETIME NOT NULL,
  update_time DATETIME NOT NULL,
  last_login_time DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 插入测试用户数据,密码为:123456(BCrypt加密后的值)
INSERT INTO jdwa_user (username, password, email, role, status, create_time, update_time)
VALUES 
('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', 'admin@example.com', 'ADMIN', 1, NOW(), NOW()),
('user', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', 'user@example.com', 'USER', 1, NOW(), NOW());

4.4 实体类设计

src/main/java/com/jdwa/login/model/JDWAUser.java:

package com.jdwa.login.model;

import lombok.Data;
import java.time.LocalDateTime;

@Data
public class JDWAUser {
    private Long id;
    private String username;
    private String password;
    private String email;
    private String phone;
    private String role;
    private Integer status;
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
    private LocalDateTime lastLoginTime;
}

4.5 DTO设计

src/main/java/com/jdwa/login/dto/JDWALoginRequest.java:

package com.jdwa.login.dto;

import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;

@Data
public class JDWALoginRequest {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 20, message = "用户名长度必须在3到20个字符之间")
    private String username;
    
    @NotBlank(message = "密码不能为空")
    @Size(min = 6, max = 20, message = "密码长度必须在6到20个字符之间")
    private String password;
}

src/main/java/com/jdwa/login/dto/JDWALoginResponse.java:

package com.jdwa.login.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class JDWALoginResponse {
    private String token;
    private JDWAUserDTO user;
}

src/main/java/com/jdwa/login/dto/JDWAUserDTO.java:

package com.jdwa.login.dto;

import lombok.Data;

@Data
public class JDWAUserDTO {
    private Long id;
    private String username;
    private String email;
    private String role;
}

src/main/java/com/jdwa/login/dto/JDWAResponse.java:

package com.jdwa.login.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class JDWAResponse<T> {
    private boolean success;
    private String message;
    private T data;
    
    public static <T> JDWAResponse<T> success(T data) {
        return new JDWAResponse<>(true, "操作成功", data);
    }
    
    public static <T> JDWAResponse<T> success(String message, T data) {
        return new JDWAResponse<>(true, message, data);
    }
    
    public static <T> JDWAResponse<T> fail(String message) {
        return new JDWAResponse<>(false, message, null);
    }
}

4.6 Mapper接口实现

src/main/java/com/jdwa/login/mapper/JDWAUserMapper.java:

package com.jdwa.login.mapper;

import com.jdwa.login.model.JDWAUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface JDWAUserMapper {
    /**
     * 根据用户名查询用户
     * @param username 用户名
     * @return 用户对象
     */
    JDWAUser findByUsername(@Param("username") String username);
    
    /**
     * 更新最后登录时间
     * @param userId 用户ID
     * @return 影响行数
     */
    int updateLastLoginTime(@Param("userId") Long userId);
}

src/main/resources/mapper/JDWAUserMapper.xml:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jdwa.login.mapper.JDWAUserMapper">
    
    <resultMap id="userMap" type="com.jdwa.login.model.JDWAUser">
        <id property="id" column="id"/>
        <result property="username" column="username"/>
        <result property="password" column="password"/>
        <result property="email" column="email"/>
        <result property="phone" column="phone"/>
        <result property="role" column="role"/>
        <result property="status" column="status"/>
        <result property="createTime" column="create_time"/>
        <result property="updateTime" column="update_time"/>
        <result property="lastLoginTime" column="last_login_time"/>
    </resultMap>
    
    <select id="findByUsername" resultMap="userMap">
        SELECT * FROM jdwa_user WHERE username = #{username}
    </select>
    
    <update id="updateLastLoginTime">
        UPDATE jdwa_user SET last_login_time = NOW() WHERE id = #{userId}
    </update>
    
</mapper>

4.7 JWT工具类

src/main/java/com/jdwa/login/security/JDWAJwtTokenUtil.java:

package com.jdwa.login.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Component
public class JDWAJwtTokenUtil {

    @Value("${jdwa.jwt.secret}")
    private String secret;

    @Value("${jdwa.jwt.expiration}")
    private Long expiration;

    /**
     * 从令牌中获取用户名
     */
    public String getUsernameFromToken(String token) {
        return getClaimFromToken(token, Claims::getSubject);
    }

    /**
     * 从令牌中获取过期时间
     */
    public Date getExpirationDateFromToken(String token) {
        return getClaimFromToken(token, Claims::getExpiration);
    }

    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    }

    /**
     * 从令牌中获取所有声明
     */
    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * 检查令牌是否过期
     */
    private Boolean isTokenExpired(String token) {
        final Date expiration = getExpirationDateFromToken(token);
        return expiration.before(new Date());
    }

    /**
     * 为用户生成令牌
     */
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        return doGenerateToken(claims, userDetails.getUsername());
    }

    /**
     * 生成令牌的具体实现
     */
    private String doGenerateToken(Map<String, Object> claims, String subject) {
        final Date createdDate = new Date();
        final Date expirationDate = new Date(createdDate.getTime() + expiration);

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(createdDate)
                .setExpiration(expirationDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    /**
     * 验证令牌
     */
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = getUsernameFromToken(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }
}

4.8 Spring Security配置

src/main/java/com/jdwa/login/security/JDWAUserDetailsService.java:

package com.jdwa.login.security;

import com.jdwa.login.mapper.JDWAUserMapper;
import com.jdwa.login.model.JDWAUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.Collections;

@Service
public class JDWAUserDetailsService implements UserDetailsService {

    @Autowired
    private JDWAUserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        JDWAUser user = userMapper.findByUsername(username);
        
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在: " + username);
        }
        
        // 如果用户被禁用,抛出异常
        if (user.getStatus() != 1) {
            throw new UsernameNotFoundException("用户已被禁用: " + username);
        }
        
        // 授予用户角色权限
        SimpleGrantedAuthority authority = new SimpleGrantedAuthority("ROLE_" + user.getRole());
        
        return new User(
                user.getUsername(),
                user.getPassword(),
                Collections.singletonList(authority)
        );
    }
}

src/main/java/com/jdwa/login/security/JDWAAuthenticationFilter.java:

package com.jdwa.login.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class JDWAAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JDWAUserDetailsService userDetailsService;

    @Autowired
    private JDWAJwtTokenUtil jwtTokenUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        final String authHeader = request.getHeader("Authorization");
        
        String username = null;
        String jwtToken = null;

        // 如果请求头包含JWT Token
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            jwtToken = authHeader.substring(7);
            try {
                username = jwtTokenUtil.getUsernameFromToken(jwtToken);
            } catch (Exception e) {
                logger.error("JWT Token解析失败: " + e.getMessage());
            }
        }

        // 一旦我们获取到token,就验证它
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

            // 如果token有效,那么我们手动设置Spring Security的上下文
            if (jwtTokenUtil.validateToken(jwtToken, userDetails)) {
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                
                logger.info("认证用户 " + username + ",设置安全上下文");
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        chain.doFilter(request, response);
    }
}

src/main/java/com/jdwa/login/security/JDWASecurityConfig.java:

package com.jdwa.login.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class JDWASecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JDWAUserDetailsService userDetailsService;

    @Autowired
    private JDWAAuthenticationFilter jwtAuthenticationFilter;

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        // 配置自定义的UserDetailsService和密码加密方式
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // 禁用CSRF(跨站请求伪造)
                .csrf().disable()
                // 启用CORS
                .cors().and()
                // 基于token,所以不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                // 设置权限
                .authorizeRequests()
                // 允许登录接口匿名访问
                .antMatchers("/api/login").permitAll()
                // 其他所有请求需要认证
                .anyRequest().authenticated();

        // 添加JWT token过滤器
        httpSecurity.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        
        // 禁用缓存
        httpSecurity.headers().cacheControl();
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type"));
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

4.9 服务层实现

src/main/java/com/jdwa/login/service/JDWAUserService.java:

package com.jdwa.login.service;

import com.jdwa.login.dto.JDWALoginRequest;
import com.jdwa.login.dto.JDWALoginResponse;
import com.jdwa.login.dto.JDWAUserDTO;
import com.jdwa.login.mapper.JDWAUserMapper;
import com.jdwa.login.model.JDWAUser;
import com.jdwa.login.security.JDWAJwtTokenUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;

@Service
public class JDWAUserService {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JDWAJwtTokenUtil jwtTokenUtil;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JDWAUserMapper userMapper;

    /**
     * 用户登录
     * @param loginRequest 登录请求
     * @return 登录响应(包含token和用户信息)
     * @throws Exception 认证异常
     */
    public JDWALoginResponse login(JDWALoginRequest loginRequest) throws Exception {
        try {
            // 认证用户凭据
            authenticate(loginRequest.getUsername(), loginRequest.getPassword());

            // 加载用户详情
            final UserDetails userDetails = userDetailsService.loadUserByUsername(loginRequest.getUsername());
            
            // 生成JWT令牌
            final String token = jwtTokenUtil.generateToken(userDetails);
            
            // 获取用户信息
            JDWAUser user = userMapper.findByUsername(loginRequest.getUsername());
            
            // 更新最后登录时间
            userMapper.updateLastLoginTime(user.getId());
            
            // 构建返回DTO
            JDWAUserDTO userDTO = new JDWAUserDTO();
            userDTO.setId(user.getId());
            userDTO.setUsername(user.getUsername());
            userDTO.setEmail(user.getEmail());
            userDTO.setRole(user.getRole());
            
            return new JDWALoginResponse(token, userDTO);
        } catch (Exception e) {
            throw e;
        }
    }

    /**
     * 认证用户
     * @param username 用户名
     * @param password 密码
     * @throws Exception 认证异常
     */
    private void authenticate(String username, String password) throws Exception {
        try {
            authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
        } catch (DisabledException e) {
            throw new Exception("用户已禁用", e);
        } catch (BadCredentialsException e) {
            throw new Exception("用户名或密码错误", e);
        }
    }
}

4.10 控制器实现

src/main/java/com/jdwa/login/controller/JDWALoginController.java:

package com.jdwa.login.controller;

import com.jdwa.login.dto.JDWALoginRequest;
import com.jdwa.login.dto.JDWALoginResponse;
import com.jdwa.login.dto.JDWAResponse;
import com.jdwa.login.service.JDWAUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@RestController
@RequestMapping("/api")
public class JDWALoginController {

    @Autowired
    private JDWAUserService userService;

    /**
     * 用户登录接口
     * @param loginRequest 登录请求
     * @return 登录响应
     */
    @PostMapping("/login")
    public ResponseEntity<?> login(@Valid @RequestBody JDWALoginRequest loginRequest) {
        try {
            JDWALoginResponse response = userService.login(loginRequest);
            return ResponseEntity.ok(JDWAResponse.success("登录成功", response));
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(JDWAResponse.fail(e.getMessage()));
        }
    }

    /**
     * 用户信息接口(需要认证)
     * @return 用户信息
     */
    @GetMapping("/user/info")
    public ResponseEntity<?> getUserInfo() {
        return ResponseEntity.ok(JDWAResponse.success("获取成功", "当前用户已认证"));
    }
}

4.11 应用程序入口类

src/main/java/com/jdwa/login/JDWALoginApplication.java:

package com.jdwa.login;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class JDWALoginApplication {

    public static void main(String[] args) {
        SpringApplication.run(JDWALoginApplication.class, args);
    }
}

5. 项目部署

5.1 前端打包部署

前端项目打包步骤:

# 进入前端项目目录
cd frontend

# 安装依赖
npm install

# 构建生产环境版本
npm run build

构建完成后,dist 目录中的文件即为可部署的静态资源。可以使用Nginx或其他Web服务器进行部署。

Nginx配置示例

server {
    listen       80;
    server_name  your-domain.com;

    # 前端静态资源
    location / {
        root   /path/to/frontend/dist;
        index  index.html;
        try_files $uri $uri/ /index.html;  # 支持Vue Router的history模式
    }

    # API代理
    location /api/ {
        proxy_pass http://localhost:8080/api/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

5.2 后端打包部署

后端项目打包步骤:

# 进入后端项目目录
cd backend

# 使用Maven打包
mvn clean package -DskipTests

打包完成后,target 目录中的 jdwa-login-0.0.1-SNAPSHOT.jar 即为可部署的JAR包。

运行JAR包

java -jar target/jdwa-login-0.0.1-SNAPSHOT.jar

使用环境变量配置

可以通过环境变量覆盖配置,例如数据库连接信息:

java -jar target/jdwa-login-0.0.1-SNAPSHOT.jar \
  --spring.datasource.url=jdbc:mysql://production-db:3306/jdwa_login \
  --spring.datasource.username=prod_user \
  --spring.datasource.password=prod_password

6. 项目测试

6.1 登录测试

测试登录接口:

curl -X POST -H "Content-Type: application/json" -d '{"username":"admin","password":"123456"}' http://localhost:8080/api/login

预期返回:

{
  "success": true,
  "message": "登录成功",
  "data": {
    "token": "eyJhbGciOiJIUzUxMiJ9...",
    "user": {
      "id": 1,
      "username": "admin",
      "email": "admin@example.com",
      "role": "ADMIN"
    }
  }
}

6.2 访问受保护资源测试

测试需要认证的接口:

curl -X GET -H "Authorization: Bearer YOUR_TOKEN_HERE" http://localhost:8080/api/user/info

预期返回:

{
  "success": true,
  "message": "获取成功",
  "data": "当前用户已认证"
}

7. 总结与扩展

7.1 项目总结

本项目展示了如何使用Vue 3和Spring Boot构建一个前后端分离的登录认证系统。主要技术要点包括:

  1. 前端方面:

    • Vue 3组合式API的使用
    • Vue Router实现路由控制和路由守卫
    • Pinia管理应用状态
    • Axios处理HTTP请求
    • Element Plus提供UI组件
  2. 后端方面:

    • Spring Boot构建RESTful API
    • Spring Security实现认证和授权
    • JWT实现无状态令牌认证
    • MyBatis实现数据库访问
    • 统一响应格式和异常处理

7.2 项目扩展

本项目提供了基础的登录功能,可以扩展以下功能:

  1. 用户管理功能:

    • 用户注册
    • 用户信息修改
    • 权限管理
  2. 安全性增强:

    • 密码策略(强度要求、定期更换)
    • 登录失败限制(防止暴力破解)
    • 验证码/图形验证
    • 双因素认证
  3. 日志和监控:

    • 用户操作日志
    • 系统监控和告警
  4. 前端体验优化:

    • 记住登录状态
    • 多主题支持
    • 国际化支持
  5. 部署和运维:

    • Docker容器化
    • CI/CD流程
    • 自动化测试

7.3 学习建议

对于初学者,建议分步骤学习:

  1. 先掌握单独的前端或后端技术
  2. 理解HTTP和RESTful API的基本原理
  3. 学习认证和授权的基本概念(Cookie、Session、Token、JWT等)
  4. 分阶段实现功能,从简单到复杂
  5. 重视安全性和代码质量

通过本项目的学习,可以掌握主流的前后端技术栈和实现方法,为进一步学习和开发打下良好基础。

项目源码

如需参考完整实现或进行实践,请通过以下途径获取源码:

  • GitHub:JDWA-vue-spring-boot-login
  • Gitee(国内推荐):vue-spring-boot-login
  • 蓝奏云(直接下载):vue-spring-boot-login.zip(密码:jdwa)

欢迎Star和Fork,如有问题可在仓库中提交Issue!

8. 常见问题解答(FAQ)

8.1 前后端路径匹配问题

问题:为什么要修改前端请求路径和Vite代理配置?

当在前后端分离项目中遇到路径不匹配问题时,通常需要调整以下配置:

  1. 前端请求路径:

    // 修改前:直接请求/login
    const response = await axios.post('/login', { username, password })
    
    // 修改后:请求/api/login
    const response = await axios.post('/api/login', { username, password })
    

    原因:后端在application.properties中配置了server.servlet.context-path=/api,这意味着所有后端接口的URL都会自动添加/api前缀。如果前端发送请求到/login,后端实际期望的完整路径是/api/login,导致路径不匹配。

  2. Vite代理配置:

    // 修改前:重写路径,删除/api前缀
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
    
    // 修改后:保留路径,不做重写
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path
      }
    }
    

    原因:原配置中的rewrite: (path) => path.replace(/^\/api/, '')会将所有以/api开头的请求路径中的/api前缀删除。例如,请求/api/login会被改写为/login然后发送到目标服务器。但由于后端已经配置了server.servlet.context-path=/api,再删除这个前缀会导致请求发送到错误的路径。

实际请求流程:

  • 修改前:前端请求/api/login → 代理重写为/login → 发送到后端http://localhost:8080/login(错误路径)
  • 修改后:前端请求/api/login → 代理保持不变 → 发送到后端http://localhost:8080/api/login(正确路径)

8.2 Spring Security工作原理

问题:JDWASecurityConfig如何控制请求访问权限?

Spring Security的工作原理可以类比为一个建筑物的安全系统:

  1. 安全系统整体架构:

    • 整个Web应用相当于一栋大楼
    • 不同的URL路径相当于不同的房间/区域
    • JDWASecurityConfig相当于大楼的总安保系统
    • JWT令牌相当于电子门禁卡
  2. 权限配置规则:

    .antMatchers("/api/login").permitAll() // 登录入口对所有人开放
    .antMatchers("/api/admin/**").hasRole("ADMIN") // 管理区域只有管理员可进入
    .anyRequest().authenticated() // 其他区域需要有效门禁卡
    
  3. 请求处理流程:

    • 当请求到达后端时,首先经过Servlet容器
    • 由于配置了server.servlet.context-path=/api,Spring Boot接收到的实际路径会去掉这个前缀
    • 请求进入Spring Security过滤器链
    • 过滤器链检查请求URL是否在允许访问的列表中
    • 对于/api/login请求,找到匹配的规则permitAll(),允许无需认证访问
    • 对于其他路径,检查是否有有效的JWT令牌和所需的角色权限
  4. 角色与权限:

    • .antMatchers("/api/admin/**").hasRole("ADMIN")表示所有以/api/admin/开头的URL路径只有拥有ADMIN角色的用户才能访问
    • Spring Security会自动将ADMIN转换为ROLE_ADMIN(内部添加ROLE_前缀)
    • 即使用户已登录,如果没有所需角色,也会返回403禁止访问错误

自动配置机制:

  • Spring Security通过注解自动应用安全配置
  • @Configuration和@EnableWebSecurity标记配置类
  • 配置类继承WebSecurityConfigurerAdapter并重写配置方法
  • Spring Boot在启动时自动构建安全过滤器链
  • 过滤器链拦截所有HTTP请求并应用安全规则

通过正确配置前后端的路径和安全规则,可以确保请求正确到达后端接口,并实现精细的权限控制。


本文档由记得晚安(JDWA)创建于2025-05-08,最后更新于2025-05-10。

Prev
JDWA Green-U 后端技术文档索引
Next
小程序开发实践