项目总结 白马 Session 用户中心

前言

白马程序员 基于 Session 的用户中心

视频教程:https://www.bilibili.com/video/BV1rT411W7QM

项目开源地址:https://github.com/itbaima-study/SpringBoot-Vue-Template-Session

前端技术选型

  • Vue 3 框架
  • Element UI 前端组件库

后端技术选型

  • Java 编程语言
  • Spring + SpringMVC + SpringBoot 框架
  • Spring Security 框架
  • MyBatis 数据访问框架
  • MySQL 数据库
  • jUnit 单元测试库

前端项目分析

项目结构

用词说明:版面 vs 组件

image-20230820152923909

image-20230820153422844

红框部分为版面,绿框部分为组件, 版面 : 组件 = 1 : n

通过配置路由组件,可以在单版面中实现版面下组件间切换
实例:上图中就相当于 http://127.0.0.1:5173/http://127.0.0.1:5173/register

routes: [
  // Welcome 版面
    {
        path: '/',
        name: 'welcome',
        component: () => import('/src/views/WelcomeView.vue'),
      	//版面下组件
        children: [{
            path: '',
            name: 'welcome-login',
            component: () => import('/src/components/welcome/LoginPage.vue')
        }, {
            path: 'register',
            name: 'welcome-register',
            component: () => import('/src/components/welcome/RegisterPage.vue')
        }, {
            path: 'forget',
            name: 'welcome-forget',
            component: () => import('/src/components/welcome/ForgetPage.vue')
        }]
    },
  // Index 版面
    {
        path: '/index',
        name: 'index',
        component: () => import('/src/views/IndexView.vue')
    }
]
Vue 完整项目结构
  • node_modules:存放项目依赖的第三方模块,当你使用yarnnpm安装依赖时,会自动下载和安装到该文件夹中

  • public:公共资源

    • favicon.ico:图标
  • src

    • components:组件
      • welcome:WelcomeView 版面下的组件
        • ForgetPage.vue
        • LoginPage. vue
        • RegisterPage.vue
    • net:网络相关配置
      • index.js
    • router:路由相关配置
      • index.js
    • stores:存储相关配置
      • index.js
    • views:版面
      • IndexView.vue
      • WelcomeView.vue
    • App.vue:入口版面
    • main.js:App 版面相关配置
  • .gitignore: 用于指定哪些文件或文件夹应该被 Git 版本控制系统忽略,这些文件通常是一些临时文件或包含敏感信息的文件,比如编译输出文件、应用程序配置文件、密码文件等。

  • index.html: 通常是前端项目的入口文件,定义了网页的整体结构和内容。它包含了 HTML 标签和需要加载的 CSS 样式和 JavaScript 脚本。

  • package.json: 用于指定项目的元数据和依赖项。其中包含项目的名称、版本号、作者、许可证等信息,还会记录项目所依赖的第三方库及其版本信息。

  • package-lock.json: 是 npm 的一种锁定文件,用于确保在不同的环境中,安装的依赖项的版本保持一致。它会详细记录每个依赖项的准确版本号,以便在后续安装时能够复现相同的依赖项版本。

  • README.md: 通常是项目的说明文档,包含了项目的介绍、使用方法、环境配置、运行示例等信息。

  • vite.config.js: 是 Vite 构建工具的配置文件,用于配置构建过程中的各种参数和选项,例如构建目标路径、插件配置、代理设置等。

  • yarn.lock: 类似于 package-lock.json,是 Yarn 包管理器的一种锁定文件,用于确保项目的依赖项在不同的环境中安装一致性。和 package-lock.json 不同的是,它是由 Yarn 自动生成的,而不是 npm。

Vue 关键项目结构
  • public:公共资源
    • favicon.ico:图标
  • src
    • components:组件
      • welcome:WelcomeView 版面下的组件
        • ForgetPage.vue
        • LoginPage. vue
        • RegisterPage.vue
    • net:网络相关配置
      • index.js
    • router:路由相关配置
      • index.js
    • stores:存储相关配置
      • index.js
    • views:版面
      • IndexView.vue
      • WelcomeView.vue
    • App.vue:入口版面
    • main.js:App 版面相关配置
  • index.html: 通常是前端项目的入口文件,定义了网页的整体结构和内容。它包含了 HTML 标签和需要加载的 CSS 样式和 JavaScript 脚本。

关键业务

路由配置
  • 想跳转时能跳转
  • 该跳转时才跳转
//导入依赖的模块
import {createRouter, createWebHistory} from 'vue-router'
import {userStore} from "../stores/counter";

