项目总结 白马 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 组件
红框部分为版面,绿框部分为组件, 版面 : 组件 = 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
:存放项目依赖的第三方模块,当你使用yarn
或npm
安装依赖时,会自动下载和安装到该文件夹中 -
public:公共资源
- favicon.ico:图标
-
src
- components:组件
- welcome:WelcomeView 版面下的组件
- ForgetPage.vue
- LoginPage. vue
- RegisterPage.vue
- welcome:WelcomeView 版面下的组件
- net:网络相关配置
- index.js
- router:路由相关配置
- index.js
- stores:存储相关配置
- index.js
- views:版面
- IndexView.vue
- WelcomeView.vue
- App.vue:入口版面
- main.js:App 版面相关配置
- components:组件
-
.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
- welcome:WelcomeView 版面下的组件
- net:网络相关配置
- index.js
- router:路由相关配置
- index.js
- stores:存储相关配置
- index.js
- views:版面
- IndexView.vue
- WelcomeView.vue
- App.vue:入口版面
- main.js:App 版面相关配置
- components:组件
- 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:统一前端响应
- auth
- interceptor
- AuthorizeInterceptor:身份验证相关拦截器
- mapper
- UserMapper:提供数据库访问服务
- service
- impl
- AuthorizeServiceImpl
- AuthorizeService:实现身份认证相关业务功能
- impl
关键业务
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
对象,并配置其使用的 UserDetailsService
。UserDetailsService
是用于加载用户信息的接口,它负责根据用户名获取相应的用户信息,此处绑定 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;
}
请求拦截器配置
配置中心
- 注册拦截器(拦截器功能:利用 Session 存储用户态信息)
- 配置拦截器的拦截范围:拦截器
AuthorizeInterceptor
被添加到所有 URL 路径(/**
)下 - 配置白名单(不拦截的路径):排除了路径以
/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分钟内发起了多次,则拒绝发送邮件。
- 检查邮箱是否存在对应的账户,根据不同的业务逻辑需求,采用不同的异常处理逻辑
- 重置密码:要求账户存在
- 注册账户:要求账户不存在
- 生成6位随机验证码。
- 构造邮件内容,并发送邮件。
- 将验证码存储到 Redis 中,有效期为3分钟。
- 如果发送邮件失败,则返回错误信息。
//从 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 "邮件发送失败,请确保邮件地址是否有效";
}
}
验证码总结
- 有效时间:出于安全性考虑,验证码需要设置过期时间
- 冷却时间:出于服务器性能考虑,需要防止短期重复请求
类比 技能 CD
用户注册功能
本来很简单的功能,就是向数据库新增一条数据,但是集成了邮箱验证码功能就稍微变得有些复杂
- 邮件发送
- 验证验证码
- 向数据库新增一条数据
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); }
- 数据合法性验证
- 服务调用
- 统一响应返回
Service
- 从 Redis 中获取验证码
- 验证码比对
- 是否存在 → 不存在则返回错误信息:“请先请求一封验证码邮件”
- 是否过期 → 已过期则返回错误信息:“验证码失效,请重新请求”
- 是否一致 → 不一致则返回错误信息:“此用户名已被注册,请更换用户名”
- 验证码比对通过后,执行注册相关的逻辑
- 判断用户账号是否存在 → 不存在则返回错误信息:“此用户名已被注册,请更换用户名”
- 删除 Redis 中的验证码
- 密码加密
- 通过
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 "请先请求一封验证码邮件";
}
}
重置密码功能
本来很简单的功能,就是向数据库更新一条数据,但是集成了邮箱验证码功能就稍微变得有些复杂
- 邮件发送
- 验证验证码
- 向数据库更新一条数据
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); } }
- 调用服务:验证验证码
- 向 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, "内部错误,请联系管理员"); } }
- 从 Session 中获取邮箱
- 调用服务:向数据库更新用户密码,如果出错,返回错误响应
- 删除 Session 中的邮箱
Service
验证验证码服务
- 从 Redis 中获取验证码
- 验证码比对
- 是否存在 → 不存在则返回错误信息:“请先请求一封验证码邮件”
- 是否过期 → 已过期则返回错误信息:“验证码失效,请重新请求”
- 是否一致 → 不一致则返回错误信息:“验证码错误,请检查后再提交”
- 验证码比对通过后,删除 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 "请先请求一封验证码邮件";
}
}
数据库信息更新服务
- 密码加密
- 通过
Userapper
更新数据库信息
@Override
public boolean resetPassword(String password, String email) {
password = encoder.encode(password);
return mapper.resetPasswordByEmail(password, email) > 0;
}
项目总结
前端
-
Vue 项目结构了解
-
关键业务
-
路由配置
-
表单填写
-
重置密码
- 进度控制
- 邮件验证码
- 按键启用
- 按键冷却
-
用户态信息存取
-
后端
- Spring Security 配置
- 拦截链配置
- 验证成功处理器
- 验证失败处理器
- 记住我功能 —— Token 持久化配置处理器
- 跨域问题处理
- 请求拦截器配置
- 配置中心
- 拦截器
- 发动邮件验证码功能
- 用户注册功能(集成邮箱验证码)
- 重置密码功能(集成邮箱验证码)