//路由配置,统一管控页面跳转(想跳转时能跳转)
const router = createRouter({
    history: createWebHistory(import.meta.env.BASE_URL),
    routes: [
        {
            path: '/',
            name: 'welcome',
            component: () => import('/src/views/WelcomeView.vue'),
            children: [{
                path: '',
                name: 'welcome-login',
                component: () => import('/src/components/welcome/LoginPage.vue')
            }, {
                path: 'register',
                name: 'welcome-register',
                component: () => import('/src/components/welcome/RegisterPage.vue')
            }, {
                path: 'forget',
                name: 'welcome-forget',
                component: () => import('/src/components/welcome/ForgetPage.vue')
            }]
        },
        {
            path: '/index',
            name: 'index',
            component: () => import('/src/views/IndexView.vue')
        }
    ]
})

//router 特有高阶函数:路由守卫,保证页面跳转逻辑(该跳转时才跳转)
router.beforeEach((to, from, next) => {
    //获取 userStore 中的数据,用于判断用户是否已经登录
    //类比小程序中的 Storage 或 Web 中的 SessionStorage 和 localStorage
    const store = userStore()
    
    //如果用户已登录但想跳转回 welcome- 开头的页面,则直接跳转到登录后的 index 页面
    if (store.auth.user != null && to.name.startsWith('welcome-')) { next('/index'); }
  
    //如果用户未登录又企图跳转到登录后的 index 页面,则强行返回登录页面
    else if (store.auth.user == null && to.fullPath.startsWith('/index')) {
        next('/');  
    		}
  
  	//如果页面不存在(to.matched.length === 0),默认跳回 index
  	//已登录的跳转到 index 时,正常跳转(即跳转到 index)
  	//未登录的跳转回 index 时会因为 store.auth.user == null,被二次转送到登录页面
  	else if (to.matched.length === 0) {
        next('/index');
    } 
  
    //其他情况正常跳转
  	else { next(); }
})

//对外暴露 router 模块
export default router
表单填写(以注册为例)
<template>

    ...

<!-- 表单本体 -->  
<el-form :model="form" :rules="rules" @validate="onValidate" ref="formRef">

  <!-- 表单元素 -->  
  <el-form-item prop="username">
    <!-- 输入框格式 -->  
    <el-input v-model="form.username" maxlength="8" type="text" placeholder="用户名">
      <!-- 图标 -->  
      <template #prefix><el-icon><User/></el-icon></template>
    </el-input>
  </el-form-item>

  <el-form-item prop="password">
    <el-input v-model="form.password" maxlength="16" type="password" placeholder="密码">
      <template #prefix><el-icon><Lock/></el-icon></template>
    </el-input>
  </el-form-item>

  <el-form-item prop="password_repeat">
    <el-input v-model="form.password_repeat" maxlength="16" type="password" placeholder="再次输入密码">
      <template #prefix><el-icon><Lock/></el-icon></template>
    </el-input>
  </el-form-item>

  <el-form-item prop="email">
    <el-input v-model="form.email" type="email" placeholder="电子邮箱地址">
      <template #prefix><el-icon><Message/></el-icon></template>
    </el-input>
  </el-form-item>

  <el-form-item prop="code">
    <el-row :gutter="10" style="width: 100%">
      <el-col :span="17">
        <el-input v-model="form.code" maxlength="6" type="text" placeholder="请输入验证码">
          <template #prefix><el-icon><EditPen/></el-icon></template>
        </el-input>
      </el-col>
      <el-col :span="5">
        <el-button 
            type="success" @click="validateEmail :disabled="!isEmailValid||coldTime > 0">
          {{ coldTime > 0 ? '请等待 ' + coldTime + '秒' : '获取验证码' }}
        </el-button>
      </el-col>
    </el-row>
  </el-form-item>

</el-form>

<!-- 表单按钮 -->  
<el-button style="width: 270px" type="warning" plain @click="register()">立即注册</el-button>

		...

</template>
<script setup>

...
  
//声明通过输入获取的双向绑定的值,即表单
const form = reactive({
  username: '',
  password: '',
  password_repeat: '',
  email: '',
  code: ''
})

//合法性验证规则定义
const validateUsername = (rule, value, callback) => {
  if (value === '') {
    callback(new Error('请输入用户名'))
  } else if (!/^[a-zA-Z0-9\u4e00-\u9fa5]+$/.test(value)) {
    callback(new Error('用户名不能包含特殊字符,只能是中文/英文'))
  } else {
    callback()
  }
}

const validatePassword = (rule, value, callback) => {
  if (value === '') {
    callback(new Error('请再次输入密码'))
  } else if (value !== form.password) {
    callback(new Error("两次输入的密码不一致"))
  } else {
    callback()
  }
}

//合法性规则配置中心
const rules = {
  username: [
    {validator: validateUsername, trigger: ['blur', 'change']},
    {min: 2, max: 8, message: '用户名的长度必须在2-8个字符之间', trigger: ['blur', 'change']},
  ],
  password: [
    {required: true, message: '请输入密码', trigger: 'blur'},
    {min: 6, max: 16, message: '密码的长度必须在6-16个字符之间', trigger: ['blur', 'change']}
  ],
  password_repeat: [
    {validator: validatePassword, trigger: ['blur', 'change']},
  ],
  email: [
    {required: true, message: '请输入邮件地址', trigger: 'blur'},
    {type: 'email', message: '请输入合法的电子邮件地址', trigger: ['blur', 'change']}
  ],
  code: [
    {required: true, message: '请输入获取的验证码', trigger: 'blur'},
  ]
}

//发送注册请求
const register = () => {
  console.log("register")
  console.log(form)
  formRef.value.validate((isValid) => {
    if (isValid) {
      post('/api/auth/register', {
        username: form.username,
        password: form.password,
        email: form.email,
        code: form.code
      }, (message) => {
        //注册成功
        ElMessage.success(message)
        router.push("/")
      })
    } else {
      ElMessage.warning("请完整填写表单内容")
    }
  })
}

...

</script>
重置密码
进度控制
<template>
  <div>
    <!--进度条组件-->
    <div style="margin: 30px 20px">
      <el-steps :active="active" finish-status="success" align-center>
        <el-step title="验证电子邮件"/>
        <el-step title="重新设定密码"/>
      </el-steps>
    </div>
    
    <!-- 进度条事件0 -->
    <transition name="el-fade-in-linear" mode="out-in">
      <div style="text-align: center;margin: 0 20px;height: 100%" v-if="active === 0">
      	...
  		</div>
    </transition>
    
    <!-- 进度条事件1 -->
    <transition name="el-fade-in-linear" mode="out-in">
      <div style="text-align: center;margin: 0 20px;height: 100%" v-if="active === 1">
      	...
  		</div>
    </transition>
    
  </div>
</template>
<script setup>

...

//进度控制
const active = ref(0)
  
const startReset = () => {
  formRef.value.validate((isValid) => {
    if (isValid) {
      // 发送申请重置密码的请求
      post('/api/auth/start-reset', {
        email: form.email,
        code: form.code
      },
      // 请求成功后执行的回调函数
      () => {
        //进度++
        active.value++
      })
    } else {
      ElMessage.warning('请填写电子邮件地址和验证码')
    }
  })
}
</script>
按键启用
<template>

	...

  <el-form :model="form" :rules="rules" @validate="onValidate" ref="formRef">
    <el-form-item prop="email">
      <el-input v-model="form.email" type="email" placeholder="电子邮件地址">
        <template #prefix><el-icon><Message/></el-icon></template>
      </el-input>
  </el-form-item>

	...

	<el-button type="success" @click="validateEmail":disabled="!isEmailValid || coldTime > 0">
    {{ coldTime > 0 ? '请稍后 ' + coldTime + ' 秒' : '获取验证码' }}
  </el-button>

	...

</template>
<script setup>
  
  ...

  const formRef = ref()
  const isEmailValid = ref(false)
	
  //监听邮箱格式,邮箱格式正确时,按键可用
  const onValidate = (prop, isValid) => {
    if (prop === 'email')
      isEmailValid.value = isValid
  }
  
  ...
  
</script>
按键冷却
<template>

	...

	<el-button type="success" @click="validateEmail":disabled="!isEmailValid || coldTime > 0">
    {{ coldTime > 0 ? '请稍后 ' + coldTime + ' 秒' : '获取验证码' }}
  </el-button>

	...

</template>
<script setup>
  
  ...

  //默认冷却时间为 0
  const coldTime = ref(0)
  
  const validateEmail = () => {
  //申请发送请求时,冷却时间设为 60
  coldTime.value = 60
  post('/api/auth/valid-reset-email', {
    email: form.email
  }, 
       
  //请求成功后执行的回调函数     
  (message) => {
    ElMessage.success(message)
    //每隔 1000 ms,冷却时间--
    setInterval(() => coldTime.value--, 1000)
  }, 
       
   //请求失败后执行的回调函数     
   (message) => {
    ElMessage.warning(message)
    //冷却时间置为 0,避免出现请求失败时也要等待的情况
    coldTime.value = 0
  })
}
  
  ...
  
</script> 
用户态信息存取
从后端获取并在前端存储用户态信息

App.vue,即入口版面中申请获取用户态信息,并存储到 userStore()

<script setup>
import {get} from "./net";
import {userStore} from "./stores/counter";
import router from "./router";

const store = userStore()

if(store.auth.user == null) {
  get('/api/user/me', (message) => {
    store.auth.user = message
    router.push('/index')
  }, () => {
    store.auth.user = null
  })
}

</script>

<template>
  <router-view/>
</template>

<style scoped>

</style>
使用前端存储的用户态信息
<script setup>
import {get} from '../net'
import {ElMessage} from "element-plus";
import router from "../router";
import {userStore} from "../stores/counter";

const store = userStore()

const logout = () => {
  get('/api/auth/logout', (message) => {
    ElMessage.success(message)
    //退出登录后清除 store 中的用户信息
    store.auth.user = null;
    router.push('/')
  })
}
</script>

<template>
  <div>欢迎 {{store.auth.user.username}} 进入白马程序员</div>
  <div>
    <el-button @click="logout()" type="danger" plain>
      退出登录
    </el-button>
  </div>
</template>

<style scoped>

</style>

后端项目分析

项目结构

  • config:配置类
    • SecurityConfiguration:Spring Security 相关配置
    • WebConfiguration:拦截器配置
  • controller
    • AuthorizeController:配置身份验证相关的 RESTful API
    • UserController:提供获取用户信息相关的 RESTful API
  • entity
    • auth
      • Account:数据库映射对象
    • user
      • AccountUser:脱敏数据对象
    • RestBean:统一前端响应
  • interceptor
    • AuthorizeInterceptor:身份验证相关拦截器
  • mapper
    • UserMapper:提供数据库访问服务
  • service
    • impl
      • AuthorizeServiceImpl
    • AuthorizeService:实现身份认证相关业务功能

关键业务

Spring Security 配置

Spring Security 是一个基于 Spring 框架的安全性解决方案,用于保护应用程序的身份验证和授权。它提供了一组用于处理身份验证、授权、防止常见攻击和管理用户会话的功能。

注意:Spring Security 用法随版本变化大,此处使用的版本为:6.0.2,新版本可能不支持这种方法链的配置方式,而是全部采用 lambda 形式进行配置

lambda 配置方法示例:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

 	...
 
 //如果你学习过 SpringSecurity 5.X 版本,可能会发现新版本的配置方式完全不一样
     //新版本全部采用 lambda 形式进行配置,无法再使用之前的 and() 方法进行连接了
     @Bean
     public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
         return http
                 //以下是验证请求拦截和放行配置
                 .authorizeHttpRequests(auth -> {
                     auth.anyRequest().authenticated();    //将所有请求全部拦截,一律需要验证
                 })
                 //以下是表单登录相关配置
                 .formLogin(conf -> {
                     conf.loginPage("/login");   //将登录页设置为我们自己的登录页面
                     conf.loginProcessingUrl("/doLogin"); //登录表单提交的地址,可以自定义
                     conf.defaultSuccessUrl("/");   //登录成功后跳转的页面
                     conf.permitAll();    //将登录相关的地址放行,否则未登录用户连登录界面都进不去
                    //用户名和密码的表单字段名称,不过默认就是这个,可以不配置,除非有特殊需求
                     conf.usernameParameter("username");
                     conf.passwordParameter("password");
                 })
                 .build();
     }
}

Spring Security 集成了以下功能

  • 登入
  • 登出
  • 记住我
  • 跨域
拦截链配置
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, PersistentTokenRepository repository) throws Exception {
    return http
        // 配置请求授权规则
        .authorizeHttpRequests()
            // 允许 /api/auth/** 路径的请求不需要认证
            .requestMatchers("/api/auth/**").permitAll()
            // 其他请求需要认证
            .anyRequest().authenticated()
        .and()
        // 配置表单登录
        .formLogin()
            // 配置登录接口的 URL
            .loginProcessingUrl("/api/auth/login")
            // 配置登录成功的处理器
            .successHandler(this::onAuthenticationSuccess)
            // 配置登录失败的处理器
            .failureHandler(this::onAuthenticationFailure)
        .and()
        // 配置登出
        .logout()
            // 配置登出接口的 URL
            .logoutUrl("/api/auth/logout")
            // 配置登出成功的处理器
            .logoutSuccessHandler(this::onAuthenticationSuccess)
        .and()
        // 配置记住我功能
        .rememberMe()
            // 配置前端传递"remember"参数用于记住我功能,若不指定,则默认参数名称为 remember-me
            .rememberMeParameter("remember")
            // 配置Token的持久化存储
            .tokenRepository(repository)
            // 配置Token的有效期为7天
            .tokenValiditySeconds(3600 * 24 * 7)
        .and()
        // 禁用 CSRF 保护
        .csrf().disable()
        // 配置跨域问题处理
        .cors().configurationSource(this.corsConfigurationSource())
        .and()
        // 配置异常处理器
        .exceptionHandling()
            // 配置未认证用户访问接口时的处理器
            .authenticationEntryPoint(this::onAuthenticationFailure)
        .and()
        // 构建 HttpSecurity 对象
        .build();
}
验证成功处理器
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
    response.setCharacterEncoding("utf-8");
    if(request.getRequestURI().endsWith("/login"))
        response.getWriter().write(JSONObject.toJSONString(RestBean.success("登录成功")));
    else if(request.getRequestURI().endsWith("/logout"))
        response.getWriter().write(JSONObject.toJSONString(RestBean.success("退出登录成功")));
}
验证失败处理器
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
    response.setCharacterEncoding("utf-8");
    response.getWriter().write(JSONObject.toJSONString(RestBean.failure(401, exception.getMessage())));
}
登录登出功能

知识补充

Spring Security 提供了基于内存验证,基于数据库验证和自定义验证三种验证机制
三种验证机制都是通过配置 UserDetailsService 实现的

  • 基于内存验证:直接以代码的形式配置我们网站的用户和密码,配置方式非常简单

    @Configuration
    @EnableWebSecurity
    public class SecurityConfiguration {
    
        @Bean   
      	//UserDetailsService就是获取用户信息的服务
        public UserDetailsService userDetailsService() {
          	//每一个UserDetails就代表一个用户信息,其中包含用户的用户名和密码以及角色
            UserDetails user = User.withDefaultPasswordEncoder()
                    .username("user")
                    .password("password")
                    .roles("USER")  //角色目前我们不需要关心,随便写就行
                    .build();
            UserDetails admin = User.withDefaultPasswordEncoder()
                    .username("admin")
                    .password("password")
                    .roles("ADMIN", "USER")
                    .build();
            return new InMemoryUserDetailsManager(user, admin); 
          	//创建一个基于内存的用户信息管理器作为UserDetailsService
        }
    }
    
  • 基于数据库验证:使用官方默认提供的可以直接使用的用户和权限表设计,不需要我们来建表

    @Configuration
    @EnableWebSecurity
    public class SecurityConfiguration {
    
       @Bean PasswordEncoder passwordEncoder(){
            return new BCryptPasswordEncoder();
        }
    
        @Bean
        public DataSource dataSource(){
          	//数据源配置
            return new PooledDataSource("com.mysql.cj.jdbc.Driver",
                    "jdbc:mysql://localhost:3306/test", "root", "123456");
        }
    
        @Bean
        public UserDetailsService userDetailsService(DataSource dataSource,
                                                     PasswordEncoder encoder) {
            JdbcUserDetailsManager manager = new JdbcUserDetailsManager(dataSource);
          	//仅首次启动时创建一个新的用户用于测试,后续无需创建
       			manager.createUser(User.withUsername("user")
                   .password(encoder.encode("password")).roles("USER").build());
            return manager;
        }
    }
    
  • 自定义验证:要使用自定义的数据库表进行验证时,需要自行实现 UserDetailsService 或> 是功能更完善的UserDetailsManager 接口,此处以实现 UserDetailsService 为例

    @Service
    public class AuthorizeService implements UserDetailsService {
        
        @Resource
        UserMapper mapper;
        
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            Account account = mapper.findUserByName(username);
            if(account == null)
                throw new UsernameNotFoundException("用户名或密码错误");
            return User
                    .withUsername(username)
                    .password(account.getPassword())
                    .build();
        }
    }
    

SecurityConfiguration

创建一个 AuthenticationManager 对象,并配置其使用的 UserDetailsServiceUserDetailsService 是用于加载用户信息的接口,它负责根据用户名获取相应的用户信息,此处绑定 authorizeService 作为 UserDetailsService

@Resource
AuthorizeService authorizeService;

@Bean
public AuthenticationManager authenticationManager(HttpSecurity security) throws Exception {
  	return security.getSharedObject(AuthenticationManagerBuilder.class)
            .userDetailsService(authorizeService)
            .and().build();
}

AuthorizeService

实现 loadUserByUsername 方法,表示通过自定义的方式进行验证,此处根据给定的用户名查询用户,并封装为UserDetails对象返回,然后由 SpringSecurity 将我们返回的对象与用户登录的信息进行核验,基本流程实际上跟之前是一样的,只是现在由我们自己来提供用户查询方式。

@Service
public class AuthorizeServiceImpl implements AuthorizeService {

    @Resource
    UserMapper mapper;

    @Override
	//虽然参数叫 username,但是从代码可以看出此处的验证逻辑扩展为按用户名或邮箱验证,而非狭义上的按用户名验证
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
      //输入参数 username 为空,抛出异常
        if (username == null)
            throw new UsernameNotFoundException("用户名或邮箱为空");
      //输入参数 username 非空,尝试利用 username 从数据库中读取用户信息
        Account account = mapper.findAccountByNameOrEmail(username);
      //数据库中不存在 username 对应的用户信息,抛出异常
        if (account == null)
            throw new UsernameNotFoundException("用户不存在");
      //若数据库存在 username 对应的用户信息,则将用户信息封装成 UserDetails 对象返回,交由 Spring Security 框架进行后续处理
        return User
                .withUsername(account.getUsername())
                .password(account.getPassword())
                .roles("user")
                .build();
    }
    
		...
	
}

也就是说,登录功能的验证逻辑(包括判空处理和数据库操作)还是开发者自己写的,但是按照 Spring Security 的规范(实现 UserDetailsService 中的 loadUserByUsername 方法)来写,为 Spring Security 返回UserDetails 对象

验证过程交由 Spring Security 完成(Spring Security 利用返回的 UserDetails 对象进行验证)

记住我功能 —— Token 持久化配置处理器
@Resource
DataSource dataSource;
@Bean
public PersistentTokenRepository tokenRepository(){
  // 创建一个JdbcTokenRepositoryImpl实例 
  JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); 
  // 设置数据源 
  jdbcTokenRepository.setDataSource(dataSource); 
  // 首次启动时设为 true,自动创建表,建表成功后设置在启动时不创建Token表 
  jdbcTokenRepository.setCreateTableOnStartup(false);
  // 返回实例
  return jdbcTokenRepository;
}
跨域问题处理

原理还是在请求头中加入 Access-Control-Allow-Origin 相关配置信息,但是交由 Spring Security 统一配置

private CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration cors = new CorsConfiguration();
    cors.addAllowedOriginPattern("*");
    cors.setAllowCredentials(true);
    cors.addAllowedHeader("*");
    cors.addAllowedMethod("*");
    cors.addExposedHeader("*");
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", cors);
    return source;
}
请求拦截器配置
配置中心
  1. 注册拦截器(拦截器功能:利用 Session 存储用户态信息)
  2. 配置拦截器的拦截范围:拦截器 AuthorizeInterceptor 被添加到所有 URL 路径(/**)下
  3. 配置白名单(不拦截的路径):排除了路径以 /api/auth/ 开头的请求
//请求拦截器
@Configuration
public class WebConfiguration implements WebMvcConfigurer {

    @Resource
    AuthorizeInterceptor interceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry
                .addInterceptor(interceptor)
                .addPathPatterns("/**")
                .excludePathPatterns("/api/auth/**");
    }
}
拦截器

用 Spring Security 实现 Session 存储用户态信息

//拦截请求,获取当前登录用户的用户信息,存储到 Session 中
@Component
public class AuthorizeInterceptor implements HandlerInterceptor {
    @Resource
    UserMapper mapper;   // 依赖注入 UserMapper 对象

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 获取当前的 SecurityContext
        SecurityContext context = SecurityContextHolder.getContext();
        // 从 SecurityContext 中获取当前的认证对象
        Authentication authentication = context.getAuthentication();
        // 从认证对象中获取认证主体对象(User),即当前登录用户
        User user = (User) authentication.getPrincipal();
        String username = user.getUsername();
        // 根据用户名或邮箱查询用户信息
        AccountUser account = mapper.findAccountUserByNameOrEmail(username);
        // 将查询到的用户信息存放在 session 中
        request.getSession().setAttribute("account", account);
        return true;
    }
}
发送邮件验证码功能

本质上就是用 STMP 服务器发邮件

依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
</dependency>
配置
spring:
  mail:
    host: smtp.163.com
    username: your_email_address
    password: your_email_password
    port: 465
    properties:
      from: anything_you_want_to_show
      mail:
        smtp:
          socketFactory:
            class: javax.net.ssl.SSLSocketFactory
  data:
    redis:
      database: 0
      host: localhost
      port: 6379
功能实现

功能总结:

  • 验证码生成
  • 邮件发送
  • 验证码存储

实现流程:

  1. 如果一个请求中同一个邮箱的该行为在1分钟内发起了多次,则拒绝发送邮件。
  2. 检查邮箱是否存在对应的账户,根据不同的业务逻辑需求,采用不同的异常处理逻辑
    • 重置密码:要求账户存在
    • 注册账户:要求账户不存在
  3. 生成6位随机验证码。
  4. 构造邮件内容,并发送邮件。
  5. 将验证码存储到 Redis 中,有效期为3分钟。
  6. 如果发送邮件失败,则返回错误信息。
//从 Application.yaml 中获取发件人信息
@Value("${spring.mail.username}")
String from;

@Resource
MailSender mailSender;

@Resource
StringRedisTemplate template;

/**
	参数说明:
	email:待接收验证码的邮箱
	sessionId:当前会话Id
	hasAccount:是否需要账户存在?
	因为重置密码和注册账户都需要用到邮箱服务,业务逻辑不一致的情况下想复用邮箱服务的代码,所以引入标识
	重置密码时,要求账户存在,此时 hasAccount 传入 true,使用 重置密码 的异常处理机制
	注册账户时,要求账户不存在,此时 hasAccount 传入 false,使用 注册账户 的异常处理机制
**/
@Override
public String sendValidateEmail(String email, String sessionId, boolean hasAccount) {
    // 构造存储在Redis中的key
    String key = "email:" + sessionId + ":" + email + ":" +hasAccount;

    // 判断是否存在该key,并获取其有效时间
    if(Boolean.TRUE.equals(template.hasKey(key))) {
        Long expire = Optional.ofNullable(template.getExpire(key, TimeUnit.SECONDS)).orElse(0L);
      	// 默认有效时间为180秒,默认冷却时间为60秒
        // 如果剩余有效时间大于 180 - 60 = 120秒,说明请求过于频繁,返回错误信息
        if(expire > 120) return "请求频繁,请稍后再试";
    }

    // 根据邮箱查找账户信息
    Account account = mapper.findAccountByNameOrEmail(email);
  	// 根据不同的业务需求,提供不同的异常处理机制
    // 重置密码时需要账户存在,此时如果查找不到账户信息,则返回错误信息
    if(hasAccount && account == null) return "没有此邮件地址的账户";
    // 注册账户时不需要账户信息,此时如果查到了账户信息,则返回错误信息
    if(!hasAccount && account != null) return "此邮箱已被其他用户注册";

    // 生成6位随机验证码
    Random random = new Random();
    int code = random.nextInt(899999) + 100000;

    // 构造邮件内容
    SimpleMailMessage message = new SimpleMailMessage();
    message.setFrom(from);
    message.setTo(email);
    message.setSubject("您的验证邮件");
    message.setText("验证码是:"+code);

    try {
        // 发送邮件
        mailSender.send(message);
        // 将验证码存储到 Redis 中,有效期为3分钟
        template.opsForValue().set(key, String.valueOf(code), 3, TimeUnit.MINUTES);
        return null;
    } catch (MailException e) {
        e.printStackTrace();
        return "邮件发送失败,请确保邮件地址是否有效";
    }
}
验证码总结
  1. 有效时间:出于安全性考虑,验证码需要设置过期时间
  2. 冷却时间:出于服务器性能考虑,需要防止短期重复请求
    类比 技能 CD
用户注册功能

本来很简单的功能,就是向数据库新增一条数据,但是集成了邮箱验证码功能就稍微变得有些复杂

  1. 邮件发送
  2. 验证验证码
  3. 向数据库新增一条数据
Controller
  • 发送邮箱验证码(注册账户时,账户不应该存在,故 hasAccount:false)

    @Resource
    AuthorizeService service;
    
    @PostMapping("/valid-register-email")
    public RestBean<String> validateRegisterEmail(@Pattern(regexp = EMAIL_REGEX) @RequestParam("email") String email, HttpSession session){
        String s = service.sendValidateEmail(email, session.getId(), false);
        if (s == null)
            return RestBean.success("邮件已发送,请注意查收");
        else
            return RestBean.failure(400, s);
    }
    
  • 注册信息写入

    //正则表达式限制
    private final String EMAIL_REGEX = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$";
    private final String USERNAME_REGEX = "^[a-zA-Z0-9一-龥]+$";
    
    
    @PostMapping("/register")
    public RestBean<String> registerUser(
      @Pattern(regexp = USERNAME_REGEX) @Length(min = 2, max = 8) @RequestParam("username") 	String username,
      @Length(min = 6, max = 16) @RequestParam("password") 
      String password,
      @Pattern(regexp = EMAIL_REGEX) @RequestParam("email") 
      String email,
      @Length(min = 6, max = 6) @RequestParam("code") 
      String code, HttpSession session) {
        String s = service.validateAndRegister(username, password, email, code, session.getId());
        if(s == null)
            return RestBean.success("注册成功");
        else
            return RestBean.failure(400, s);
    }
    
    1. 数据合法性验证
    2. 服务调用
    3. 统一响应返回
Service
  1. 从 Redis 中获取验证码
  2. 验证码比对
    • 是否存在 → 不存在则返回错误信息:“请先请求一封验证码邮件”
    • 是否过期 → 已过期则返回错误信息:“验证码失效,请重新请求”
    • 是否一致 → 不一致则返回错误信息:“此用户名已被注册,请更换用户名”
  3. 验证码比对通过后,执行注册相关的逻辑
    1. 判断用户账号是否存在 → 不存在则返回错误信息:“此用户名已被注册,请更换用户名”
    2. 删除 Redis 中的验证码
    3. 密码加密
    4. 通过 UserMapper 将账户信息存储到数据库中,若出错则返回错误信息:“内部错误,请联系管理员”
@Resource
UserMapper mapper;

//Redis 数据控制模板
@Resource
StringRedisTemplate template;

//编码器,密码加密用
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();

@Override
public String validateAndRegister(String username, String password, String email, String code, String sessionId) {
    String key = "email:" + sessionId + ":" + email + ":false";
    if(Boolean.TRUE.equals(template.hasKey(key))) {
        String s = template.opsForValue().get(key);
        if(s == null) return "验证码失效,请重新请求";
        if(s.equals(code)) {
            Account account = mapper.findAccountByNameOrEmail(username);
            if(account != null) return "此用户名已被注册,请更换用户名";
            template.delete(key);
            password = encoder.encode(password);
            if (mapper.createAccount(username, password, email) > 0) {
                return null;
            } else {
                return "内部错误,请联系管理员";
            }
        } else {
            return "验证码错误,请检查后再提交";
        }
    } else {
        return "请先请求一封验证码邮件";
    }
}
重置密码功能

本来很简单的功能,就是向数据库更新一条数据,但是集成了邮箱验证码功能就稍微变得有些复杂

  1. 邮件发送
  2. 验证验证码
  3. 向数据库更新一条数据
Controller
  • 发送邮箱验证码(重置密码时,账户应该存在,故 hasAccount:true)

    @PostMapping("/valid-reset-email")
    public RestBean<String> validateResetEmail(@Pattern(regexp = EMAIL_REGEX) @RequestParam("email") String email, HttpSession session){
        String s = service.sendValidateEmail(email, session.getId(), true);
        if (s == null)
            return RestBean.success("邮件已发送,请注意查收");
        else
            return RestBean.failure(400, s);
    }
    
  • 验证验证码

    @PostMapping("/start-reset")
    public RestBean<String> startReset(
      @Pattern(regexp = EMAIL_REGEX) @RequestParam("email") String email,
      @Length(min = 6, max = 6) @RequestParam("code") String code, HttpSession session) {
        String s = service.validateOnly(email, code, session.getId());
        if(s == null) {
            session.setAttribute("reset-password", email);
            return RestBean.success();
        } else {
            return RestBean.failure(400, s);
        }
    }
    
    1. 调用服务:验证验证码
    2. 向 Session 中写入邮箱
  • 重置密码

    @PostMapping("/do-reset")
    public RestBean<String> resetPassword(
      @Length(min = 6, max = 16) @RequestParam("password") String password,HttpSession session){
        String email = (String) session.getAttribute("reset-password");
        if(email == null) {
            return RestBean.failure(401, "请先完成邮箱验证");
        } else if(service.resetPassword(password, email)){
            session.removeAttribute("reset-password");
            return RestBean.success("密码重置成功");
        } else {
            return RestBean.failure(500, "内部错误,请联系管理员");
        }
    }
    
    1. 从 Session 中获取邮箱
    2. 调用服务:向数据库更新用户密码,如果出错,返回错误响应
    3. 删除 Session 中的邮箱
Service

验证验证码服务

  1. 从 Redis 中获取验证码
  2. 验证码比对
    • 是否存在 → 不存在则返回错误信息:“请先请求一封验证码邮件”
    • 是否过期 → 已过期则返回错误信息:“验证码失效,请重新请求”
    • 是否一致 → 不一致则返回错误信息:“验证码错误,请检查后再提交”
  3. 验证码比对通过后,删除 Redis 中的验证码
@Override
public String validateOnly(String email, String code, String sessionId) {
    String key = "email:" + sessionId + ":" + email + ":true";
    if(Boolean.TRUE.equals(template.hasKey(key))) {
        String s = template.opsForValue().get(key);
        if(s == null) return "验证码失效,请重新请求";
        if(s.equals(code)) {
            template.delete(key);
            return null;
        } else {
            return "验证码错误,请检查后再提交";
        }
    } else {
        return "请先请求一封验证码邮件";
    }
}

数据库信息更新服务

  1. 密码加密
  2. 通过 Userapper 更新数据库信息
@Override
public boolean resetPassword(String password, String email) {
    password = encoder.encode(password);
    return mapper.resetPasswordByEmail(password, email) > 0;
}

项目总结

前端

  1. Vue 项目结构了解

  2. 关键业务

    • 路由配置

    • 表单填写

    • 重置密码

      1. 进度控制
      2. 邮件验证码
        1. 按键启用
        2. 按键冷却
    • 用户态信息存取

后端

  1. Spring Security 配置
    • 拦截链配置
    • 验证成功处理器
    • 验证失败处理器
    • 记住我功能 —— Token 持久化配置处理器
    • 跨域问题处理
  2. 请求拦截器配置
    • 配置中心
    • 拦截器
  3. 发动邮件验证码功能
  4. 用户注册功能(集成邮箱验证码)
  5. 重置密码功能(集成邮箱验证码)
posted @ 2023-08-21 10:49  Ba11ooner  阅读(162)  评论(0编辑  收藏  举报